diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 7584334a7b..65fbf0f3d6 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -6,7 +6,7 @@ runs: - name: Mount Bun Cache uses: useblacksmith/stickydisk@v1 with: - key: ${{ github.repository }}-bun-cache + key: ${{ github.repository }}-bun-cache-${{ runner.os }} path: ~/.bun - name: Setup Bun diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2a36c07e14..647b9e1886 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,8 +7,32 @@ on: pull_request: workflow_dispatch: jobs: - test: - name: test (${{ matrix.settings.name }}) + unit: + name: unit (linux) + runs-on: blacksmith-4vcpu-ubuntu-2404 + defaults: + run: + shell: bash + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + - name: Configure git identity + run: | + git config --global user.email "bot@opencode.ai" + git config --global user.name "opencode" + + - name: Run unit tests + run: bun turbo test + + e2e: + name: e2e (${{ matrix.settings.name }}) + needs: unit strategy: fail-fast: false matrix: @@ -16,17 +40,12 @@ jobs: - name: linux host: blacksmith-4vcpu-ubuntu-2404 playwright: bunx playwright install --with-deps - workdir: . - command: | - git config --global user.email "bot@opencode.ai" - git config --global user.name "opencode" - bun turbo test - name: windows - host: windows-latest + host: blacksmith-4vcpu-windows-2025 playwright: bunx playwright install - workdir: packages/app - command: bun test:e2e:local runs-on: ${{ matrix.settings.host }} + env: + PLAYWRIGHT_BROWSERS_PATH: 0 defaults: run: shell: bash @@ -43,87 +62,10 @@ jobs: working-directory: packages/app run: ${{ matrix.settings.playwright }} - - name: Set OS-specific paths - run: | - if [ "${{ runner.os }}" = "Windows" ]; then - printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}\\opencode-e2e" >> "$GITHUB_ENV" - printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}\\opencode-e2e\\home" >> "$GITHUB_ENV" - printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}\\opencode-e2e\\share" >> "$GITHUB_ENV" - printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}\\opencode-e2e\\cache" >> "$GITHUB_ENV" - printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}\\opencode-e2e\\config" >> "$GITHUB_ENV" - printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}\\opencode-e2e\\state" >> "$GITHUB_ENV" - else - printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}/opencode-e2e" >> "$GITHUB_ENV" - printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}/opencode-e2e/home" >> "$GITHUB_ENV" - printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}/opencode-e2e/share" >> "$GITHUB_ENV" - printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}/opencode-e2e/cache" >> "$GITHUB_ENV" - printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}/opencode-e2e/config" >> "$GITHUB_ENV" - printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}/opencode-e2e/state" >> "$GITHUB_ENV" - fi - - - name: Seed opencode data - if: matrix.settings.name != 'windows' - working-directory: packages/opencode - run: bun script/seed-e2e.ts - env: - OPENCODE_DISABLE_SHARE: "true" - OPENCODE_DISABLE_LSP_DOWNLOAD: "true" - OPENCODE_DISABLE_DEFAULT_PLUGINS: "true" - OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" - OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }} - XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }} - XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }} - XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }} - XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }} - OPENCODE_E2E_PROJECT_DIR: ${{ github.workspace }} - OPENCODE_E2E_SESSION_TITLE: "E2E Session" - OPENCODE_E2E_MESSAGE: "Seeded for UI e2e" - OPENCODE_E2E_MODEL: "opencode/gpt-5-nano" - - - name: Run opencode server - if: matrix.settings.name != 'windows' - working-directory: packages/opencode - run: bun dev -- --print-logs --log-level WARN serve --port 4096 --hostname 127.0.0.1 & - env: - OPENCODE_DISABLE_SHARE: "true" - OPENCODE_DISABLE_LSP_DOWNLOAD: "true" - OPENCODE_DISABLE_DEFAULT_PLUGINS: "true" - OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" - OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }} - XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }} - XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }} - XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }} - XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }} - OPENCODE_CLIENT: "app" - - - name: Wait for opencode server - if: matrix.settings.name != 'windows' - run: | - for i in {1..120}; do - curl -fsS "http://127.0.0.1:4096/global/health" > /dev/null && exit 0 - sleep 1 - done - exit 1 - - - name: run - working-directory: ${{ matrix.settings.workdir }} - run: ${{ matrix.settings.command }} + - name: Run app e2e tests + run: bun --cwd packages/app test:e2e:local env: CI: true - OPENCODE_DISABLE_SHARE: "true" - OPENCODE_DISABLE_LSP_DOWNLOAD: "true" - OPENCODE_DISABLE_DEFAULT_PLUGINS: "true" - OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" - OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }} - XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }} - XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }} - XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }} - XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }} - PLAYWRIGHT_SERVER_HOST: "127.0.0.1" - PLAYWRIGHT_SERVER_PORT: "4096" - VITE_OPENCODE_SERVER_HOST: "127.0.0.1" - VITE_OPENCODE_SERVER_PORT: "4096" - OPENCODE_CLIENT: "app" timeout-minutes: 30 - name: Upload Playwright artifacts @@ -136,3 +78,18 @@ jobs: path: | packages/app/e2e/test-results packages/app/e2e/playwright-report + + required: + name: test (linux) + runs-on: blacksmith-4vcpu-ubuntu-2404 + needs: + - unit + - e2e + if: always() + steps: + - name: Verify upstream test jobs passed + run: | + echo "unit=${{ needs.unit.result }}" + echo "e2e=${{ needs.e2e.result }}" + test "${{ needs.unit.result }}" = "success" + test "${{ needs.e2e.result }}" = "success" diff --git a/.prettierignore b/.prettierignore index 5f86f710fb..a2a2776596 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,2 @@ sst-env.d.ts -desktop/src/bindings.ts +packages/desktop/src/bindings.ts diff --git a/AGENTS.md b/AGENTS.md index eeec0c3418..d51134c0e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ - To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`. - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. - The default branch in this repo is `dev`. +- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. - Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility. ## Style Guide diff --git a/README.bs.md b/README.bs.md new file mode 100644 index 0000000000..56a1e72fb6 --- /dev/null +++ b/README.bs.md @@ -0,0 +1,136 @@ +

+ + + + + OpenCode logo + + +

+

OpenCode je open source AI agent za programiranje.

+

+ Discord + npm + Build status +

+ +

+ English | + 简体中文 | + 繁體中文 | + 한국어 | + Deutsch | + Español | + Français | + Italiano | + Dansk | + 日本語 | + Polski | + Русский | + Bosanski | + العربية | + Norsk | + Português (Brasil) | + ไทย | + Türkçe +

+ +[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) + +--- + +### Instalacija + +```bash +# YOLO +curl -fsSL https://opencode.ai/install | bash + +# Package manageri +npm i -g opencode-ai@latest # ili bun/pnpm/yarn +scoop install opencode # Windows +choco install opencode # Windows +brew install anomalyco/tap/opencode # macOS i Linux (preporučeno, uvijek ažurno) +brew install opencode # macOS i Linux (zvanična brew formula, rjeđe se ažurira) +paru -S opencode-bin # Arch Linux +mise use -g opencode # Bilo koji OS +nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji dev branch +``` + +> [!TIP] +> Ukloni verzije starije od 0.1.x prije instalacije. + +### Desktop aplikacija (BETA) + +OpenCode je dostupan i kao desktop aplikacija. Preuzmi je direktno sa [stranice izdanja](https://github.com/anomalyco/opencode/releases) ili sa [opencode.ai/download](https://opencode.ai/download). + +| Platforma | Preuzimanje | +| --------------------- | ------------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | +| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm`, ili AppImage | + +```bash +# macOS (Homebrew) +brew install --cask opencode-desktop +# Windows (Scoop) +scoop bucket add extras; scoop install extras/opencode-desktop +``` + +#### Instalacijski direktorij + +Instalacijska skripta koristi sljedeći redoslijed prioriteta za putanju instalacije: + +1. `$OPENCODE_INSTALL_DIR` - Prilagođeni instalacijski direktorij +2. `$XDG_BIN_DIR` - Putanja usklađena sa XDG Base Directory specifikacijom +3. `$HOME/bin` - Standardni korisnički bin direktorij (ako postoji ili se može kreirati) +4. `$HOME/.opencode/bin` - Podrazumijevana rezervna lokacija + +```bash +# Primjeri +OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash +``` + +### Agenti + +OpenCode uključuje dva ugrađena agenta između kojih možeš prebacivati tasterom `Tab`. + +- **build** - Podrazumijevani agent sa punim pristupom za razvoj +- **plan** - Agent samo za čitanje za analizu i istraživanje koda + - Podrazumijevano zabranjuje izmjene datoteka + - Traži dozvolu prije pokretanja bash komandi + - Idealan za istraživanje nepoznatih codebase-ova ili planiranje izmjena + +Uključen je i **general** pod-agent za složene pretrage i višekoračne zadatke. +Koristi se interno i može se pozvati pomoću `@general` u porukama. + +Saznaj više o [agentima](https://opencode.ai/docs/agents). + +### Dokumentacija + +Za više informacija o konfiguraciji OpenCode-a, [**pogledaj dokumentaciju**](https://opencode.ai/docs). + +### Doprinosi + +Ako želiš doprinositi OpenCode-u, pročitaj [upute za doprinošenje](./CONTRIBUTING.md) prije slanja pull requesta. + +### Gradnja na OpenCode-u + +Ako radiš na projektu koji je povezan s OpenCode-om i koristi "opencode" kao dio naziva, npr. "opencode-dashboard" ili "opencode-mobile", dodaj napomenu u svoj README da projekat nije napravio OpenCode tim i da nije povezan s nama. + +### FAQ + +#### Po čemu se razlikuje od Claude Code-a? + +Po mogućnostima je vrlo sličan Claude Code-u. Ključne razlike su: + +- 100% open source +- Nije vezan za jednog provajdera. Iako preporučujemo modele koje nudimo kroz [OpenCode Zen](https://opencode.ai/zen), OpenCode možeš koristiti s Claude, OpenAI, Google ili čak lokalnim modelima. Kako modeli napreduju, razlike među njima će se smanjivati, a cijene padati, zato je nezavisnost od provajdera važna. +- LSP podrška odmah po instalaciji +- Fokus na TUI. OpenCode grade neovim korisnici i kreatori [terminal.shop](https://terminal.shop); pomjeraćemo granice onoga što je moguće u terminalu. +- Klijent/server arhitektura. To, recimo, omogućava da OpenCode radi na tvom računaru dok ga daljinski koristiš iz mobilne aplikacije, što znači da je TUI frontend samo jedan od mogućih klijenata. + +--- + +**Pridruži se našoj zajednici** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.md b/README.md index 671a9072bf..2cd1e2aa01 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ 日本語 | Polski | Русский | + Bosanski | العربية | Norsk | Português (Brasil) | diff --git a/bun.lock b/bun.lock index 91b9fa3399..35de988870 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.51", + "version": "1.1.53", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -44,7 +44,7 @@ "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", "fuzzysort": "catalog:", - "ghostty-web": "0.3.0", + "ghostty-web": "0.4.0", "luxon": "catalog:", "marked": "catalog:", "marked-shiki": "catalog:", @@ -73,7 +73,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.51", + "version": "1.1.53", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -107,7 +107,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.51", + "version": "1.1.53", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -134,7 +134,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.51", + "version": "1.1.53", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -158,7 +158,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.51", + "version": "1.1.53", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -182,12 +182,13 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.1.51", + "version": "1.1.53", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", + "@solidjs/meta": "catalog:", "@tauri-apps/api": "^2", "@tauri-apps/plugin-deep-link": "~2", "@tauri-apps/plugin-dialog": "~2", @@ -213,7 +214,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.51", + "version": "1.1.53", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -242,7 +243,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.51", + "version": "1.1.53", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -258,14 +259,14 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.51", + "version": "1.1.53", "bin": { "opencode": "./bin/opencode", }, "dependencies": { "@actions/core": "1.11.1", "@actions/github": "6.0.1", - "@agentclientprotocol/sdk": "0.13.0", + "@agentclientprotocol/sdk": "0.14.1", "@ai-sdk/amazon-bedrock": "3.0.74", "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/azure": "2.0.91", @@ -286,7 +287,8 @@ "@ai-sdk/vercel": "1.0.33", "@ai-sdk/xai": "2.0.51", "@clack/prompts": "1.0.0-alpha.1", - "@gitlab/gitlab-ai-provider": "3.4.0", + "@gitlab/gitlab-ai-provider": "3.5.0", + "@gitlab/opencode-gitlab-auth": "1.3.2", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", @@ -363,7 +365,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.51", + "version": "1.1.53", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -383,7 +385,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.51", + "version": "1.1.53", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -394,7 +396,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.51", + "version": "1.1.53", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -407,7 +409,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.51", + "version": "1.1.53", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -449,7 +451,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.51", + "version": "1.1.53", "dependencies": { "zod": "catalog:", }, @@ -460,7 +462,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.51", + "version": "1.1.53", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -495,9 +497,6 @@ "web-tree-sitter", "tree-sitter-bash", ], - "patchedDependencies": { - "ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch", - }, "overrides": { "@types/bun": "catalog:", "@types/node": "catalog:", @@ -558,7 +557,7 @@ "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], - "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.13.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Z6/Fp4cXLbYdMXr5AK752JM5qG2VKb6ShM0Ql6FimBSckMmLyK54OA20UhPYoH4C37FSFwUTARuwQOwQUToYrw=="], + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.14.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w=="], "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="], @@ -920,8 +919,22 @@ "@expressive-code/plugin-text-markers": ["@expressive-code/plugin-text-markers@0.41.6", "", { "dependencies": { "@expressive-code/core": "^0.41.6" } }, "sha512-PBFa1wGyYzRExMDzBmAWC6/kdfG1oLn4pLpBeTfIRrALPjcGA/59HP3e7q9J0Smk4pC7U+lWkA2LHR8FYV8U7Q=="], + "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="], + "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + "@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="], + + "@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="], + + "@fastify/forwarded": ["@fastify/forwarded@3.0.1", "", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="], + + "@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="], + + "@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="], + + "@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="], + "@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="], @@ -934,7 +947,9 @@ "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], - "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.4.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-1fEZgqjSZ0WLesftw/J5UtFuJCYFDvCZCHhTH5PZAmpDEmCwllJBoe84L3+vIk38V2FGDMTW128iKTB2mVzr3A=="], + "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.5.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-OoAwCz4fOci3h/2l+PRHMclclh3IaFq8w1es2wvBJ8ca7vtglKsBYT7dvmYpsXlu7pg9mopbjcexvmVCQEUTAQ=="], + + "@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.2", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-pvGrC+aDVLY8bRCC/fZaG/Qihvt2r4by5xbTo5JTSz9O7yIcR6xG2d9Wkuu4bcXFz674z2C+i5bUk+J/RSdBpg=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], @@ -1128,6 +1143,8 @@ "@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="], + "@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="], + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], @@ -1368,6 +1385,8 @@ "@pierre/diffs": ["@pierre/diffs@1.0.2", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/transformers": "3.19.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.19.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-RkFSDD5X/U+8QjyilPViYGJfmJNWXR17zTL8zw48+DcVC1Ujbh6I1edyuRnFfgRzpft05x2DSCkz2cjoIAxPvQ=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@planetscale/database": ["@planetscale/database@1.19.0", "", {}, "sha512-Tv4jcFUFAFjOWrGSio49H6R2ijALv0ZzVBfJKIdm+kl9X046Fh4LLawrF9OMsglVbK6ukqMJsUCeucGAFTBcMA=="], @@ -1944,6 +1963,8 @@ "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="], + "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], @@ -2020,10 +2041,14 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + "autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "avvio": ["avvio@9.1.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw=="], + "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], "aws-sdk": ["aws-sdk@2.1692.0", "", { "dependencies": { "buffer": "4.9.2", "events": "1.1.1", "ieee754": "1.1.13", "jmespath": "0.16.0", "querystring": "0.2.0", "sax": "1.2.1", "url": "0.10.3", "util": "^0.12.4", "uuid": "8.0.0", "xml2js": "0.6.2" } }, "sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw=="], @@ -2466,16 +2491,26 @@ "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + "fast-json-stringify": ["fast-json-stringify@6.2.0", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-Eaf/KNIDwHkzfyeQFNfLXJnQ7cl1XQI3+zRqmPlvtkMigbXnAcasTrvJQmquBSxKfFGeRA6PFog8t+hFmpDoWw=="], + + "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], "fast-xml-parser": ["fast-xml-parser@4.4.1", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw=="], + "fastify": ["fastify@5.7.4", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA=="], + + "fastify-plugin": ["fastify-plugin@5.1.0", "", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="], + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -2490,6 +2525,8 @@ "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], + "find-my-way": ["find-my-way@9.4.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w=="], + "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], "finity": ["finity@0.5.4", "", {}, "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA=="], @@ -2566,7 +2603,7 @@ "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], - "ghostty-web": ["ghostty-web@0.3.0", "", {}, "sha512-SAdSHWYF20GMZUB0n8kh1N6Z4ljMnuUqT8iTB2n5FAPswEV10MejEpLlhW/769GL5+BQa1NYwEg9y/XCckV5+A=="], + "ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], @@ -2856,6 +2893,8 @@ "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="], + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -2892,6 +2931,8 @@ "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], + "light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="], + "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], @@ -3194,6 +3235,8 @@ "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -3300,6 +3343,12 @@ "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], + "pino": ["pino@10.3.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA=="], + + "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], + + "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], @@ -3352,6 +3401,8 @@ "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + "promise.allsettled": ["promise.allsettled@1.0.7", "", { "dependencies": { "array.prototype.map": "^1.0.5", "call-bind": "^1.0.2", "define-properties": "^1.2.0", "es-abstract": "^1.22.1", "get-intrinsic": "^1.2.1", "iterate-value": "^1.0.2" } }, "sha512-hezvKvQQmsFkOdrZfYxUxkyxl8mgFQeT259Ajj9PXdbg9VzBCWrItOev72JyWxkCD5VSSqAeHmlN3tWx4DlmsA=="], "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], @@ -3374,6 +3425,8 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], @@ -3408,6 +3461,8 @@ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], "recma-jsx": ["recma-jsx@1.0.1", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" }, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w=="], @@ -3474,6 +3529,8 @@ "restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="], + "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], + "retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="], "retext-latin": ["retext-latin@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "parse-latin": "^7.0.0", "unified": "^11.0.0" } }, "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA=="], @@ -3486,6 +3543,8 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], "rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="], @@ -3508,6 +3567,10 @@ "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + "safe-regex2": ["safe-regex2@5.0.0", "", { "dependencies": { "ret": "~0.5.0" } }, "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "sax": ["sax@1.2.1", "", {}, "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA=="], @@ -3516,6 +3579,8 @@ "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], + "selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="], "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -3530,6 +3595,8 @@ "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], @@ -3592,6 +3659,8 @@ "solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="], + "sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -3600,6 +3669,8 @@ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], "sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="], @@ -3702,6 +3773,8 @@ "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="], + "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], "thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="], @@ -4084,6 +4157,8 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], + "@gitlab/gitlab-ai-provider/openai": ["openai@6.17.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-NHRpPEUPzAvFOAFs9+9pC6+HCw/iWsYsKCMPXH5Kw7BpMxqd8g/A07/1o7Gx2TWtCnzevVRyKMRFqyiHyAlqcA=="], "@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -4384,6 +4459,8 @@ "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], + "lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="], diff --git a/nix/hashes.json b/nix/hashes.json index 9ba6b762c6..0bb59650f6 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-OJ4a65oeJpSXfmL2tNslGhgvo1WPNBLL/5pWlDOK0jY=", - "aarch64-linux": "sha256-DoJcXOKb/SNN78Fp4z3sQSlzPLaUQ0Ss/jZa2tL6hdc=", - "aarch64-darwin": "sha256-jtMqFH7uYzgI1ICASAePJOj1QeaQfEWuoME7gyrvinA=", - "x86_64-darwin": "sha256-doY5oQKtOlmF4aegKvchU1M86zDjGNfMwE+GvfpZa0g=" + "x86_64-linux": "sha256-FMrW0aXYOgRe3ginr4l1LwCszsD/r5CQkvRU6HHA7iw=", + "aarch64-linux": "sha256-NZTtIsFZshWOp5mVFvrcVeHUlx62QcsSJKPYjwPhmYk=", + "aarch64-darwin": "sha256-6cWt8KaqojTJ/b3WSYb3dDPTNuKBDt9Fxx6p/WGBnik=", + "x86_64-darwin": "sha256-F6zuxV34RQ9RTjH0c22rGZaPrhemhRUPi+OkF+Y0ytM=" } } diff --git a/package.json b/package.json index b052e2bbf4..65cd0dea80 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,5 @@ "@types/bun": "catalog:", "@types/node": "catalog:" }, - "patchedDependencies": { - "ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch" - } + "patchedDependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index 3a492ed919..a995880e01 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,11 +1,12 @@ { "name": "@opencode-ai/app", - "version": "1.1.51", + "version": "1.1.53", "description": "", "type": "module", "exports": { ".": "./src/index.ts", - "./vite": "./vite.js" + "./vite": "./vite.js", + "./index.css": "./src/index.css" }, "scripts": { "typecheck": "tsgo -b", @@ -13,7 +14,9 @@ "dev": "vite", "build": "vite build", "serve": "vite preview", - "test": "playwright test", + "test": "bun run test:unit", + "test:unit": "bun test --preload ./happydom.ts ./src", + "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:e2e": "playwright test", "test:e2e:local": "bun script/e2e-local.ts", "test:e2e:ui": "playwright test --ui", @@ -54,7 +57,7 @@ "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", "fuzzysort": "catalog:", - "ghostty-web": "0.3.0", + "ghostty-web": "0.4.0", "luxon": "catalog:", "marked": "catalog:", "marked-shiki": "catalog:", diff --git a/packages/app/src/addons/serialize.test.ts b/packages/app/src/addons/serialize.test.ts index 7fb1a61f35..7f6780557d 100644 --- a/packages/app/src/addons/serialize.test.ts +++ b/packages/app/src/addons/serialize.test.ts @@ -36,7 +36,7 @@ function writeAndWait(term: Terminal, data: string): Promise { }) } -describe.skip("SerializeAddon", () => { +describe("SerializeAddon", () => { describe("ANSI color preservation", () => { test("should preserve text attributes (bold, italic, underline)", async () => { const { term, addon } = createTerminal() diff --git a/packages/app/src/addons/serialize.ts b/packages/app/src/addons/serialize.ts index 3f0a8fb0aa..4cab55b3f2 100644 --- a/packages/app/src/addons/serialize.ts +++ b/packages/app/src/addons/serialize.ts @@ -56,6 +56,39 @@ interface IBufferCell { isDim(): boolean } +type TerminalBuffers = { + active?: IBuffer + normal?: IBuffer + alternate?: IBuffer +} + +const isRecord = (value: unknown): value is Record => { + return typeof value === "object" && value !== null +} + +const isBuffer = (value: unknown): value is IBuffer => { + if (!isRecord(value)) return false + if (typeof value.length !== "number") return false + if (typeof value.cursorX !== "number") return false + if (typeof value.cursorY !== "number") return false + if (typeof value.baseY !== "number") return false + if (typeof value.viewportY !== "number") return false + if (typeof value.getLine !== "function") return false + if (typeof value.getNullCell !== "function") return false + return true +} + +const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined => { + if (!isRecord(value)) return + const raw = value.buffer + if (!isRecord(raw)) return + const active = isBuffer(raw.active) ? raw.active : undefined + const normal = isBuffer(raw.normal) ? raw.normal : undefined + const alternate = isBuffer(raw.alternate) ? raw.alternate : undefined + if (!active && !normal) return + return { active, normal, alternate } +} + // ============================================================================ // Types // ============================================================================ @@ -498,14 +531,13 @@ export class SerializeAddon implements ITerminalAddon { throw new Error("Cannot use addon until it has been loaded") } - const terminal = this._terminal as any - const buffer = terminal.buffer + const buffer = getTerminalBuffers(this._terminal) if (!buffer) { return "" } - const normalBuffer = buffer.normal || buffer.active + const normalBuffer = buffer.normal ?? buffer.active const altBuffer = buffer.alternate if (!normalBuffer) { @@ -533,14 +565,13 @@ export class SerializeAddon implements ITerminalAddon { throw new Error("Cannot use addon until it has been loaded") } - const terminal = this._terminal as any - const buffer = terminal.buffer + const buffer = getTerminalBuffers(this._terminal) if (!buffer) { return "" } - const activeBuffer = buffer.active || buffer.normal + const activeBuffer = buffer.active ?? buffer.normal if (!activeBuffer) { return "" } diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 11fdb57432..8a111472ba 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -30,7 +30,7 @@ import { HighlightsProvider } from "@/context/highlights" import Layout from "@/pages/layout" import DirectoryLayout from "@/pages/directory-layout" import { ErrorPage } from "./pages/error" -import { Suspense } from "solid-js" +import { Suspense, JSX } from "solid-js" const Home = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) @@ -84,7 +84,7 @@ function ServerKey(props: ParentProps) { ) } -export function AppInterface(props: { defaultUrl?: string }) { +export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element }) { const platform = usePlatform() const stored = (() => { @@ -111,7 +111,7 @@ export function AppInterface(props: { defaultUrl?: string }) { ( + root={(routerProps) => ( @@ -119,7 +119,10 @@ export function AppInterface(props: { defaultUrl?: string }) { - {props.children} + + {props.children} + {routerProps.children} + diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 28a947f3b3..53773ed9ea 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -124,16 +124,16 @@ export function DialogCustomProvider(props: Props) { const key = apiKey && !env ? apiKey : undefined const idError = !providerID - ? "Provider ID is required" + ? language.t("provider.custom.error.providerID.required") : !PROVIDER_ID.test(providerID) - ? "Use lowercase letters, numbers, hyphens, or underscores" + ? language.t("provider.custom.error.providerID.format") : undefined - const nameError = !name ? "Display name is required" : undefined + const nameError = !name ? language.t("provider.custom.error.name.required") : undefined const urlError = !baseURL - ? "Base URL is required" + ? language.t("provider.custom.error.baseURL.required") : !/^https?:\/\//.test(baseURL) - ? "Must start with http:// or https://" + ? language.t("provider.custom.error.baseURL.format") : undefined const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID) @@ -141,21 +141,21 @@ export function DialogCustomProvider(props: Props) { const existsError = idError ? undefined : existingProvider && !disabled - ? "That provider ID already exists" + ? language.t("provider.custom.error.providerID.exists") : undefined const seenModels = new Set() const modelErrors = form.models.map((m) => { const id = m.id.trim() const modelIdError = !id - ? "Required" + ? language.t("provider.custom.error.required") : seenModels.has(id) - ? "Duplicate" + ? language.t("provider.custom.error.duplicate") : (() => { seenModels.add(id) return undefined })() - const modelNameError = !m.name.trim() ? "Required" : undefined + const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined return { id: modelIdError, name: modelNameError } }) const modelsValid = modelErrors.every((m) => !m.id && !m.name) @@ -168,14 +168,14 @@ export function DialogCustomProvider(props: Props) { if (!key && !value) return {} const keyError = !key - ? "Required" + ? language.t("provider.custom.error.required") : seenHeaders.has(key.toLowerCase()) - ? "Duplicate" + ? language.t("provider.custom.error.duplicate") : (() => { seenHeaders.add(key.toLowerCase()) return undefined })() - const valueError = !value ? "Required" : undefined + const valueError = !value ? language.t("provider.custom.error.required") : undefined return { key: keyError, value: valueError } }) const headersValid = headerErrors.every((h) => !h.key && !h.value) @@ -278,64 +278,64 @@ export function DialogCustomProvider(props: Props) {
-
Custom provider
+
{language.t("provider.custom.title")}

- Configure an OpenAI-compatible provider. See the{" "} + {language.t("provider.custom.description.prefix")} - provider config docs + {language.t("provider.custom.description.link")} - . + {language.t("provider.custom.description.suffix")}

- + {(m, i) => (
setForm("models", i(), "id", v)} validationState={errors.models[i()]?.id ? "invalid" : undefined} @@ -344,9 +344,9 @@ export function DialogCustomProvider(props: Props) {
setForm("models", i(), "name", v)} validationState={errors.models[i()]?.name ? "invalid" : undefined} @@ -360,26 +360,26 @@ export function DialogCustomProvider(props: Props) { class="mt-1.5" onClick={() => removeModel(i())} disabled={form.models.length <= 1} - aria-label="Remove model" + aria-label={language.t("provider.custom.models.remove")} />
)}
- + {(h, i) => (
setForm("headers", i(), "key", v)} validationState={errors.headers[i()]?.key ? "invalid" : undefined} @@ -388,9 +388,9 @@ export function DialogCustomProvider(props: Props) {
setForm("headers", i(), "value", v)} validationState={errors.headers[i()]?.value ? "invalid" : undefined} @@ -404,18 +404,18 @@ export function DialogCustomProvider(props: Props) { class="mt-1.5" onClick={() => removeHeader(i())} disabled={form.headers.length <= 1} - aria-label="Remove header" + aria-label={language.t("provider.custom.headers.remove")} />
)}
diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 167f211953..8e221577b9 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -15,6 +15,7 @@ import { useLayout } from "@/context/layout" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { decode64 } from "@/utils/base64" +import { getRelativeTime } from "@/utils/time" type EntryType = "command" | "file" | "session" @@ -30,6 +31,7 @@ type Entry = { directory?: string sessionID?: string archived?: number + updated?: number } type DialogSelectFileMode = "all" | "files" @@ -47,6 +49,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil const filesOnly = () => props.mode === "files" const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) + const view = createMemo(() => layout.view(sessionKey)) const state = { cleanup: undefined as (() => void) | void, committed: false } const [grouped, setGrouped] = createSignal(false) const common = [ @@ -119,6 +122,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil title: string description: string archived?: number + updated?: number }): Entry => ({ id: `session:${input.directory}:${input.id}`, type: "session", @@ -128,6 +132,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil directory: input.directory, sessionID: input.id, archived: input.archived, + updated: input.updated, }) const list = createMemo(() => allowed().map(commandItem)) @@ -213,6 +218,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil description, directory, archived: s.time?.archived, + updated: s.time?.updated, })), ) .catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[]) @@ -282,6 +288,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil const value = file.tab(path) tabs().open(value) file.load(path) + if (!view().reviewPanel.opened()) view().reviewPanel.open() layout.fileTree.open() layout.fileTree.setTab("all") props.onOpenFile?.(path) @@ -382,6 +389,11 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
+ + + {getRelativeTime(new Date(item.updated!).toISOString())} + + diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 3d0d6c7938..26021f06aa 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -87,11 +87,13 @@ const ModelList: Component<{ ) } -export function ModelSelectorPopover(props: { +type ModelSelectorTriggerProps = Omit, "as" | "ref"> + +export function ModelSelectorPopover(props: { provider?: string children?: JSX.Element - triggerAs?: T - triggerProps?: ComponentProps + triggerAs?: ValidComponent + triggerProps?: ModelSelectorTriggerProps }) { const [store, setStore] = createStore<{ open: boolean @@ -176,11 +178,7 @@ export function ModelSelectorPopover(props: { placement="top-start" gutter={8} > - setStore("trigger", el)} - as={props.triggerAs ?? "div"} - {...(props.triggerProps as any)} - > + setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}> {props.children} diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index e9e7646d5a..65b679f70a 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -1,4 +1,4 @@ -import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } from "solid-js" +import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -6,17 +6,15 @@ import { List } from "@opencode-ai/ui/list" import { Button } from "@opencode-ai/ui/button" import { IconButton } from "@opencode-ai/ui/icon-button" import { TextField } from "@opencode-ai/ui/text-field" -import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server" +import { normalizeServerUrl, useServer } from "@/context/server" import { usePlatform } from "@/context/platform" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { useNavigate } from "@solidjs/router" import { useLanguage } from "@/context/language" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Tooltip } from "@opencode-ai/ui/tooltip" import { useGlobalSDK } from "@/context/global-sdk" import { showToast } from "@opencode-ai/ui/toast" - -type ServerStatus = { healthy: boolean; version?: string } +import { ServerRow } from "@/components/server/server-row" +import { checkServerHealth, type ServerHealth } from "@/utils/server-health" interface AddRowProps { value: string @@ -40,19 +38,6 @@ interface EditRowProps { onBlur: () => void } -async function checkHealth(url: string, platform: ReturnType): Promise { - const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000) - const sdk = createOpencodeClient({ - baseUrl: url, - fetch: platform.fetch, - signal, - }) - return sdk.global - .health() - .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version })) - .catch(() => ({ healthy: false })) -} - function AddRow(props: AddRowProps) { return (
@@ -131,7 +116,7 @@ export function DialogSelectServer() { const globalSDK = useGlobalSDK() const language = useLanguage() const [store, setStore] = createStore({ - status: {} as Record, + status: {} as Record, addServer: { url: "", adding: false, @@ -165,6 +150,7 @@ export function DialogSelectServer() { { initialValue: null }, ) const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl) + const fetcher = platform.fetch ?? globalThis.fetch const looksComplete = (value: string) => { const normalized = normalizeServerUrl(value) @@ -180,7 +166,7 @@ export function DialogSelectServer() { if (!looksComplete(value)) return const normalized = normalizeServerUrl(value) if (!normalized) return - const result = await checkHealth(normalized, platform) + const result = await checkServerHealth(normalized, fetcher) setStatus(result.healthy) } @@ -227,7 +213,7 @@ export function DialogSelectServer() { if (!list.length) return list const active = current() const order = new Map(list.map((url, index) => [url, index] as const)) - const rank = (value?: ServerStatus) => { + const rank = (value?: ServerHealth) => { if (value?.healthy === true) return 0 if (value?.healthy === false) return 2 return 1 @@ -242,10 +228,10 @@ export function DialogSelectServer() { }) async function refreshHealth() { - const results: Record = {} + const results: Record = {} await Promise.all( items().map(async (url) => { - results[url] = await checkHealth(url, platform) + results[url] = await checkServerHealth(url, fetcher) }), ) setStore("status", reconcile(results)) @@ -300,7 +286,7 @@ export function DialogSelectServer() { setStore("addServer", { adding: true, error: "" }) - const result = await checkHealth(normalized, platform) + const result = await checkServerHealth(normalized, fetcher) setStore("addServer", { adding: false }) if (!result.healthy) { @@ -327,7 +313,7 @@ export function DialogSelectServer() { setStore("editServer", { busy: true, error: "" }) - const result = await checkHealth(normalized, platform) + const result = await checkServerHealth(normalized, fetcher) setStore("editServer", { busy: false }) if (!result.healthy) { @@ -369,6 +355,9 @@ export function DialogSelectServer() { async function handleRemove(url: string) { server.remove(url) + if ((await platform.getDefaultServerUrl?.()) === url) { + platform.setDefaultServerUrl?.(null) + } } return ( @@ -410,35 +399,6 @@ export function DialogSelectServer() { } > {(i) => { - const [truncated, setTruncated] = createSignal(false) - let nameRef: HTMLSpanElement | undefined - let versionRef: HTMLSpanElement | undefined - - const check = () => { - const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false - const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false - setTruncated(nameTruncated || versionTruncated) - } - - createEffect(() => { - check() - window.addEventListener("resize", check) - onCleanup(() => window.removeEventListener("resize", check)) - }) - - const tooltipValue = () => { - const name = serverDisplayName(i) - const version = store.status[i]?.version - return ( - - {name} - - {version} - - - ) - } - return (
} > - -
-
- - {serverDisplayName(i)} - - - - {store.status[i]?.version} - - + {language.t("dialog.server.status.default")} -
- + } + />
diff --git a/packages/app/src/components/file-tree.test.ts b/packages/app/src/components/file-tree.test.ts new file mode 100644 index 0000000000..eb048e29ed --- /dev/null +++ b/packages/app/src/components/file-tree.test.ts @@ -0,0 +1,77 @@ +import { beforeAll, describe, expect, mock, test } from "bun:test" + +let shouldListRoot: typeof import("./file-tree").shouldListRoot +let shouldListExpanded: typeof import("./file-tree").shouldListExpanded +let dirsToExpand: typeof import("./file-tree").dirsToExpand + +beforeAll(async () => { + mock.module("@solidjs/router", () => ({ + useParams: () => ({}), + })) + mock.module("@/context/file", () => ({ + useFile: () => ({ + tree: { + state: () => undefined, + list: () => Promise.resolve(), + children: () => [], + expand: () => {}, + collapse: () => {}, + }, + }), + })) + mock.module("@opencode-ai/ui/collapsible", () => ({ + Collapsible: { + Trigger: (props: { children?: unknown }) => props.children, + Content: (props: { children?: unknown }) => props.children, + }, + })) + mock.module("@opencode-ai/ui/file-icon", () => ({ FileIcon: () => null })) + mock.module("@opencode-ai/ui/icon", () => ({ Icon: () => null })) + mock.module("@opencode-ai/ui/tooltip", () => ({ Tooltip: (props: { children?: unknown }) => props.children })) + const mod = await import("./file-tree") + shouldListRoot = mod.shouldListRoot + shouldListExpanded = mod.shouldListExpanded + dirsToExpand = mod.dirsToExpand +}) + +describe("file tree fetch discipline", () => { + test("root lists on mount unless already loaded or loading", () => { + expect(shouldListRoot({ level: 0 })).toBe(true) + expect(shouldListRoot({ level: 0, dir: { loaded: true } })).toBe(false) + expect(shouldListRoot({ level: 0, dir: { loading: true } })).toBe(false) + expect(shouldListRoot({ level: 1 })).toBe(false) + }) + + test("nested dirs list only when expanded and stale", () => { + expect(shouldListExpanded({ level: 1 })).toBe(false) + expect(shouldListExpanded({ level: 1, dir: { expanded: false } })).toBe(false) + expect(shouldListExpanded({ level: 1, dir: { expanded: true } })).toBe(true) + expect(shouldListExpanded({ level: 1, dir: { expanded: true, loaded: true } })).toBe(false) + expect(shouldListExpanded({ level: 1, dir: { expanded: true, loading: true } })).toBe(false) + expect(shouldListExpanded({ level: 0, dir: { expanded: true } })).toBe(false) + }) + + test("allowed auto-expand picks only collapsed dirs", () => { + const expanded = new Set() + const filter = { dirs: new Set(["src", "src/components"]) } + + const first = dirsToExpand({ + level: 0, + filter, + expanded: (dir) => expanded.has(dir), + }) + + expect(first).toEqual(["src", "src/components"]) + + for (const dir of first) expanded.add(dir) + + const second = dirsToExpand({ + level: 0, + filter, + expanded: (dir) => expanded.has(dir), + }) + + expect(second).toEqual([]) + expect(dirsToExpand({ level: 1, filter, expanded: () => false })).toEqual([]) + }) +}) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index a4cfe4a90e..183c1555bd 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -8,6 +8,7 @@ import { createMemo, For, Match, + on, Show, splitProps, Switch, @@ -25,6 +26,34 @@ type Filter = { dirs: Set } +export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) { + if (input.level !== 0) return false + if (input.dir?.loaded) return false + if (input.dir?.loading) return false + return true +} + +export function shouldListExpanded(input: { + level: number + dir?: { expanded?: boolean; loaded?: boolean; loading?: boolean } +}) { + if (input.level === 0) return false + if (!input.dir?.expanded) return false + if (input.dir.loaded) return false + if (input.dir.loading) return false + return true +} + +export function dirsToExpand(input: { + level: number + filter?: { dirs: Set } + expanded: (dir: string) => boolean +}) { + if (input.level !== 0) return [] + if (!input.filter) return [] + return [...input.filter.dirs].filter((dir) => !input.expanded(dir)) +} + export default function FileTree(props: { path: string class?: string @@ -111,19 +140,30 @@ export default function FileTree(props: { createEffect(() => { const current = filter() - if (!current) return - if (level !== 0) return - - for (const dir of current.dirs) { - const expanded = untrack(() => file.tree.state(dir)?.expanded) ?? false - if (expanded) continue - file.tree.expand(dir) - } + const dirs = dirsToExpand({ + level, + filter: current, + expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false, + }) + for (const dir of dirs) file.tree.expand(dir) }) + createEffect( + on( + () => props.path, + (path) => { + const dir = untrack(() => file.tree.state(path)) + if (!shouldListRoot({ level, dir })) return + void file.tree.list(path) + }, + { defer: false }, + ), + ) + createEffect(() => { - const path = props.path - untrack(() => void file.tree.list(path)) + const dir = file.tree.state(props.path) + if (!shouldListExpanded({ level, dir })) return + void file.tree.list(props.path) }) const nodes = createMemo(() => { @@ -241,7 +281,7 @@ export default function FileTree(props: { : kind === "del" ? "color: var(--icon-diff-delete-base)" : kind === "mix" - ? "color: var(--icon-diff-modified-base)" + ? "color: var(--icon-warning-active)" : undefined return ( @@ -283,7 +323,7 @@ export default function FileTree(props: { ? "background-color: var(--icon-diff-add-base)" : kind === "del" ? "background-color: var(--icon-diff-delete-base)" - : "background-color: var(--icon-diff-modified-base)" + : "background-color: var(--icon-warning-active)" return
} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index b897e394aa..46d7f93eb3 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1,21 +1,9 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" -import { - createEffect, - on, - Component, - Show, - For, - onMount, - onCleanup, - Switch, - Match, - createMemo, - createSignal, -} from "solid-js" -import { createStore, produce } from "solid-js/store" +import { createEffect, on, Component, Show, For, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js" +import { createStore } from "solid-js/store" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" -import { useFile, type FileSelection } from "@/context/file" +import { useFile } from "@/context/file" import { ContentPart, DEFAULT_PROMPT, @@ -28,10 +16,9 @@ import { } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" -import { useNavigate, useParams } from "@solidjs/router" +import { useParams } from "@solidjs/router" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" -import { FileIcon } from "@opencode-ai/ui/file-icon" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" @@ -39,35 +26,25 @@ import type { IconName } from "@opencode-ai/ui/icons/provider" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" -import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ModelSelectorPopover } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { useProviders } from "@/hooks/use-providers" import { useCommand } from "@/context/command" import { Persist, persisted } from "@/utils/persist" -import { Identifier } from "@/utils/id" -import { Worktree as WorktreeState } from "@/utils/worktree" import { SessionContextUsage } from "@/components/session-context-usage" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" -import { useGlobalSync } from "@/context/global-sync" -import { usePlatform } from "@/context/platform" -import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client" -import { Binary } from "@opencode-ai/util/binary" -import { showToast } from "@opencode-ai/ui/toast" -import { base64Encode } from "@opencode-ai/util/encode" - -const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] -const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] - -type PendingPrompt = { - abort: AbortController - cleanup: VoidFunction -} - -const pending = new Map() +import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" +import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" +import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history" +import { createPromptSubmit } from "./prompt-input/submit" +import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" +import { PromptContextItems } from "./prompt-input/context-items" +import { PromptImageAttachments } from "./prompt-input/image-attachments" +import { PromptDragOverlay } from "./prompt-input/drag-overlay" +import { promptPlaceholder } from "./prompt-input/placeholder" +import { ImagePreview } from "@opencode-ai/ui/image-preview" interface PromptInputProps { class?: string @@ -105,22 +82,9 @@ const EXAMPLES = [ "prompt.example.25", ] as const -interface SlashCommand { - id: string - trigger: string - title: string - description?: string - keybind?: string - type: "builtin" | "custom" - source?: "command" | "mcp" | "skill" -} - export const PromptInput: Component = (props) => { - const navigate = useNavigate() const sdk = useSDK() const sync = useSync() - const globalSync = useGlobalSync() - const platform = usePlatform() const local = useLocal() const files = useFile() const prompt = usePrompt() @@ -172,6 +136,7 @@ export const PromptInput: Component = (props) => { const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) + const view = createMemo(() => layout.view(sessionKey)) const commentInReview = (path: string) => { const sessionID = params.id @@ -190,12 +155,14 @@ export const PromptInput: Component = (props) => { const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path)) if (wantsReview) { + if (!view().reviewPanel.opened()) view().reviewPanel.open() layout.fileTree.open() layout.fileTree.setTab("changes") requestAnimationFrame(() => comments.setFocus(focus)) return } + if (!view().reviewPanel.opened()) view().reviewPanel.open() layout.fileTree.open() layout.fileTree.setTab("all") const tab = files.tab(item.path) @@ -229,8 +196,8 @@ export const PromptInput: Component = (props) => { }, ) const working = createMemo(() => status()?.type !== "idle") - const imageAttachments = createMemo( - () => prompt.current().filter((part) => part.type === "image") as ImageAttachmentPart[], + const imageAttachments = createMemo(() => + prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), ) const [store, setStore] = createStore<{ @@ -250,6 +217,14 @@ export const PromptInput: Component = (props) => { mode: "normal", applyingHistory: false, }) + const placeholder = createMemo(() => + promptPlaceholder({ + mode: store.mode, + commentCount: commentCount(), + example: language.t(EXAMPLES[store.placeholder]), + t: (key, params) => language.t(key as Parameters[0], params as never), + }), + ) const MAX_HISTORY = 100 const [history, setHistory] = persisted( @@ -269,20 +244,6 @@ export const PromptInput: Component = (props) => { }), ) - const clonePromptParts = (prompt: Prompt): Prompt => - prompt.map((part) => { - if (part.type === "text") return { ...part } - if (part.type === "image") return { ...part } - if (part.type === "agent") return { ...part } - return { - ...part, - selection: part.selection ? { ...part.selection } : undefined, - } - }) - - const promptLength = (prompt: Prompt) => - prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0) - const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const length = position === "start" ? 0 : promptLength(p) setStore("applyingHistory", true) @@ -326,110 +287,6 @@ export const PromptInput: Component = (props) => { const [composing, setComposing] = createSignal(false) const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 - const addImageAttachment = async (file: File) => { - if (!ACCEPTED_FILE_TYPES.includes(file.type)) return - - const reader = new FileReader() - reader.onload = () => { - const dataUrl = reader.result as string - const attachment: ImageAttachmentPart = { - type: "image", - id: crypto.randomUUID(), - filename: file.name, - mime: file.type, - dataUrl, - } - const cursorPosition = prompt.cursor() ?? getCursorPosition(editorRef) - prompt.set([...prompt.current(), attachment], cursorPosition) - } - reader.readAsDataURL(file) - } - - const removeImageAttachment = (id: string) => { - const current = prompt.current() - const next = current.filter((part) => part.type !== "image" || part.id !== id) - prompt.set(next, prompt.cursor()) - } - - const handlePaste = async (event: ClipboardEvent) => { - if (!isFocused()) return - const clipboardData = event.clipboardData - if (!clipboardData) return - - event.preventDefault() - event.stopPropagation() - - const items = Array.from(clipboardData.items) - const fileItems = items.filter((item) => item.kind === "file") - const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) - - if (imageItems.length > 0) { - for (const item of imageItems) { - const file = item.getAsFile() - if (file) await addImageAttachment(file) - } - return - } - - if (fileItems.length > 0) { - showToast({ - title: language.t("prompt.toast.pasteUnsupported.title"), - description: language.t("prompt.toast.pasteUnsupported.description"), - }) - return - } - - const plainText = clipboardData.getData("text/plain") ?? "" - if (!plainText) return - addPart({ type: "text", content: plainText, start: 0, end: 0 }) - } - - const handleGlobalDragOver = (event: DragEvent) => { - if (dialog.active) return - - event.preventDefault() - const hasFiles = event.dataTransfer?.types.includes("Files") - if (hasFiles) { - setStore("dragging", true) - } - } - - const handleGlobalDragLeave = (event: DragEvent) => { - if (dialog.active) return - - // relatedTarget is null when leaving the document window - if (!event.relatedTarget) { - setStore("dragging", false) - } - } - - const handleGlobalDrop = async (event: DragEvent) => { - if (dialog.active) return - - event.preventDefault() - setStore("dragging", false) - - const dropped = event.dataTransfer?.files - if (!dropped) return - - for (const file of Array.from(dropped)) { - if (ACCEPTED_FILE_TYPES.includes(file.type)) { - await addImageAttachment(file) - } - } - } - - onMount(() => { - document.addEventListener("dragover", handleGlobalDragOver) - document.addEventListener("dragleave", handleGlobalDragLeave) - document.addEventListener("drop", handleGlobalDrop) - }) - onCleanup(() => { - document.removeEventListener("dragover", handleGlobalDragOver) - document.removeEventListener("dragleave", handleGlobalDragLeave) - document.removeEventListener("drop", handleGlobalDrop) - }) - createEffect(() => { if (!isFocused()) setStore("popover", null) }) @@ -440,10 +297,6 @@ export const PromptInput: Component = (props) => { if (!isFocused()) setComposing(false) }) - type AtOption = - | { type: "agent"; name: string; display: string } - | { type: "file"; path: string; display: string; recent?: boolean } - const agentList = createMemo(() => sync.data.agent .filter((agent) => !agent.hidden && agent.mode !== "primary") @@ -653,7 +506,7 @@ export const PromptInput: Component = (props) => { on( () => prompt.current(), (currentParts) => { - const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt + const inputParts = currentParts.filter((part) => part.type !== "image") if (mirror.input) { mirror.input = false @@ -823,36 +676,6 @@ export const PromptInput: Component = (props) => { queueScroll() } - const setRangeEdge = (range: Range, edge: "start" | "end", offset: number) => { - let remaining = offset - const nodes = Array.from(editorRef.childNodes) - - for (const node of nodes) { - const length = getNodeLength(node) - const isText = node.nodeType === Node.TEXT_NODE - const isPill = - node.nodeType === Node.ELEMENT_NODE && - ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") - const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" - - if (isText && remaining <= length) { - if (edge === "start") range.setStart(node, remaining) - if (edge === "end") range.setEnd(node, remaining) - return - } - - if ((isPill || isBreak) && remaining <= length) { - if (edge === "start" && remaining === 0) range.setStartBefore(node) - if (edge === "start" && remaining > 0) range.setStartAfter(node) - if (edge === "end" && remaining === 0) range.setEndBefore(node) - if (edge === "end" && remaining > 0) range.setEndAfter(node) - return - } - - remaining -= length - } - } - const addPart = (part: ContentPart) => { const selection = window.getSelection() if (!selection || selection.rangeCount === 0) return @@ -870,8 +693,8 @@ export const PromptInput: Component = (props) => { if (atMatch) { const start = atMatch.index ?? cursorPosition - atMatch[0].length - setRangeEdge(range, "start", start) - setRangeEdge(range, "end", cursorPosition) + setRangeEdge(editorRef, range, "start", start) + setRangeEdge(editorRef, range, "end", cursorPosition) } range.deleteContents() @@ -910,82 +733,58 @@ export const PromptInput: Component = (props) => { setStore("popover", null) } - const abort = async () => { - const sessionID = params.id - if (!sessionID) return Promise.resolve() - const queued = pending.get(sessionID) - if (queued) { - queued.abort.abort() - queued.cleanup() - pending.delete(sessionID) - return Promise.resolve() - } - return sdk.client.session - .abort({ - sessionID, - }) - .catch(() => {}) - } - const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { - const text = prompt - .map((p) => ("content" in p ? p.content : "")) - .join("") - .trim() - const hasImages = prompt.some((part) => part.type === "image") - if (!text && !hasImages) return - - const entry = clonePromptParts(prompt) const currentHistory = mode === "shell" ? shellHistory : history const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory - const lastEntry = currentHistory.entries[0] - if (lastEntry && isPromptEqual(lastEntry, entry)) return - - setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY)) + const next = prependHistoryEntry(currentHistory.entries, prompt) + if (next === currentHistory.entries) return + setCurrentHistory("entries", next) } const navigateHistory = (direction: "up" | "down") => { - const entries = store.mode === "shell" ? shellHistory.entries : history.entries - const current = store.historyIndex - - if (direction === "up") { - if (entries.length === 0) return false - if (current === -1) { - setStore("savedPrompt", clonePromptParts(prompt.current())) - setStore("historyIndex", 0) - applyHistoryPrompt(entries[0], "start") - return true - } - if (current < entries.length - 1) { - const next = current + 1 - setStore("historyIndex", next) - applyHistoryPrompt(entries[next], "start") - return true - } - return false - } - - if (current > 0) { - const next = current - 1 - setStore("historyIndex", next) - applyHistoryPrompt(entries[next], "end") - return true - } - if (current === 0) { - setStore("historyIndex", -1) - const saved = store.savedPrompt - if (saved) { - applyHistoryPrompt(saved, "end") - setStore("savedPrompt", null) - return true - } - applyHistoryPrompt(DEFAULT_PROMPT, "end") - return true - } - - return false + const result = navigatePromptHistory({ + direction, + entries: store.mode === "shell" ? shellHistory.entries : history.entries, + historyIndex: store.historyIndex, + currentPrompt: prompt.current(), + savedPrompt: store.savedPrompt, + }) + if (!result.handled) return false + setStore("historyIndex", result.historyIndex) + setStore("savedPrompt", result.savedPrompt) + applyHistoryPrompt(result.prompt, result.cursor) + return true } + const { addImageAttachment, removeImageAttachment, handlePaste } = createPromptAttachments({ + editor: () => editorRef, + isFocused, + isDialogActive: () => !!dialog.active, + setDragging: (value) => setStore("dragging", value), + addPart, + }) + + const { abort, handleSubmit } = createPromptSubmit({ + info, + imageAttachments, + commentCount, + mode: () => store.mode, + working, + editor: () => editorRef, + queueScroll, + promptLength, + addToHistory, + resetHistoryNavigation: () => { + setStore("historyIndex", -1) + setStore("savedPrompt", null) + }, + setMode: (mode) => setStore("mode", mode), + setPopover: (popover) => setStore("popover", popover), + newSessionWorktree: props.newSessionWorktree, + onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, + onSubmit: props.onSubmit, + }) + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Backspace") { const selection = window.getSelection() @@ -1124,609 +923,23 @@ export const PromptInput: Component = (props) => { } } - const handleSubmit = async (event: Event) => { - event.preventDefault() - - const currentPrompt = prompt.current() - const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") - const images = imageAttachments().slice() - const mode = store.mode - - if (text.trim().length === 0 && images.length === 0 && commentCount() === 0) { - if (working()) abort() - return - } - - const currentModel = local.model.current() - const currentAgent = local.agent.current() - if (!currentModel || !currentAgent) { - showToast({ - title: language.t("prompt.toast.modelAgentRequired.title"), - description: language.t("prompt.toast.modelAgentRequired.description"), - }) - return - } - - const errorMessage = (err: unknown) => { - if (err && typeof err === "object" && "data" in err) { - const data = (err as { data?: { message?: string } }).data - if (data?.message) return data.message - } - if (err instanceof Error) return err.message - return language.t("common.requestFailed") - } - - addToHistory(currentPrompt, mode) - setStore("historyIndex", -1) - setStore("savedPrompt", null) - - const projectDirectory = sdk.directory - const isNewSession = !params.id - const worktreeSelection = props.newSessionWorktree ?? "main" - - let sessionDirectory = projectDirectory - let client = sdk.client - - if (isNewSession) { - if (worktreeSelection === "create") { - const createdWorktree = await client.worktree - .create({ directory: projectDirectory }) - .then((x) => x.data) - .catch((err) => { - showToast({ - title: language.t("prompt.toast.worktreeCreateFailed.title"), - description: errorMessage(err), - }) - return undefined - }) - - if (!createdWorktree?.directory) { - showToast({ - title: language.t("prompt.toast.worktreeCreateFailed.title"), - description: language.t("common.requestFailed"), - }) - return - } - WorktreeState.pending(createdWorktree.directory) - sessionDirectory = createdWorktree.directory - } - - if (worktreeSelection !== "main" && worktreeSelection !== "create") { - sessionDirectory = worktreeSelection - } - - if (sessionDirectory !== projectDirectory) { - client = createOpencodeClient({ - baseUrl: sdk.url, - fetch: platform.fetch, - directory: sessionDirectory, - throwOnError: true, - }) - globalSync.child(sessionDirectory) - } - - props.onNewSessionWorktreeReset?.() - } - - let session = info() - if (!session && isNewSession) { - session = await client.session - .create() - .then((x) => x.data ?? undefined) - .catch((err) => { - showToast({ - title: language.t("prompt.toast.sessionCreateFailed.title"), - description: errorMessage(err), - }) - return undefined - }) - if (session) { - layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) - navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) - } - } - if (!session) return - - props.onSubmit?.() - - const model = { - modelID: currentModel.id, - providerID: currentModel.provider.id, - } - const agent = currentAgent.name - const variant = local.model.variant.current() - - const clearInput = () => { - prompt.reset() - setStore("mode", "normal") - setStore("popover", null) - } - - const restoreInput = () => { - prompt.set(currentPrompt, promptLength(currentPrompt)) - setStore("mode", mode) - setStore("popover", null) - requestAnimationFrame(() => { - editorRef.focus() - setCursorPosition(editorRef, promptLength(currentPrompt)) - queueScroll() - }) - } - - if (mode === "shell") { - clearInput() - client.session - .shell({ - sessionID: session.id, - agent, - model, - command: text, - }) - .catch((err) => { - showToast({ - title: language.t("prompt.toast.shellSendFailed.title"), - description: errorMessage(err), - }) - restoreInput() - }) - return - } - - if (text.startsWith("/")) { - const [cmdName, ...args] = text.split(" ") - const commandName = cmdName.slice(1) - const customCommand = sync.data.command.find((c) => c.name === commandName) - if (customCommand) { - clearInput() - client.session - .command({ - sessionID: session.id, - command: commandName, - arguments: args.join(" "), - agent, - model: `${model.providerID}/${model.modelID}`, - variant, - parts: images.map((attachment) => ({ - id: Identifier.ascending("part"), - type: "file" as const, - mime: attachment.mime, - url: attachment.dataUrl, - filename: attachment.filename, - })), - }) - .catch((err) => { - showToast({ - title: language.t("prompt.toast.commandSendFailed.title"), - description: errorMessage(err), - }) - restoreInput() - }) - return - } - } - - const toAbsolutePath = (path: string) => - path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/") - - const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[] - const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[] - - const fileAttachmentParts = fileAttachments.map((attachment) => { - const absolute = toAbsolutePath(attachment.path) - const query = attachment.selection - ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` - : "" - return { - id: Identifier.ascending("part"), - type: "file" as const, - mime: "text/plain", - url: `file://${absolute}${query}`, - filename: getFilename(attachment.path), - source: { - type: "file" as const, - text: { - value: attachment.content, - start: attachment.start, - end: attachment.end, - }, - path: absolute, - }, - } - }) - - const agentAttachmentParts = agentAttachments.map((attachment) => ({ - id: Identifier.ascending("part"), - type: "agent" as const, - name: attachment.name, - source: { - value: attachment.content, - start: attachment.start, - end: attachment.end, - }, - })) - - const usedUrls = new Set(fileAttachmentParts.map((part) => part.url)) - - const context = prompt.context.items().slice() - - const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) - - const contextParts: Array< - | { - id: string - type: "text" - text: string - synthetic?: boolean - } - | { - id: string - type: "file" - mime: string - url: string - filename?: string - } - > = [] - - const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => { - const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined - const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined - const range = - start === undefined || end === undefined - ? "this file" - : start === end - ? `line ${start}` - : `lines ${start} through ${end}` - - return `The user made the following comment regarding ${range} of ${path}: ${comment}` - } - - const addContextFile = (input: { path: string; selection?: FileSelection; comment?: string }) => { - const absolute = toAbsolutePath(input.path) - const query = input.selection ? `?start=${input.selection.startLine}&end=${input.selection.endLine}` : "" - const url = `file://${absolute}${query}` - - const comment = input.comment?.trim() - if (!comment && usedUrls.has(url)) return - usedUrls.add(url) - - if (comment) { - contextParts.push({ - id: Identifier.ascending("part"), - type: "text", - text: commentNote(input.path, input.selection, comment), - synthetic: true, - }) - } - - contextParts.push({ - id: Identifier.ascending("part"), - type: "file", - mime: "text/plain", - url, - filename: getFilename(input.path), - }) - } - - for (const item of context) { - if (item.type !== "file") continue - addContextFile({ path: item.path, selection: item.selection, comment: item.comment }) - } - - const imageAttachmentParts = images.map((attachment) => ({ - id: Identifier.ascending("part"), - type: "file" as const, - mime: attachment.mime, - url: attachment.dataUrl, - filename: attachment.filename, - })) - - const messageID = Identifier.ascending("message") - const textPart = { - id: Identifier.ascending("part"), - type: "text" as const, - text, - } - const requestParts = [ - textPart, - ...fileAttachmentParts, - ...contextParts, - ...agentAttachmentParts, - ...imageAttachmentParts, - ] - - const optimisticParts = requestParts.map((part) => ({ - ...part, - sessionID: session.id, - messageID, - })) as unknown as Part[] - - const optimisticMessage: Message = { - id: messageID, - sessionID: session.id, - role: "user", - time: { created: Date.now() }, - agent, - model, - } - - const addOptimisticMessage = () => { - if (sessionDirectory === projectDirectory) { - sync.set( - produce((draft) => { - const messages = draft.message[session.id] - if (!messages) { - draft.message[session.id] = [optimisticMessage] - } else { - const result = Binary.search(messages, messageID, (m) => m.id) - messages.splice(result.index, 0, optimisticMessage) - } - draft.part[messageID] = optimisticParts - .filter((p) => !!p?.id) - .slice() - .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - }), - ) - return - } - - globalSync.child(sessionDirectory)[1]( - produce((draft) => { - const messages = draft.message[session.id] - if (!messages) { - draft.message[session.id] = [optimisticMessage] - } else { - const result = Binary.search(messages, messageID, (m) => m.id) - messages.splice(result.index, 0, optimisticMessage) - } - draft.part[messageID] = optimisticParts - .filter((p) => !!p?.id) - .slice() - .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - }), - ) - } - - const removeOptimisticMessage = () => { - if (sessionDirectory === projectDirectory) { - sync.set( - produce((draft) => { - const messages = draft.message[session.id] - if (messages) { - const result = Binary.search(messages, messageID, (m) => m.id) - if (result.found) messages.splice(result.index, 1) - } - delete draft.part[messageID] - }), - ) - return - } - - globalSync.child(sessionDirectory)[1]( - produce((draft) => { - const messages = draft.message[session.id] - if (messages) { - const result = Binary.search(messages, messageID, (m) => m.id) - if (result.found) messages.splice(result.index, 1) - } - delete draft.part[messageID] - }), - ) - } - - for (const item of commentItems) { - prompt.context.remove(item.key) - } - - clearInput() - addOptimisticMessage() - - const waitForWorktree = async () => { - const worktree = WorktreeState.get(sessionDirectory) - if (!worktree || worktree.status !== "pending") return true - - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "busy" }) - } - - const controller = new AbortController() - - const cleanup = () => { - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "idle" }) - } - removeOptimisticMessage() - for (const item of commentItems) { - prompt.context.add({ - type: "file", - path: item.path, - selection: item.selection, - comment: item.comment, - commentID: item.commentID, - commentOrigin: item.commentOrigin, - preview: item.preview, - }) - } - restoreInput() - } - - pending.set(session.id, { abort: controller, cleanup }) - - const abort = new Promise>>((resolve) => { - if (controller.signal.aborted) { - resolve({ status: "failed", message: "aborted" }) - return - } - controller.signal.addEventListener( - "abort", - () => { - resolve({ status: "failed", message: "aborted" }) - }, - { once: true }, - ) - }) - - const timeoutMs = 5 * 60 * 1000 - const timer = { id: undefined as number | undefined } - const timeout = new Promise>>((resolve) => { - timer.id = window.setTimeout(() => { - resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") }) - }, timeoutMs) - }) - - const result = await Promise.race([WorktreeState.wait(sessionDirectory), abort, timeout]).finally(() => { - if (timer.id === undefined) return - clearTimeout(timer.id) - }) - pending.delete(session.id) - if (controller.signal.aborted) return false - if (result.status === "failed") throw new Error(result.message) - return true - } - - const send = async () => { - const ok = await waitForWorktree() - if (!ok) return - await client.session.prompt({ - sessionID: session.id, - agent, - model, - messageID, - parts: requestParts, - variant, - }) - } - - void send().catch((err) => { - pending.delete(session.id) - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "idle" }) - } - showToast({ - title: language.t("prompt.toast.promptSendFailed.title"), - description: errorMessage(err), - }) - removeOptimisticMessage() - for (const item of commentItems) { - prompt.context.add({ - type: "file", - path: item.path, - selection: item.selection, - comment: item.comment, - commentID: item.commentID, - commentOrigin: item.commentOrigin, - preview: item.preview, - }) - } - restoreInput() - }) - } - return (
- -
{ - if (store.popover === "slash") slashPopoverRef = el - }} - class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10 - overflow-auto no-scrollbar flex flex-col p-2 rounded-md - border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" - onMouseDown={(e) => e.preventDefault()} - > - - - 0} - fallback={
{language.t("prompt.popover.emptyResults")}
} - > - - {(item) => ( - - )} - -
-
- - 0} - fallback={
{language.t("prompt.popover.emptyCommands")}
} - > - - {(cmd) => ( - - )} - -
-
-
-
-
+ (slashPopoverRef = el)} + atFlat={atFlat()} + atActive={atActive() ?? undefined} + atKey={atKey} + setAtActive={setAtActive} + onAtSelect={handleAtSelect} + slashFlat={slashFlat()} + slashActive={slashActive() ?? undefined} + setSlashActive={setSlashActive} + onSlashSelect={handleSlashSelect} + commandKeybind={command.keybind} + t={(key) => language.t(key as Parameters[0])} + />
= (props) => { [props.class ?? ""]: !!props.class, }} > - -
-
- - {language.t("prompt.dropzone.label")} -
-
-
- 0}> -
- - {(item) => { - const active = () => { - const a = comments.active() - return !!item.commentID && item.commentID === a?.id && item.path === a?.file - } - return ( - - - {getDirectory(item.path)} - - {getFilename(item.path)} - - } - placement="top" - openDelay={2000} - > -
{ - openComment(item) - }} - > -
- -
- {getFilenameTruncated(item.path, 14)} - - {(sel) => ( - - {sel().startLine === sel().endLine - ? `:${sel().startLine}` - : `:${sel().startLine}-${sel().endLine}`} - - )} - -
- { - e.stopPropagation() - if (item.commentID) comments.remove(item.path, item.commentID) - prompt.context.remove(item.key) - }} - aria-label={language.t("prompt.context.removeFile")} - /> -
- - {(comment) => ( -
{comment()}
- )} -
-
-
- ) - }} -
-
-
- 0}> -
- - {(attachment) => ( -
- - -
- } - > - {attachment.filename} - dialog.show(() => ) - } - /> - - -
- {attachment.filename} -
-
- )} - -
- + + { + const active = comments.active() + return !!item.commentID && item.commentID === active?.id && item.path === active?.file + }} + openComment={openComment} + remove={(item) => { + if (item.commentID) comments.remove(item.path, item.commentID) + prompt.context.remove(item.key) + }} + t={(key) => language.t(key as Parameters[0])} + /> + + dialog.show(() => ) + } + onRemove={removeImageAttachment} + removeLabel={language.t("prompt.attachment.remove")} + />
(scrollRef = el)}>
= (props) => { }} role="textbox" aria-multiline="true" - aria-label={ - store.mode === "shell" - ? language.t("prompt.placeholder.shell") - : commentCount() > 1 - ? language.t("prompt.placeholder.summarizeComments") - : commentCount() === 1 - ? language.t("prompt.placeholder.summarizeComment") - : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) }) - } + aria-label={placeholder()} contenteditable="true" onInput={handleInput} onPaste={handlePaste} @@ -1889,13 +998,7 @@ export const PromptInput: Component = (props) => { />
- {store.mode === "shell" - ? language.t("prompt.placeholder.shell") - : commentCount() > 1 - ? language.t("prompt.placeholder.summarizeComments") - : commentCount() === 1 - ? language.t("prompt.placeholder.summarizeComment") - : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })} + {placeholder()}
@@ -2084,109 +1187,3 @@ export const PromptInput: Component = (props) => {
) } - -function createTextFragment(content: string): DocumentFragment { - const fragment = document.createDocumentFragment() - const segments = content.split("\n") - segments.forEach((segment, index) => { - if (segment) { - fragment.appendChild(document.createTextNode(segment)) - } else if (segments.length > 1) { - fragment.appendChild(document.createTextNode("\u200B")) - } - if (index < segments.length - 1) { - fragment.appendChild(document.createElement("br")) - } - }) - return fragment -} - -function getNodeLength(node: Node): number { - if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 - return (node.textContent ?? "").replace(/\u200B/g, "").length -} - -function getTextLength(node: Node): number { - if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length - if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 - let length = 0 - for (const child of Array.from(node.childNodes)) { - length += getTextLength(child) - } - return length -} - -function getCursorPosition(parent: HTMLElement): number { - const selection = window.getSelection() - if (!selection || selection.rangeCount === 0) return 0 - const range = selection.getRangeAt(0) - if (!parent.contains(range.startContainer)) return 0 - const preCaretRange = range.cloneRange() - preCaretRange.selectNodeContents(parent) - preCaretRange.setEnd(range.startContainer, range.startOffset) - return getTextLength(preCaretRange.cloneContents()) -} - -function setCursorPosition(parent: HTMLElement, position: number) { - let remaining = position - let node = parent.firstChild - while (node) { - const length = getNodeLength(node) - const isText = node.nodeType === Node.TEXT_NODE - const isPill = - node.nodeType === Node.ELEMENT_NODE && - ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") - const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" - - if (isText && remaining <= length) { - const range = document.createRange() - const selection = window.getSelection() - range.setStart(node, remaining) - range.collapse(true) - selection?.removeAllRanges() - selection?.addRange(range) - return - } - - if ((isPill || isBreak) && remaining <= length) { - const range = document.createRange() - const selection = window.getSelection() - if (remaining === 0) { - range.setStartBefore(node) - } - if (remaining > 0 && isPill) { - range.setStartAfter(node) - } - if (remaining > 0 && isBreak) { - const next = node.nextSibling - if (next && next.nodeType === Node.TEXT_NODE) { - range.setStart(next, 0) - } - if (!next || next.nodeType !== Node.TEXT_NODE) { - range.setStartAfter(node) - } - } - range.collapse(true) - selection?.removeAllRanges() - selection?.addRange(range) - return - } - - remaining -= length - node = node.nextSibling - } - - const fallbackRange = document.createRange() - const fallbackSelection = window.getSelection() - const last = parent.lastChild - if (last && last.nodeType === Node.TEXT_NODE) { - const len = last.textContent ? last.textContent.length : 0 - fallbackRange.setStart(last, len) - } - if (!last || last.nodeType !== Node.TEXT_NODE) { - fallbackRange.selectNodeContents(parent) - } - fallbackRange.collapse(false) - fallbackSelection?.removeAllRanges() - fallbackSelection?.addRange(fallbackRange) -} diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts new file mode 100644 index 0000000000..4ea2cfb90f --- /dev/null +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -0,0 +1,132 @@ +import { onCleanup, onMount } from "solid-js" +import { showToast } from "@opencode-ai/ui/toast" +import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt" +import { useLanguage } from "@/context/language" +import { getCursorPosition } from "./editor-dom" + +export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] +export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] + +type PromptAttachmentsInput = { + editor: () => HTMLDivElement | undefined + isFocused: () => boolean + isDialogActive: () => boolean + setDragging: (value: boolean) => void + addPart: (part: ContentPart) => void +} + +export function createPromptAttachments(input: PromptAttachmentsInput) { + const prompt = usePrompt() + const language = useLanguage() + + const addImageAttachment = async (file: File) => { + if (!ACCEPTED_FILE_TYPES.includes(file.type)) return + + const reader = new FileReader() + reader.onload = () => { + const editor = input.editor() + if (!editor) return + const dataUrl = reader.result as string + const attachment: ImageAttachmentPart = { + type: "image", + id: crypto.randomUUID(), + filename: file.name, + mime: file.type, + dataUrl, + } + const cursorPosition = prompt.cursor() ?? getCursorPosition(editor) + prompt.set([...prompt.current(), attachment], cursorPosition) + } + reader.readAsDataURL(file) + } + + const removeImageAttachment = (id: string) => { + const current = prompt.current() + const next = current.filter((part) => part.type !== "image" || part.id !== id) + prompt.set(next, prompt.cursor()) + } + + const handlePaste = async (event: ClipboardEvent) => { + if (!input.isFocused()) return + const clipboardData = event.clipboardData + if (!clipboardData) return + + event.preventDefault() + event.stopPropagation() + + const items = Array.from(clipboardData.items) + const fileItems = items.filter((item) => item.kind === "file") + const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) + + if (imageItems.length > 0) { + for (const item of imageItems) { + const file = item.getAsFile() + if (file) await addImageAttachment(file) + } + return + } + + if (fileItems.length > 0) { + showToast({ + title: language.t("prompt.toast.pasteUnsupported.title"), + description: language.t("prompt.toast.pasteUnsupported.description"), + }) + return + } + + const plainText = clipboardData.getData("text/plain") ?? "" + if (!plainText) return + input.addPart({ type: "text", content: plainText, start: 0, end: 0 }) + } + + const handleGlobalDragOver = (event: DragEvent) => { + if (input.isDialogActive()) return + + event.preventDefault() + const hasFiles = event.dataTransfer?.types.includes("Files") + if (hasFiles) { + input.setDragging(true) + } + } + + const handleGlobalDragLeave = (event: DragEvent) => { + if (input.isDialogActive()) return + if (!event.relatedTarget) { + input.setDragging(false) + } + } + + const handleGlobalDrop = async (event: DragEvent) => { + if (input.isDialogActive()) return + + event.preventDefault() + input.setDragging(false) + + const dropped = event.dataTransfer?.files + if (!dropped) return + + for (const file of Array.from(dropped)) { + if (ACCEPTED_FILE_TYPES.includes(file.type)) { + await addImageAttachment(file) + } + } + } + + onMount(() => { + document.addEventListener("dragover", handleGlobalDragOver) + document.addEventListener("dragleave", handleGlobalDragLeave) + document.addEventListener("drop", handleGlobalDrop) + }) + + onCleanup(() => { + document.removeEventListener("dragover", handleGlobalDragOver) + document.removeEventListener("dragleave", handleGlobalDragLeave) + document.removeEventListener("drop", handleGlobalDrop) + }) + + return { + addImageAttachment, + removeImageAttachment, + handlePaste, + } +} diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts new file mode 100644 index 0000000000..b284c38841 --- /dev/null +++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test" +import type { Prompt } from "@/context/prompt" +import { buildRequestParts } from "./build-request-parts" + +describe("buildRequestParts", () => { + test("builds typed request and optimistic parts without cast path", () => { + const prompt: Prompt = [ + { type: "text", content: "hello", start: 0, end: 5 }, + { + type: "file", + path: "src/foo.ts", + content: "@src/foo.ts", + start: 5, + end: 16, + selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 }, + }, + { type: "agent", name: "planner", content: "@planner", start: 16, end: 24 }, + ] + + const result = buildRequestParts({ + prompt, + context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }], + images: [ + { type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" }, + ], + text: "hello @src/foo.ts @planner", + messageID: "msg_1", + sessionID: "ses_1", + sessionDirectory: "/repo", + }) + + expect(result.requestParts[0]?.type).toBe("text") + expect(result.requestParts.some((part) => part.type === "agent")).toBe(true) + expect( + result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")), + ).toBe(true) + expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true) + + expect(result.optimisticParts).toHaveLength(result.requestParts.length) + expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true) + }) + + test("deduplicates context files when prompt already includes same path", () => { + const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }] + + const result = buildRequestParts({ + prompt, + context: [ + { key: "ctx:dup", type: "file", path: "src/foo.ts" }, + { key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" }, + ], + images: [], + text: "@src/foo.ts", + messageID: "msg_2", + sessionID: "ses_2", + sessionDirectory: "/repo", + }) + + const fooFiles = result.requestParts.filter( + (part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"), + ) + const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic) + + expect(fooFiles).toHaveLength(2) + expect(synthetic).toHaveLength(1) + }) +}) diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts new file mode 100644 index 0000000000..4cf2f29acf --- /dev/null +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -0,0 +1,174 @@ +import { getFilename } from "@opencode-ai/util/path" +import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client" +import type { FileSelection } from "@/context/file" +import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" +import { Identifier } from "@/utils/id" + +type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string } + +type ContextFile = { + key: string + type: "file" + path: string + selection?: FileSelection + comment?: string + commentID?: string + commentOrigin?: "review" | "file" + preview?: string +} + +type BuildRequestPartsInput = { + prompt: Prompt + context: ContextFile[] + images: ImageAttachmentPart[] + text: string + messageID: string + sessionID: string + sessionDirectory: string +} + +const absolute = (directory: string, path: string) => + path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/") + +const fileQuery = (selection: FileSelection | undefined) => + selection ? `?start=${selection.startLine}&end=${selection.endLine}` : "" + +const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file" +const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent" + +const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => { + const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined + const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined + const range = + start === undefined || end === undefined + ? "this file" + : start === end + ? `line ${start}` + : `lines ${start} through ${end}` + return `The user made the following comment regarding ${range} of ${path}: ${comment}` +} + +const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => { + if (part.type === "text") { + return { + id: part.id, + type: "text", + text: part.text, + synthetic: part.synthetic, + ignored: part.ignored, + time: part.time, + metadata: part.metadata, + sessionID, + messageID, + } + } + if (part.type === "file") { + return { + id: part.id, + type: "file", + mime: part.mime, + filename: part.filename, + url: part.url, + source: part.source, + sessionID, + messageID, + } + } + return { + id: part.id, + type: "agent", + name: part.name, + source: part.source, + sessionID, + messageID, + } +} + +export function buildRequestParts(input: BuildRequestPartsInput) { + const requestParts: PromptRequestPart[] = [ + { + id: Identifier.ascending("part"), + type: "text", + text: input.text, + }, + ] + + const files = input.prompt.filter(isFileAttachment).map((attachment) => { + const path = absolute(input.sessionDirectory, attachment.path) + return { + id: Identifier.ascending("part"), + type: "file", + mime: "text/plain", + url: `file://${path}${fileQuery(attachment.selection)}`, + filename: getFilename(attachment.path), + source: { + type: "file", + text: { + value: attachment.content, + start: attachment.start, + end: attachment.end, + }, + path, + }, + } satisfies PromptRequestPart + }) + + const agents = input.prompt.filter(isAgentAttachment).map((attachment) => { + return { + id: Identifier.ascending("part"), + type: "agent", + name: attachment.name, + source: { + value: attachment.content, + start: attachment.start, + end: attachment.end, + }, + } satisfies PromptRequestPart + }) + + const used = new Set(files.map((part) => part.url)) + const context = input.context.flatMap((item) => { + const path = absolute(input.sessionDirectory, item.path) + const url = `file://${path}${fileQuery(item.selection)}` + const comment = item.comment?.trim() + if (!comment && used.has(url)) return [] + used.add(url) + + const filePart = { + id: Identifier.ascending("part"), + type: "file", + mime: "text/plain", + url, + filename: getFilename(item.path), + } satisfies PromptRequestPart + + if (!comment) return [filePart] + + return [ + { + id: Identifier.ascending("part"), + type: "text", + text: commentNote(item.path, item.selection, comment), + synthetic: true, + } satisfies PromptRequestPart, + filePart, + ] + }) + + const images = input.images.map((attachment) => { + return { + id: Identifier.ascending("part"), + type: "file", + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + } satisfies PromptRequestPart + }) + + requestParts.push(...files, ...context, ...agents, ...images) + + return { + requestParts, + optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)), + } +} diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx new file mode 100644 index 0000000000..a843e109d8 --- /dev/null +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -0,0 +1,82 @@ +import { Component, For, Show } from "solid-js" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" +import type { ContextItem } from "@/context/prompt" + +type PromptContextItem = ContextItem & { key: string } + +type ContextItemsProps = { + items: PromptContextItem[] + active: (item: PromptContextItem) => boolean + openComment: (item: PromptContextItem) => void + remove: (item: PromptContextItem) => void + t: (key: string) => string +} + +export const PromptContextItems: Component = (props) => { + return ( + 0}> +
+ + {(item) => ( + + + {getDirectory(item.path)} + + {getFilename(item.path)} + + } + placement="top" + openDelay={2000} + > +
props.openComment(item)} + > +
+ +
+ {getFilenameTruncated(item.path, 14)} + + {(sel) => ( + + {sel().startLine === sel().endLine + ? `:${sel().startLine}` + : `:${sel().startLine}-${sel().endLine}`} + + )} + +
+ { + e.stopPropagation() + props.remove(item) + }} + aria-label={props.t("prompt.context.removeFile")} + /> +
+ + {(comment) =>
{comment()}
} +
+
+
+ )} +
+
+
+ ) +} diff --git a/packages/app/src/components/prompt-input/drag-overlay.tsx b/packages/app/src/components/prompt-input/drag-overlay.tsx new file mode 100644 index 0000000000..f5a4d399ef --- /dev/null +++ b/packages/app/src/components/prompt-input/drag-overlay.tsx @@ -0,0 +1,20 @@ +import { Component, Show } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" + +type PromptDragOverlayProps = { + dragging: boolean + label: string +} + +export const PromptDragOverlay: Component = (props) => { + return ( + +
+
+ + {props.label} +
+
+
+ ) +} diff --git a/packages/app/src/components/prompt-input/editor-dom.test.ts b/packages/app/src/components/prompt-input/editor-dom.test.ts new file mode 100644 index 0000000000..fce8b4b953 --- /dev/null +++ b/packages/app/src/components/prompt-input/editor-dom.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test" +import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom" + +describe("prompt-input editor dom", () => { + test("createTextFragment preserves newlines with br and zero-width placeholders", () => { + const fragment = createTextFragment("foo\n\nbar") + const container = document.createElement("div") + container.appendChild(fragment) + + expect(container.childNodes.length).toBe(5) + expect(container.childNodes[0]?.textContent).toBe("foo") + expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR") + expect(container.childNodes[2]?.textContent).toBe("\u200B") + expect((container.childNodes[3] as HTMLElement).tagName).toBe("BR") + expect(container.childNodes[4]?.textContent).toBe("bar") + }) + + test("length helpers treat breaks as one char and ignore zero-width chars", () => { + const container = document.createElement("div") + container.appendChild(document.createTextNode("ab\u200B")) + container.appendChild(document.createElement("br")) + container.appendChild(document.createTextNode("cd")) + + expect(getNodeLength(container.childNodes[0]!)).toBe(2) + expect(getNodeLength(container.childNodes[1]!)).toBe(1) + expect(getTextLength(container)).toBe(5) + }) + + test("setCursorPosition and getCursorPosition round-trip with pills and breaks", () => { + const container = document.createElement("div") + const pill = document.createElement("span") + pill.dataset.type = "file" + pill.textContent = "@file" + container.appendChild(document.createTextNode("ab")) + container.appendChild(pill) + container.appendChild(document.createElement("br")) + container.appendChild(document.createTextNode("cd")) + document.body.appendChild(container) + + setCursorPosition(container, 2) + expect(getCursorPosition(container)).toBe(2) + + setCursorPosition(container, 7) + expect(getCursorPosition(container)).toBe(7) + + setCursorPosition(container, 8) + expect(getCursorPosition(container)).toBe(8) + + container.remove() + }) +}) diff --git a/packages/app/src/components/prompt-input/editor-dom.ts b/packages/app/src/components/prompt-input/editor-dom.ts new file mode 100644 index 0000000000..3116ceb126 --- /dev/null +++ b/packages/app/src/components/prompt-input/editor-dom.ts @@ -0,0 +1,135 @@ +export function createTextFragment(content: string): DocumentFragment { + const fragment = document.createDocumentFragment() + const segments = content.split("\n") + segments.forEach((segment, index) => { + if (segment) { + fragment.appendChild(document.createTextNode(segment)) + } else if (segments.length > 1) { + fragment.appendChild(document.createTextNode("\u200B")) + } + if (index < segments.length - 1) { + fragment.appendChild(document.createElement("br")) + } + }) + return fragment +} + +export function getNodeLength(node: Node): number { + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 + return (node.textContent ?? "").replace(/\u200B/g, "").length +} + +export function getTextLength(node: Node): number { + if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 + let length = 0 + for (const child of Array.from(node.childNodes)) { + length += getTextLength(child) + } + return length +} + +export function getCursorPosition(parent: HTMLElement): number { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return 0 + const range = selection.getRangeAt(0) + if (!parent.contains(range.startContainer)) return 0 + const preCaretRange = range.cloneRange() + preCaretRange.selectNodeContents(parent) + preCaretRange.setEnd(range.startContainer, range.startOffset) + return getTextLength(preCaretRange.cloneContents()) +} + +export function setCursorPosition(parent: HTMLElement, position: number) { + let remaining = position + let node = parent.firstChild + while (node) { + const length = getNodeLength(node) + const isText = node.nodeType === Node.TEXT_NODE + const isPill = + node.nodeType === Node.ELEMENT_NODE && + ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") + const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" + + if (isText && remaining <= length) { + const range = document.createRange() + const selection = window.getSelection() + range.setStart(node, remaining) + range.collapse(true) + selection?.removeAllRanges() + selection?.addRange(range) + return + } + + if ((isPill || isBreak) && remaining <= length) { + const range = document.createRange() + const selection = window.getSelection() + if (remaining === 0) { + range.setStartBefore(node) + } + if (remaining > 0 && isPill) { + range.setStartAfter(node) + } + if (remaining > 0 && isBreak) { + const next = node.nextSibling + if (next && next.nodeType === Node.TEXT_NODE) { + range.setStart(next, 0) + } + if (!next || next.nodeType !== Node.TEXT_NODE) { + range.setStartAfter(node) + } + } + range.collapse(true) + selection?.removeAllRanges() + selection?.addRange(range) + return + } + + remaining -= length + node = node.nextSibling + } + + const fallbackRange = document.createRange() + const fallbackSelection = window.getSelection() + const last = parent.lastChild + if (last && last.nodeType === Node.TEXT_NODE) { + const len = last.textContent ? last.textContent.length : 0 + fallbackRange.setStart(last, len) + } + if (!last || last.nodeType !== Node.TEXT_NODE) { + fallbackRange.selectNodeContents(parent) + } + fallbackRange.collapse(false) + fallbackSelection?.removeAllRanges() + fallbackSelection?.addRange(fallbackRange) +} + +export function setRangeEdge(parent: HTMLElement, range: Range, edge: "start" | "end", offset: number) { + let remaining = offset + const nodes = Array.from(parent.childNodes) + + for (const node of nodes) { + const length = getNodeLength(node) + const isText = node.nodeType === Node.TEXT_NODE + const isPill = + node.nodeType === Node.ELEMENT_NODE && + ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") + const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" + + if (isText && remaining <= length) { + if (edge === "start") range.setStart(node, remaining) + if (edge === "end") range.setEnd(node, remaining) + return + } + + if ((isPill || isBreak) && remaining <= length) { + if (edge === "start" && remaining === 0) range.setStartBefore(node) + if (edge === "start" && remaining > 0) range.setStartAfter(node) + if (edge === "end" && remaining === 0) range.setEndBefore(node) + if (edge === "end" && remaining > 0) range.setEndAfter(node) + return + } + + remaining -= length + } +} diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts new file mode 100644 index 0000000000..54be9cb75b --- /dev/null +++ b/packages/app/src/components/prompt-input/history.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import type { Prompt } from "@/context/prompt" +import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history" + +const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] + +const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }] + +describe("prompt-input history", () => { + test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => { + const first = prependHistoryEntry([], DEFAULT_PROMPT) + expect(first).toEqual([]) + + const withOne = prependHistoryEntry([], text("hello")) + expect(withOne).toHaveLength(1) + + const deduped = prependHistoryEntry(withOne, text("hello")) + expect(deduped).toBe(withOne) + }) + + test("navigatePromptHistory restores saved prompt when moving down from newest", () => { + const entries = [text("third"), text("second"), text("first")] + const up = navigatePromptHistory({ + direction: "up", + entries, + historyIndex: -1, + currentPrompt: text("draft"), + savedPrompt: null, + }) + expect(up.handled).toBe(true) + if (!up.handled) throw new Error("expected handled") + expect(up.historyIndex).toBe(0) + expect(up.cursor).toBe("start") + + const down = navigatePromptHistory({ + direction: "down", + entries, + historyIndex: up.historyIndex, + currentPrompt: text("ignored"), + savedPrompt: up.savedPrompt, + }) + expect(down.handled).toBe(true) + if (!down.handled) throw new Error("expected handled") + expect(down.historyIndex).toBe(-1) + expect(down.prompt[0]?.type === "text" ? down.prompt[0].content : "").toBe("draft") + }) + + test("helpers clone prompt and count text content length", () => { + const original: Prompt = [ + { type: "text", content: "one", start: 0, end: 3 }, + { + type: "file", + path: "src/a.ts", + content: "@src/a.ts", + start: 3, + end: 12, + selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 }, + }, + { type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" }, + ] + const copy = clonePromptParts(original) + expect(copy).not.toBe(original) + expect(promptLength(copy)).toBe(12) + if (copy[1]?.type !== "file") throw new Error("expected file") + copy[1].selection!.startLine = 9 + if (original[1]?.type !== "file") throw new Error("expected file") + expect(original[1].selection?.startLine).toBe(1) + }) +}) diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts new file mode 100644 index 0000000000..63164f0ba3 --- /dev/null +++ b/packages/app/src/components/prompt-input/history.ts @@ -0,0 +1,160 @@ +import type { Prompt } from "@/context/prompt" + +const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] + +export const MAX_HISTORY = 100 + +export function clonePromptParts(prompt: Prompt): Prompt { + return prompt.map((part) => { + if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } + if (part.type === "agent") return { ...part } + return { + ...part, + selection: part.selection ? { ...part.selection } : undefined, + } + }) +} + +export function promptLength(prompt: Prompt) { + return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0) +} + +export function prependHistoryEntry(entries: Prompt[], prompt: Prompt, max = MAX_HISTORY) { + const text = prompt + .map((part) => ("content" in part ? part.content : "")) + .join("") + .trim() + const hasImages = prompt.some((part) => part.type === "image") + if (!text && !hasImages) return entries + + const entry = clonePromptParts(prompt) + const last = entries[0] + if (last && isPromptEqual(last, entry)) return entries + return [entry, ...entries].slice(0, max) +} + +function isPromptEqual(promptA: Prompt, promptB: Prompt) { + if (promptA.length !== promptB.length) return false + for (let i = 0; i < promptA.length; i++) { + const partA = promptA[i] + const partB = promptB[i] + if (partA.type !== partB.type) return false + if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false + if (partA.type === "file") { + if (partA.path !== (partB.type === "file" ? partB.path : "")) return false + const a = partA.selection + const b = partB.type === "file" ? partB.selection : undefined + const sameSelection = + (!a && !b) || + (!!a && + !!b && + a.startLine === b.startLine && + a.startChar === b.startChar && + a.endLine === b.endLine && + a.endChar === b.endChar) + if (!sameSelection) return false + } + if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false + if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false + } + return true +} + +type HistoryNavInput = { + direction: "up" | "down" + entries: Prompt[] + historyIndex: number + currentPrompt: Prompt + savedPrompt: Prompt | null +} + +type HistoryNavResult = + | { + handled: false + historyIndex: number + savedPrompt: Prompt | null + } + | { + handled: true + historyIndex: number + savedPrompt: Prompt | null + prompt: Prompt + cursor: "start" | "end" + } + +export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult { + if (input.direction === "up") { + if (input.entries.length === 0) { + return { + handled: false, + historyIndex: input.historyIndex, + savedPrompt: input.savedPrompt, + } + } + + if (input.historyIndex === -1) { + return { + handled: true, + historyIndex: 0, + savedPrompt: clonePromptParts(input.currentPrompt), + prompt: input.entries[0], + cursor: "start", + } + } + + if (input.historyIndex < input.entries.length - 1) { + const next = input.historyIndex + 1 + return { + handled: true, + historyIndex: next, + savedPrompt: input.savedPrompt, + prompt: input.entries[next], + cursor: "start", + } + } + + return { + handled: false, + historyIndex: input.historyIndex, + savedPrompt: input.savedPrompt, + } + } + + if (input.historyIndex > 0) { + const next = input.historyIndex - 1 + return { + handled: true, + historyIndex: next, + savedPrompt: input.savedPrompt, + prompt: input.entries[next], + cursor: "end", + } + } + + if (input.historyIndex === 0) { + if (input.savedPrompt) { + return { + handled: true, + historyIndex: -1, + savedPrompt: null, + prompt: input.savedPrompt, + cursor: "end", + } + } + + return { + handled: true, + historyIndex: -1, + savedPrompt: null, + prompt: DEFAULT_PROMPT, + cursor: "end", + } + } + + return { + handled: false, + historyIndex: input.historyIndex, + savedPrompt: input.savedPrompt, + } +} diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx new file mode 100644 index 0000000000..ba3addf0a1 --- /dev/null +++ b/packages/app/src/components/prompt-input/image-attachments.tsx @@ -0,0 +1,51 @@ +import { Component, For, Show } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import type { ImageAttachmentPart } from "@/context/prompt" + +type PromptImageAttachmentsProps = { + attachments: ImageAttachmentPart[] + onOpen: (attachment: ImageAttachmentPart) => void + onRemove: (id: string) => void + removeLabel: string +} + +export const PromptImageAttachments: Component = (props) => { + return ( + 0}> +
+ + {(attachment) => ( +
+ + +
+ } + > + {attachment.filename} props.onOpen(attachment)} + /> + + +
+ {attachment.filename} +
+
+ )} + +
+ + ) +} diff --git a/packages/app/src/components/prompt-input/placeholder.test.ts b/packages/app/src/components/prompt-input/placeholder.test.ts new file mode 100644 index 0000000000..b633df8295 --- /dev/null +++ b/packages/app/src/components/prompt-input/placeholder.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test" +import { promptPlaceholder } from "./placeholder" + +describe("promptPlaceholder", () => { + const t = (key: string, params?: Record) => `${key}${params?.example ? `:${params.example}` : ""}` + + test("returns shell placeholder in shell mode", () => { + const value = promptPlaceholder({ + mode: "shell", + commentCount: 0, + example: "example", + t, + }) + expect(value).toBe("prompt.placeholder.shell") + }) + + test("returns summarize placeholders for comment context", () => { + expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe( + "prompt.placeholder.summarizeComment", + ) + expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe( + "prompt.placeholder.summarizeComments", + ) + }) + + test("returns default placeholder with example", () => { + const value = promptPlaceholder({ + mode: "normal", + commentCount: 0, + example: "translated-example", + t, + }) + expect(value).toBe("prompt.placeholder.normal:translated-example") + }) +}) diff --git a/packages/app/src/components/prompt-input/placeholder.ts b/packages/app/src/components/prompt-input/placeholder.ts new file mode 100644 index 0000000000..07f6a43b51 --- /dev/null +++ b/packages/app/src/components/prompt-input/placeholder.ts @@ -0,0 +1,13 @@ +type PromptPlaceholderInput = { + mode: "normal" | "shell" + commentCount: number + example: string + t: (key: string, params?: Record) => string +} + +export function promptPlaceholder(input: PromptPlaceholderInput) { + if (input.mode === "shell") return input.t("prompt.placeholder.shell") + if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments") + if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment") + return input.t("prompt.placeholder.normal", { example: input.example }) +} diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx new file mode 100644 index 0000000000..b97bb67522 --- /dev/null +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -0,0 +1,144 @@ +import { Component, For, Match, Show, Switch } from "solid-js" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { getDirectory, getFilename } from "@opencode-ai/util/path" + +export type AtOption = + | { type: "agent"; name: string; display: string } + | { type: "file"; path: string; display: string; recent?: boolean } + +export interface SlashCommand { + id: string + trigger: string + title: string + description?: string + keybind?: string + type: "builtin" | "custom" + source?: "command" | "mcp" | "skill" +} + +type PromptPopoverProps = { + popover: "at" | "slash" | null + setSlashPopoverRef: (el: HTMLDivElement) => void + atFlat: AtOption[] + atActive?: string + atKey: (item: AtOption) => string + setAtActive: (id: string) => void + onAtSelect: (item: AtOption) => void + slashFlat: SlashCommand[] + slashActive?: string + setSlashActive: (id: string) => void + onSlashSelect: (item: SlashCommand) => void + commandKeybind: (id: string) => string | undefined + t: (key: string) => string +} + +export const PromptPopover: Component = (props) => { + return ( + +
{ + if (props.popover === "slash") props.setSlashPopoverRef(el) + }} + class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10 + overflow-auto no-scrollbar flex flex-col p-2 rounded-md + border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" + onMouseDown={(e) => e.preventDefault()} + > + + + 0} + fallback={
{props.t("prompt.popover.emptyResults")}
} + > + + {(item) => ( + + )} + +
+
+ + 0} + fallback={
{props.t("prompt.popover.emptyCommands")}
} + > + + {(cmd) => ( + + )} + +
+
+
+
+
+ ) +} diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts new file mode 100644 index 0000000000..5ed5eedada --- /dev/null +++ b/packages/app/src/components/prompt-input/submit.ts @@ -0,0 +1,411 @@ +import { Accessor } from "solid-js" +import { useNavigate, useParams } from "@solidjs/router" +import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client" +import { showToast } from "@opencode-ai/ui/toast" +import { base64Encode } from "@opencode-ai/util/encode" +import { useLocal } from "@/context/local" +import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt" +import { useLayout } from "@/context/layout" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { useGlobalSync } from "@/context/global-sync" +import { usePlatform } from "@/context/platform" +import { useLanguage } from "@/context/language" +import { Identifier } from "@/utils/id" +import { Worktree as WorktreeState } from "@/utils/worktree" +import type { FileSelection } from "@/context/file" +import { setCursorPosition } from "./editor-dom" +import { buildRequestParts } from "./build-request-parts" + +type PendingPrompt = { + abort: AbortController + cleanup: VoidFunction +} + +const pending = new Map() + +type PromptSubmitInput = { + info: Accessor<{ id: string } | undefined> + imageAttachments: Accessor + commentCount: Accessor + mode: Accessor<"normal" | "shell"> + working: Accessor + editor: () => HTMLDivElement | undefined + queueScroll: () => void + promptLength: (prompt: Prompt) => number + addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void + resetHistoryNavigation: () => void + setMode: (mode: "normal" | "shell") => void + setPopover: (popover: "at" | "slash" | null) => void + newSessionWorktree?: string + onNewSessionWorktreeReset?: () => void + onSubmit?: () => void +} + +type CommentItem = { + path: string + selection?: FileSelection + comment?: string + commentID?: string + commentOrigin?: "review" | "file" + preview?: string +} + +export function createPromptSubmit(input: PromptSubmitInput) { + const navigate = useNavigate() + const sdk = useSDK() + const sync = useSync() + const globalSync = useGlobalSync() + const platform = usePlatform() + const local = useLocal() + const prompt = usePrompt() + const layout = useLayout() + const language = useLanguage() + const params = useParams() + + const errorMessage = (err: unknown) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return language.t("common.requestFailed") + } + + const abort = async () => { + const sessionID = params.id + if (!sessionID) return Promise.resolve() + const queued = pending.get(sessionID) + if (queued) { + queued.abort.abort() + queued.cleanup() + pending.delete(sessionID) + return Promise.resolve() + } + return sdk.client.session + .abort({ + sessionID, + }) + .catch(() => {}) + } + + const restoreCommentItems = (items: CommentItem[]) => { + for (const item of items) { + prompt.context.add({ + type: "file", + path: item.path, + selection: item.selection, + comment: item.comment, + commentID: item.commentID, + commentOrigin: item.commentOrigin, + preview: item.preview, + }) + } + } + + const removeCommentItems = (items: { key: string }[]) => { + for (const item of items) { + prompt.context.remove(item.key) + } + } + + const handleSubmit = async (event: Event) => { + event.preventDefault() + + const currentPrompt = prompt.current() + const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") + const images = input.imageAttachments().slice() + const mode = input.mode() + + if (text.trim().length === 0 && images.length === 0 && input.commentCount() === 0) { + if (input.working()) abort() + return + } + + const currentModel = local.model.current() + const currentAgent = local.agent.current() + if (!currentModel || !currentAgent) { + showToast({ + title: language.t("prompt.toast.modelAgentRequired.title"), + description: language.t("prompt.toast.modelAgentRequired.description"), + }) + return + } + + input.addToHistory(currentPrompt, mode) + input.resetHistoryNavigation() + + const projectDirectory = sdk.directory + const isNewSession = !params.id + const worktreeSelection = input.newSessionWorktree ?? "main" + + let sessionDirectory = projectDirectory + let client = sdk.client + + if (isNewSession) { + if (worktreeSelection === "create") { + const createdWorktree = await client.worktree + .create({ directory: projectDirectory }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: language.t("prompt.toast.worktreeCreateFailed.title"), + description: errorMessage(err), + }) + return undefined + }) + + if (!createdWorktree?.directory) { + showToast({ + title: language.t("prompt.toast.worktreeCreateFailed.title"), + description: language.t("common.requestFailed"), + }) + return + } + WorktreeState.pending(createdWorktree.directory) + sessionDirectory = createdWorktree.directory + } + + if (worktreeSelection !== "main" && worktreeSelection !== "create") { + sessionDirectory = worktreeSelection + } + + if (sessionDirectory !== projectDirectory) { + client = createOpencodeClient({ + baseUrl: sdk.url, + fetch: platform.fetch, + directory: sessionDirectory, + throwOnError: true, + }) + globalSync.child(sessionDirectory) + } + + input.onNewSessionWorktreeReset?.() + } + + let session = input.info() + if (!session && isNewSession) { + session = await client.session + .create() + .then((x) => x.data ?? undefined) + .catch((err) => { + showToast({ + title: language.t("prompt.toast.sessionCreateFailed.title"), + description: errorMessage(err), + }) + return undefined + }) + if (session) { + layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) + navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + } + } + if (!session) return + + input.onSubmit?.() + + const model = { + modelID: currentModel.id, + providerID: currentModel.provider.id, + } + const agent = currentAgent.name + const variant = local.model.variant.current() + + const clearInput = () => { + prompt.reset() + input.setMode("normal") + input.setPopover(null) + } + + const restoreInput = () => { + prompt.set(currentPrompt, input.promptLength(currentPrompt)) + input.setMode(mode) + input.setPopover(null) + requestAnimationFrame(() => { + const editor = input.editor() + if (!editor) return + editor.focus() + setCursorPosition(editor, input.promptLength(currentPrompt)) + input.queueScroll() + }) + } + + if (mode === "shell") { + clearInput() + client.session + .shell({ + sessionID: session.id, + agent, + model, + command: text, + }) + .catch((err) => { + showToast({ + title: language.t("prompt.toast.shellSendFailed.title"), + description: errorMessage(err), + }) + restoreInput() + }) + return + } + + if (text.startsWith("/")) { + const [cmdName, ...args] = text.split(" ") + const commandName = cmdName.slice(1) + const customCommand = sync.data.command.find((c) => c.name === commandName) + if (customCommand) { + clearInput() + client.session + .command({ + sessionID: session.id, + command: commandName, + arguments: args.join(" "), + agent, + model: `${model.providerID}/${model.modelID}`, + variant, + parts: images.map((attachment) => ({ + id: Identifier.ascending("part"), + type: "file" as const, + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + })), + }) + .catch((err) => { + showToast({ + title: language.t("prompt.toast.commandSendFailed.title"), + description: errorMessage(err), + }) + restoreInput() + }) + return + } + } + + const context = prompt.context.items().slice() + const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) + + const messageID = Identifier.ascending("message") + const { requestParts, optimisticParts } = buildRequestParts({ + prompt: currentPrompt, + context, + images, + text, + sessionID: session.id, + messageID, + sessionDirectory, + }) + + const optimisticMessage: Message = { + id: messageID, + sessionID: session.id, + role: "user", + time: { created: Date.now() }, + agent, + model, + } + + const addOptimisticMessage = () => + sync.session.optimistic.add({ + directory: sessionDirectory, + sessionID: session.id, + message: optimisticMessage, + parts: optimisticParts, + }) + + const removeOptimisticMessage = () => + sync.session.optimistic.remove({ + directory: sessionDirectory, + sessionID: session.id, + messageID, + }) + + removeCommentItems(commentItems) + clearInput() + addOptimisticMessage() + + const waitForWorktree = async () => { + const worktree = WorktreeState.get(sessionDirectory) + if (!worktree || worktree.status !== "pending") return true + + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "busy" }) + } + + const controller = new AbortController() + const cleanup = () => { + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "idle" }) + } + removeOptimisticMessage() + restoreCommentItems(commentItems) + restoreInput() + } + + pending.set(session.id, { abort: controller, cleanup }) + + const abortWait = new Promise>>((resolve) => { + if (controller.signal.aborted) { + resolve({ status: "failed", message: "aborted" }) + return + } + controller.signal.addEventListener( + "abort", + () => { + resolve({ status: "failed", message: "aborted" }) + }, + { once: true }, + ) + }) + + const timeoutMs = 5 * 60 * 1000 + const timer = { id: undefined as number | undefined } + const timeout = new Promise>>((resolve) => { + timer.id = window.setTimeout(() => { + resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") }) + }, timeoutMs) + }) + + const result = await Promise.race([WorktreeState.wait(sessionDirectory), abortWait, timeout]).finally(() => { + if (timer.id === undefined) return + clearTimeout(timer.id) + }) + pending.delete(session.id) + if (controller.signal.aborted) return false + if (result.status === "failed") throw new Error(result.message) + return true + } + + const send = async () => { + const ok = await waitForWorktree() + if (!ok) return + await client.session.prompt({ + sessionID: session.id, + agent, + model, + messageID, + parts: requestParts, + variant, + }) + } + + void send().catch((err) => { + pending.delete(session.id) + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "idle" }) + } + showToast({ + title: language.t("prompt.toast.promptSendFailed.title"), + description: errorMessage(err), + }) + removeOptimisticMessage() + restoreCommentItems(commentItems) + restoreInput() + }) + } + + return { + abort, + handleSubmit, + } +} diff --git a/packages/app/src/components/question-dock.tsx b/packages/app/src/components/question-dock.tsx new file mode 100644 index 0000000000..f626fcc9b2 --- /dev/null +++ b/packages/app/src/components/question-dock.tsx @@ -0,0 +1,295 @@ +import { For, Show, createMemo, type Component } from "solid-js" +import { createStore } from "solid-js/store" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { showToast } from "@opencode-ai/ui/toast" +import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" +import { useLanguage } from "@/context/language" +import { useSDK } from "@/context/sdk" + +export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => { + const sdk = useSDK() + const language = useLanguage() + + const questions = createMemo(() => props.request.questions) + const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) + + const [store, setStore] = createStore({ + tab: 0, + answers: [] as QuestionAnswer[], + custom: [] as string[], + editing: false, + sending: false, + }) + + const question = createMemo(() => questions()[store.tab]) + const confirm = createMemo(() => !single() && store.tab === questions().length) + const options = createMemo(() => question()?.options ?? []) + const input = createMemo(() => store.custom[store.tab] ?? "") + const multi = createMemo(() => question()?.multiple === true) + const customPicked = createMemo(() => { + const value = input() + if (!value) return false + return store.answers[store.tab]?.includes(value) ?? false + }) + + const fail = (err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + } + + const reply = (answers: QuestionAnswer[]) => { + if (store.sending) return + + setStore("sending", true) + sdk.client.question + .reply({ requestID: props.request.id, answers }) + .catch(fail) + .finally(() => setStore("sending", false)) + } + + const reject = () => { + if (store.sending) return + + setStore("sending", true) + sdk.client.question + .reject({ requestID: props.request.id }) + .catch(fail) + .finally(() => setStore("sending", false)) + } + + const submit = () => { + reply(questions().map((_, i) => store.answers[i] ?? [])) + } + + const pick = (answer: string, custom: boolean = false) => { + const answers = [...store.answers] + answers[store.tab] = [answer] + setStore("answers", answers) + + if (custom) { + const inputs = [...store.custom] + inputs[store.tab] = answer + setStore("custom", inputs) + } + + if (single()) { + reply([[answer]]) + return + } + + setStore("tab", store.tab + 1) + } + + const toggle = (answer: string) => { + const existing = store.answers[store.tab] ?? [] + const next = [...existing] + const index = next.indexOf(answer) + if (index === -1) next.push(answer) + if (index !== -1) next.splice(index, 1) + + const answers = [...store.answers] + answers[store.tab] = next + setStore("answers", answers) + } + + const selectTab = (index: number) => { + setStore("tab", index) + setStore("editing", false) + } + + const selectOption = (optIndex: number) => { + if (store.sending) return + + if (optIndex === options().length) { + setStore("editing", true) + return + } + + const opt = options()[optIndex] + if (!opt) return + if (multi()) { + toggle(opt.label) + return + } + pick(opt.label) + } + + const handleCustomSubmit = (e: Event) => { + e.preventDefault() + if (store.sending) return + + const value = input().trim() + if (!value) { + setStore("editing", false) + return + } + + if (multi()) { + const existing = store.answers[store.tab] ?? [] + const next = [...existing] + if (!next.includes(value)) next.push(value) + + const answers = [...store.answers] + answers[store.tab] = next + setStore("answers", answers) + setStore("editing", false) + return + } + + pick(value, true) + setStore("editing", false) + } + + return ( +
+ +
+ + {(q, index) => { + const active = () => index() === store.tab + const answered = () => (store.answers[index()]?.length ?? 0) > 0 + return ( + + ) + }} + + +
+
+ + +
+
+ {question()?.question} + {multi() ? " " + language.t("ui.question.multiHint") : ""} +
+
+ + {(opt, i) => { + const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false + return ( + + ) + }} + + + + + setTimeout(() => el.focus(), 0)} + type="text" + data-slot="custom-input" + placeholder={language.t("ui.question.custom.placeholder")} + value={input()} + disabled={store.sending} + onInput={(e) => { + const inputs = [...store.custom] + inputs[store.tab] = e.currentTarget.value + setStore("custom", inputs) + }} + /> + + + + +
+
+
+ + +
+
{language.t("ui.messagePart.review.title")}
+ + {(q, index) => { + const value = () => store.answers[index()]?.join(", ") ?? "" + const answered = () => Boolean(value()) + return ( +
+ {q.question} + + {answered() ? value() : language.t("ui.question.review.notAnswered")} + +
+ ) + }} +
+
+
+ +
+ + + + + + + + + +
+
+ ) +} diff --git a/packages/app/src/components/server/server-row.tsx b/packages/app/src/components/server/server-row.tsx new file mode 100644 index 0000000000..b43c07882c --- /dev/null +++ b/packages/app/src/components/server/server-row.tsx @@ -0,0 +1,77 @@ +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { JSXElement, ParentProps, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js" +import { serverDisplayName } from "@/context/server" +import type { ServerHealth } from "@/utils/server-health" + +interface ServerRowProps extends ParentProps { + url: string + status?: ServerHealth + class?: string + nameClass?: string + versionClass?: string + dimmed?: boolean + badge?: JSXElement +} + +export function ServerRow(props: ServerRowProps) { + const [truncated, setTruncated] = createSignal(false) + let nameRef: HTMLSpanElement | undefined + let versionRef: HTMLSpanElement | undefined + + const check = () => { + const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false + const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false + setTruncated(nameTruncated || versionTruncated) + } + + createEffect(() => { + props.url + props.status?.version + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(check) + return + } + check() + }) + + onMount(() => { + check() + if (typeof window === "undefined") return + window.addEventListener("resize", check) + onCleanup(() => window.removeEventListener("resize", check)) + }) + + const tooltipValue = () => ( + + {serverDisplayName(props.url)} + + {props.status?.version} + + + ) + + return ( + +
+
+ + {serverDisplayName(props.url)} + + + + {props.status?.version} + + + {props.badge} + {props.children} +
+ + ) +} diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index c5de54cf0f..4e5dae139c 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -3,12 +3,11 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { Button } from "@opencode-ai/ui/button" import { useParams } from "@solidjs/router" -import { AssistantMessage } from "@opencode-ai/sdk/v2/client" -import { findLast } from "@opencode-ai/util/array" import { useLayout } from "@/context/layout" import { useSync } from "@/context/sync" import { useLanguage } from "@/context/language" +import { getSessionContextMetrics } from "@/components/session/session-context-metrics" interface SessionContextUsageProps { variant?: "button" | "indicator" @@ -23,6 +22,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const variant = createMemo(() => props.variant ?? "button") const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) + const view = createMemo(() => layout.view(sessionKey)) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const usd = createMemo( @@ -33,30 +33,15 @@ export function SessionContextUsage(props: SessionContextUsageProps) { }), ) + const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all)) + const context = createMemo(() => metrics().context) const cost = createMemo(() => { - const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0) - return usd().format(total) - }) - - const context = createMemo(() => { - const locale = language.locale() - const last = findLast(messages(), (x) => { - if (x.role !== "assistant") return false - const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write - return total > 0 - }) as AssistantMessage - if (!last) return - const total = - last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write - const model = sync.data.provider.all.find((x) => x.id === last.providerID)?.models[last.modelID] - return { - tokens: total.toLocaleString(locale), - percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null, - } + return usd().format(metrics().totalCost) }) const openContext = () => { if (!params.id) return + if (!view().reviewPanel.opened()) view().reviewPanel.open() layout.fileTree.open() layout.fileTree.setTab("all") tabs().open("context") @@ -65,7 +50,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const circle = () => (
- +
) @@ -75,11 +60,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) { {(ctx) => ( <>
- {ctx().tokens} + {ctx().total.toLocaleString(language.locale())} {language.t("context.usage.tokens")}
- {ctx().percentage ?? 0}% + {ctx().usage ?? 0}% {language.t("context.usage.usage")}
diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts new file mode 100644 index 0000000000..68903a455b --- /dev/null +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test" +import type { Message } from "@opencode-ai/sdk/v2/client" +import { getSessionContextMetrics } from "./session-context-metrics" + +const assistant = ( + id: string, + tokens: { input: number; output: number; reasoning: number; read: number; write: number }, + cost: number, + providerID = "openai", + modelID = "gpt-4.1", +) => { + return { + id, + role: "assistant", + providerID, + modelID, + cost, + tokens: { + input: tokens.input, + output: tokens.output, + reasoning: tokens.reasoning, + cache: { + read: tokens.read, + write: tokens.write, + }, + }, + time: { created: 1 }, + } as unknown as Message +} + +const user = (id: string) => { + return { + id, + role: "user", + cost: 0, + time: { created: 1 }, + } as unknown as Message +} + +describe("getSessionContextMetrics", () => { + test("computes totals and usage from latest assistant with tokens", () => { + const messages = [ + user("u1"), + assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5), + assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25), + ] + const providers = [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-4.1": { + name: "GPT-4.1", + limit: { context: 1000 }, + }, + }, + }, + ] + + const metrics = getSessionContextMetrics(messages, providers) + + expect(metrics.totalCost).toBe(1.75) + expect(metrics.context?.message.id).toBe("a2") + expect(metrics.context?.total).toBe(500) + expect(metrics.context?.usage).toBe(50) + expect(metrics.context?.providerLabel).toBe("OpenAI") + expect(metrics.context?.modelLabel).toBe("GPT-4.1") + }) + + test("preserves fallback labels and null usage when model metadata is missing", () => { + const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")] + const providers = [{ id: "p-1", models: {} }] + + const metrics = getSessionContextMetrics(messages, providers) + + expect(metrics.context?.providerLabel).toBe("p-1") + expect(metrics.context?.modelLabel).toBe("m-1") + expect(metrics.context?.limit).toBeUndefined() + expect(metrics.context?.usage).toBeNull() + }) + + test("memoizes by message and provider array identity", () => { + const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)] + const providers = [{ id: "openai", models: {} }] + + const one = getSessionContextMetrics(messages, providers) + const two = getSessionContextMetrics(messages, providers) + const three = getSessionContextMetrics([...messages], providers) + + expect(two).toBe(one) + expect(three).not.toBe(one) + }) +}) diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts new file mode 100644 index 0000000000..2b6edbd951 --- /dev/null +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -0,0 +1,94 @@ +import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client" + +type Provider = { + id: string + name?: string + models: Record +} + +type Model = { + name?: string + limit: { + context: number + } +} + +type Context = { + message: AssistantMessage + provider?: Provider + model?: Model + providerLabel: string + modelLabel: string + limit: number | undefined + input: number + output: number + reasoning: number + cacheRead: number + cacheWrite: number + total: number + usage: number | null +} + +type Metrics = { + totalCost: number + context: Context | undefined +} + +const cache = new WeakMap>() + +const tokenTotal = (msg: AssistantMessage) => { + return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write +} + +const lastAssistantWithTokens = (messages: Message[]) => { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.role !== "assistant") continue + if (tokenTotal(msg) <= 0) continue + return msg + } +} + +const build = (messages: Message[], providers: Provider[]): Metrics => { + const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0) + const message = lastAssistantWithTokens(messages) + if (!message) return { totalCost, context: undefined } + + const provider = providers.find((item) => item.id === message.providerID) + const model = provider?.models[message.modelID] + const limit = model?.limit.context + const total = tokenTotal(message) + + return { + totalCost, + context: { + message, + provider, + model, + providerLabel: provider?.name ?? message.providerID, + modelLabel: model?.name ?? message.modelID, + limit, + input: message.tokens.input, + output: message.tokens.output, + reasoning: message.tokens.reasoning, + cacheRead: message.tokens.cache.read, + cacheWrite: message.tokens.cache.write, + total, + usage: limit ? Math.round((total / limit) * 100) : null, + }, + } +} + +export function getSessionContextMetrics(messages: Message[], providers: Provider[]) { + const byProvider = cache.get(messages) + if (byProvider) { + const hit = byProvider.get(providers) + if (hit) return hit + } + + const value = build(messages, providers) + const next = byProvider ?? new WeakMap() + next.set(providers, value) + if (!byProvider) cache.set(messages, next) + return value +} diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 37733caff6..8aae44863e 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -11,8 +11,9 @@ import { Accordion } from "@opencode-ai/ui/accordion" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { Code } from "@opencode-ai/ui/code" import { Markdown } from "@opencode-ai/ui/markdown" -import type { AssistantMessage, Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" +import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import { useLanguage } from "@/context/language" +import { getSessionContextMetrics } from "./session-context-metrics" interface SessionContextTabProps { messages: () => Message[] @@ -34,44 +35,11 @@ export function SessionContextTab(props: SessionContextTabProps) { }), ) - const ctx = createMemo(() => { - const last = findLast(props.messages(), (x) => { - if (x.role !== "assistant") return false - const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write - return total > 0 - }) as AssistantMessage - if (!last) return - - const provider = sync.data.provider.all.find((x) => x.id === last.providerID) - const model = provider?.models[last.modelID] - const limit = model?.limit.context - - const input = last.tokens.input - const output = last.tokens.output - const reasoning = last.tokens.reasoning - const cacheRead = last.tokens.cache.read - const cacheWrite = last.tokens.cache.write - const total = input + output + reasoning + cacheRead + cacheWrite - const usage = limit ? Math.round((total / limit) * 100) : null - - return { - message: last, - provider, - model, - limit, - input, - output, - reasoning, - cacheRead, - cacheWrite, - total, - usage, - } - }) + const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all)) + const ctx = createMemo(() => metrics().context) const cost = createMemo(() => { - const total = props.messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0) - return usd().format(total) + return usd().format(metrics().totalCost) }) const counts = createMemo(() => { @@ -114,14 +82,13 @@ export function SessionContextTab(props: SessionContextTabProps) { const providerLabel = createMemo(() => { const c = ctx() if (!c) return "—" - return c.provider?.name ?? c.message.providerID + return c.providerLabel }) const modelLabel = createMemo(() => { const c = ctx() if (!c) return "—" - if (c.model?.name) return c.model.name - return c.message.modelID + return c.modelLabel }) const breakdown = createMemo( diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 5b00f80c05..805e699312 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -6,18 +6,23 @@ import { useLayout } from "@/context/layout" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useGlobalSDK } from "@/context/global-sdk" import { getFilename } from "@opencode-ai/util/path" import { decode64 } from "@/utils/base64" +import { Persist, persisted } from "@/utils/persist" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" +import { AppIcon } from "@opencode-ai/ui/app-icon" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Popover } from "@opencode-ai/ui/popover" import { TextField } from "@opencode-ai/ui/text-field" import { Keybind } from "@opencode-ai/ui/keybind" +import { showToast } from "@opencode-ai/ui/toast" import { StatusPopover } from "../status-popover" export function SessionHeader() { @@ -25,6 +30,7 @@ export function SessionHeader() { const layout = useLayout() const params = useParams() const command = useCommand() + const server = useServer() const sync = useSync() const platform = usePlatform() const language = useLanguage() @@ -48,6 +54,153 @@ export function SessionHeader() { const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const view = createMemo(() => layout.view(sessionKey)) + const OPEN_APPS = [ + "vscode", + "cursor", + "zed", + "textmate", + "antigravity", + "finder", + "terminal", + "iterm2", + "ghostty", + "xcode", + "android-studio", + "powershell", + "sublime-text", + ] as const + type OpenApp = (typeof OPEN_APPS)[number] + + const MAC_APPS = [ + { id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" }, + { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" }, + { id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" }, + { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" }, + { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" }, + { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" }, + { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" }, + { id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" }, + { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, + ] as const + + const WINDOWS_APPS = [ + { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, + { id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" }, + { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, + ] as const + + const LINUX_APPS = [ + { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, + { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, + ] as const + + const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => { + if (platform.platform === "desktop" && platform.os) return platform.os + if (typeof navigator !== "object") return "unknown" + const value = navigator.platform || navigator.userAgent + if (/Mac/i.test(value)) return "macos" + if (/Win/i.test(value)) return "windows" + if (/Linux/i.test(value)) return "linux" + return "unknown" + }) + + const [exists, setExists] = createStore>>({ finder: true }) + + createEffect(() => { + if (platform.platform !== "desktop") return + if (!platform.checkAppExists) return + + const list = os() + const apps = list === "macos" ? MAC_APPS : list === "windows" ? WINDOWS_APPS : list === "linux" ? LINUX_APPS : [] + if (apps.length === 0) return + + void Promise.all( + apps.map((app) => + Promise.resolve(platform.checkAppExists?.(app.openWith)).then((value) => { + const ok = Boolean(value) + console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`) + return [app.id, ok] as const + }), + ), + ).then((entries) => { + setExists(Object.fromEntries(entries) as Partial>) + }) + }) + + const options = createMemo(() => { + if (os() === "macos") { + return [{ id: "finder", label: "Finder", icon: "finder" }, ...MAC_APPS.filter((app) => exists[app.id])] as const + } + + if (os() === "windows") { + return [ + { id: "finder", label: "File Explorer", icon: "file-explorer" }, + ...WINDOWS_APPS.filter((app) => exists[app.id]), + ] as const + } + + return [ + { id: "finder", label: "File Manager", icon: "finder" }, + ...LINUX_APPS.filter((app) => exists[app.id]), + ] as const + }) + + const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) + + const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) + const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0]) + + createEffect(() => { + if (platform.platform !== "desktop") return + const value = prefs.app + if (options().some((o) => o.id === value)) return + setPrefs("app", options()[0]?.id ?? "finder") + }) + + const openDir = (app: OpenApp) => { + const directory = projectDirectory() + if (!directory) return + if (!canOpen()) return + + const item = options().find((o) => o.id === app) + const openWith = item && "openWith" in item ? item.openWith : undefined + Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + }) + } + + const copyPath = () => { + const directory = projectDirectory() + if (!directory) return + navigator.clipboard + .writeText(directory) + .then(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("session.share.copy.copied"), + description: directory, + }) + }) + .catch((err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + }) + } + const [state, setState] = createStore({ share: false, unshare: false, @@ -150,6 +303,80 @@ export function SessionHeader() { {(mount) => (
+ + +
@@ -283,27 +510,57 @@ export function SessionHeader() { + +
+ -
- - + unknown[]} + visibleUserMessages={visibleUserMessages as () => unknown[]} + view={view} + info={info as () => unknown} + handoffFiles={() => handoff.session.get(sessionKey())?.files} + codeComponent={codeComponent} + addCommentToContext={addCommentToContext} + activeDraggable={() => store.activeDraggable} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onDragOver={handleDragOver} + fileTreeTab={fileTreeTab} + setFileTreeTabValue={setFileTreeTabValue} + diffsReady={diffsReady()} + diffFiles={diffFiles()} + kinds={kinds()} + activeDiff={tree.activeDiff} + focusReviewDiff={focusReviewDiff} + />
- -
- - -
- - {(title) => ( -
- {title} -
- )} -
-
-
- {language.t("common.loading")} - {language.t("common.loading.ellipsis")} -
-
-
- {language.t("terminal.loading")} -
-
- } - > - - - -
- { - // Only switch tabs if not in the middle of starting edit mode - terminal.open(id) - }} - class="!h-auto !flex-none" - > - - t.id)}> - - {(pty) => ( - { - view().terminal.close() - setUi("autoCreated", false) - }} - /> - )} - - -
- - - -
-
-
-
- - {(pty) => ( -
- - terminal.clone(pty.id)} - /> - -
- )} -
-
-
- - - {(draggedId) => { - const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId())) - return ( - - {(t) => ( -
- {(() => { - const title = t().title - const number = t().titleNumber - const match = title.match(/^Terminal (\d+)$/) - const parsed = match ? Number(match[1]) : undefined - const isDefaultTitle = - Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number - - if (title && !isDefaultTitle) return title - if (Number.isFinite(number) && number > 0) - return language.t("terminal.title.numbered", { number }) - if (title) return title - return language.t("terminal.title") - })()} -
- )} -
- ) - }} -
-
-
-
-
-
+ handoff.terminal.get(params.dir!) ?? []} + activeTerminalDraggable={() => store.activeTerminalDraggable} + handleTerminalDragStart={handleTerminalDragStart} + handleTerminalDragOver={handleTerminalDragOver} + handleTerminalDragEnd={handleTerminalDragEnd} + onCloseTab={() => setUi("autoCreated", false)} + />
) } diff --git a/packages/app/src/pages/session/file-tab-scroll.test.ts b/packages/app/src/pages/session/file-tab-scroll.test.ts new file mode 100644 index 0000000000..89e0dcc8fd --- /dev/null +++ b/packages/app/src/pages/session/file-tab-scroll.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import { nextTabListScrollLeft } from "./file-tab-scroll" + +describe("nextTabListScrollLeft", () => { + test("does not scroll when width shrinks", () => { + const left = nextTabListScrollLeft({ + prevScrollWidth: 500, + scrollWidth: 420, + clientWidth: 300, + prevContextOpen: false, + contextOpen: false, + }) + + expect(left).toBeUndefined() + }) + + test("scrolls to start when context tab opens", () => { + const left = nextTabListScrollLeft({ + prevScrollWidth: 400, + scrollWidth: 500, + clientWidth: 320, + prevContextOpen: false, + contextOpen: true, + }) + + expect(left).toBe(0) + }) + + test("scrolls to right edge for new file tabs", () => { + const left = nextTabListScrollLeft({ + prevScrollWidth: 500, + scrollWidth: 780, + clientWidth: 300, + prevContextOpen: true, + contextOpen: true, + }) + + expect(left).toBe(480) + }) +}) diff --git a/packages/app/src/pages/session/file-tab-scroll.ts b/packages/app/src/pages/session/file-tab-scroll.ts new file mode 100644 index 0000000000..b69188d405 --- /dev/null +++ b/packages/app/src/pages/session/file-tab-scroll.ts @@ -0,0 +1,67 @@ +type Input = { + prevScrollWidth: number + scrollWidth: number + clientWidth: number + prevContextOpen: boolean + contextOpen: boolean +} + +export const nextTabListScrollLeft = (input: Input) => { + if (input.scrollWidth <= input.prevScrollWidth) return + if (!input.prevContextOpen && input.contextOpen) return 0 + if (input.scrollWidth <= input.clientWidth) return + return input.scrollWidth - input.clientWidth +} + +export const createFileTabListSync = (input: { el: HTMLDivElement; contextOpen: () => boolean }) => { + let frame: number | undefined + let prevScrollWidth = input.el.scrollWidth + let prevContextOpen = input.contextOpen() + + const update = () => { + const scrollWidth = input.el.scrollWidth + const clientWidth = input.el.clientWidth + const contextOpen = input.contextOpen() + const left = nextTabListScrollLeft({ + prevScrollWidth, + scrollWidth, + clientWidth, + prevContextOpen, + contextOpen, + }) + + if (left !== undefined) { + input.el.scrollTo({ + left, + behavior: "smooth", + }) + } + + prevScrollWidth = scrollWidth + prevContextOpen = contextOpen + } + + const schedule = () => { + if (frame !== undefined) cancelAnimationFrame(frame) + frame = requestAnimationFrame(() => { + frame = undefined + update() + }) + } + + const onWheel = (e: WheelEvent) => { + if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return + input.el.scrollLeft += e.deltaY > 0 ? 50 : -50 + e.preventDefault() + } + + input.el.addEventListener("wheel", onWheel, { passive: false }) + const observer = new MutationObserver(schedule) + observer.observe(input.el, { childList: true }) + + return () => { + input.el.removeEventListener("wheel", onWheel) + observer.disconnect() + if (frame !== undefined) cancelAnimationFrame(frame) + } +} diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx new file mode 100644 index 0000000000..0c8281a66d --- /dev/null +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -0,0 +1,516 @@ +import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js" +import { createStore } from "solid-js/store" +import { Dynamic } from "solid-js/web" +import { checksum } from "@opencode-ai/util/encode" +import { decode64 } from "@/utils/base64" +import { showToast } from "@opencode-ai/ui/toast" +import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment" +import { Mark } from "@opencode-ai/ui/logo" +import { Tabs } from "@opencode-ai/ui/tabs" +import { useLayout } from "@/context/layout" +import { useFile, type SelectedLineRange } from "@/context/file" +import { useComments } from "@/context/comments" +import { useLanguage } from "@/context/language" + +export function FileTabContent(props: { + tab: string + activeTab: () => string + tabs: () => ReturnType["tabs"]> + view: () => ReturnType["view"]> + handoffFiles: () => Record | undefined + file: ReturnType + comments: ReturnType + language: ReturnType + codeComponent: NonNullable + addCommentToContext: (input: { + file: string + selection: SelectedLineRange + comment: string + preview?: string + origin?: "review" | "file" + }) => void +}) { + let scroll: HTMLDivElement | undefined + let scrollFrame: number | undefined + let pending: { x: number; y: number } | undefined + let codeScroll: HTMLElement[] = [] + + const path = createMemo(() => props.file.pathFromTab(props.tab)) + const state = createMemo(() => { + const p = path() + if (!p) return + return props.file.get(p) + }) + const contents = createMemo(() => state()?.content?.content ?? "") + const cacheKey = createMemo(() => checksum(contents())) + const isImage = createMemo(() => { + const c = state()?.content + return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml" + }) + const isSvg = createMemo(() => { + const c = state()?.content + return c?.mimeType === "image/svg+xml" + }) + const isBinary = createMemo(() => state()?.content?.type === "binary") + const svgContent = createMemo(() => { + if (!isSvg()) return + const c = state()?.content + if (!c) return + if (c.encoding !== "base64") return c.content + return decode64(c.content) + }) + + const svgDecodeFailed = createMemo(() => { + if (!isSvg()) return false + const c = state()?.content + if (!c) return false + if (c.encoding !== "base64") return false + return svgContent() === undefined + }) + + const svgToast = { shown: false } + createEffect(() => { + if (!svgDecodeFailed()) return + if (svgToast.shown) return + svgToast.shown = true + showToast({ + variant: "error", + title: props.language.t("toast.file.loadFailed.title"), + description: "Invalid base64 content.", + }) + }) + const svgPreviewUrl = createMemo(() => { + if (!isSvg()) return + const c = state()?.content + if (!c) return + if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}` + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}` + }) + const imageDataUrl = createMemo(() => { + if (!isImage()) return + const c = state()?.content + return `data:${c?.mimeType};base64,${c?.content}` + }) + const selectedLines = createMemo(() => { + const p = path() + if (!p) return null + if (props.file.ready()) return props.file.selectedLines(p) ?? null + return props.handoffFiles()?.[p] ?? null + }) + + let wrap: HTMLDivElement | undefined + + const fileComments = createMemo(() => { + const p = path() + if (!p) return [] + return props.comments.list(p) + }) + + const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection)) + + const [note, setNote] = createStore({ + openedComment: null as string | null, + commenting: null as SelectedLineRange | null, + draft: "", + positions: {} as Record, + draftTop: undefined as number | undefined, + }) + + const openedComment = () => note.openedComment + const setOpenedComment = ( + value: typeof note.openedComment | ((value: typeof note.openedComment) => typeof note.openedComment), + ) => setNote("openedComment", value) + + const commenting = () => note.commenting + const setCommenting = (value: typeof note.commenting | ((value: typeof note.commenting) => typeof note.commenting)) => + setNote("commenting", value) + + const draft = () => note.draft + const setDraft = (value: typeof note.draft | ((value: typeof note.draft) => typeof note.draft)) => + setNote("draft", value) + + const positions = () => note.positions + const setPositions = (value: typeof note.positions | ((value: typeof note.positions) => typeof note.positions)) => + setNote("positions", value) + + const draftTop = () => note.draftTop + const setDraftTop = (value: typeof note.draftTop | ((value: typeof note.draftTop) => typeof note.draftTop)) => + setNote("draftTop", value) + + const commentLabel = (range: SelectedLineRange) => { + const start = Math.min(range.start, range.end) + const end = Math.max(range.start, range.end) + if (start === end) return `line ${start}` + return `lines ${start}-${end}` + } + + const getRoot = () => { + const el = wrap + if (!el) return + + const host = el.querySelector("diffs-container") + if (!(host instanceof HTMLElement)) return + + const root = host.shadowRoot + if (!root) return + + return root + } + + const findMarker = (root: ShadowRoot, range: SelectedLineRange) => { + const line = Math.max(range.start, range.end) + const node = root.querySelector(`[data-line="${line}"]`) + if (!(node instanceof HTMLElement)) return + return node + } + + const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => { + const wrapperRect = wrapper.getBoundingClientRect() + const rect = marker.getBoundingClientRect() + return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2) + } + + const updateComments = () => { + const el = wrap + const root = getRoot() + if (!el || !root) { + setPositions({}) + setDraftTop(undefined) + return + } + + const next: Record = {} + for (const comment of fileComments()) { + const marker = findMarker(root, comment.selection) + if (!marker) continue + next[comment.id] = markerTop(el, marker) + } + + setPositions(next) + + const range = commenting() + if (!range) { + setDraftTop(undefined) + return + } + + const marker = findMarker(root, range) + if (!marker) { + setDraftTop(undefined) + return + } + + setDraftTop(markerTop(el, marker)) + } + + const scheduleComments = () => { + requestAnimationFrame(updateComments) + } + + createEffect(() => { + fileComments() + scheduleComments() + }) + + createEffect(() => { + const range = commenting() + scheduleComments() + if (!range) return + setDraft("") + }) + + createEffect(() => { + const focus = props.comments.focus() + const p = path() + if (!focus || !p) return + if (focus.file !== p) return + if (props.activeTab() !== props.tab) return + + const target = fileComments().find((comment) => comment.id === focus.id) + if (!target) return + + setOpenedComment(target.id) + setCommenting(null) + props.file.setSelectedLines(p, target.selection) + requestAnimationFrame(() => props.comments.clearFocus()) + }) + + const getCodeScroll = () => { + const el = scroll + if (!el) return [] + + const host = el.querySelector("diffs-container") + if (!(host instanceof HTMLElement)) return [] + + const root = host.shadowRoot + if (!root) return [] + + return Array.from(root.querySelectorAll("[data-code]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0, + ) + } + + const queueScrollUpdate = (next: { x: number; y: number }) => { + pending = next + if (scrollFrame !== undefined) return + + scrollFrame = requestAnimationFrame(() => { + scrollFrame = undefined + + const out = pending + pending = undefined + if (!out) return + + props.view().setScroll(props.tab, out) + }) + } + + const handleCodeScroll = (event: Event) => { + const el = scroll + if (!el) return + + const target = event.currentTarget + if (!(target instanceof HTMLElement)) return + + queueScrollUpdate({ + x: target.scrollLeft, + y: el.scrollTop, + }) + } + + const syncCodeScroll = () => { + const next = getCodeScroll() + if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return + + for (const item of codeScroll) { + item.removeEventListener("scroll", handleCodeScroll) + } + + codeScroll = next + + for (const item of codeScroll) { + item.addEventListener("scroll", handleCodeScroll) + } + } + + const restoreScroll = () => { + const el = scroll + if (!el) return + + const s = props.view()?.scroll(props.tab) + if (!s) return + + syncCodeScroll() + + if (codeScroll.length > 0) { + for (const item of codeScroll) { + if (item.scrollLeft !== s.x) item.scrollLeft = s.x + } + } + + if (el.scrollTop !== s.y) el.scrollTop = s.y + if (codeScroll.length > 0) return + if (el.scrollLeft !== s.x) el.scrollLeft = s.x + } + + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + if (codeScroll.length === 0) syncCodeScroll() + + queueScrollUpdate({ + x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft, + y: event.currentTarget.scrollTop, + }) + } + + createEffect( + on( + () => state()?.loaded, + (loaded) => { + if (!loaded) return + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => props.file.ready(), + (ready) => { + if (!ready) return + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => props.tabs().active() === props.tab, + (active) => { + if (!active) return + if (!state()?.loaded) return + requestAnimationFrame(restoreScroll) + }, + ), + ) + + onCleanup(() => { + for (const item of codeScroll) { + item.removeEventListener("scroll", handleCodeScroll) + } + + if (scrollFrame === undefined) return + cancelAnimationFrame(scrollFrame) + }) + + const renderCode = (source: string, wrapperClass: string) => ( +
{ + wrap = el + scheduleComments() + }} + class={`relative overflow-hidden ${wrapperClass}`} + > + { + requestAnimationFrame(restoreScroll) + requestAnimationFrame(scheduleComments) + }} + onLineSelected={(range: SelectedLineRange | null) => { + const p = path() + if (!p) return + props.file.setSelectedLines(p, range) + if (!range) setCommenting(null) + }} + onLineSelectionEnd={(range: SelectedLineRange | null) => { + if (!range) { + setCommenting(null) + return + } + + setOpenedComment(null) + setCommenting(range) + }} + overflow="scroll" + class="select-text" + /> + + {(comment) => ( + { + const p = path() + if (!p) return + props.file.setSelectedLines(p, comment.selection) + }} + onClick={() => { + const p = path() + if (!p) return + setCommenting(null) + setOpenedComment((current) => (current === comment.id ? null : comment.id)) + props.file.setSelectedLines(p, comment.selection) + }} + /> + )} + + + {(range) => ( + + setDraft(value)} + onCancel={() => setCommenting(null)} + onSubmit={(value) => { + const p = path() + if (!p) return + props.addCommentToContext({ + file: p, + selection: range(), + comment: value, + origin: "file", + }) + setCommenting(null) + }} + onPopoverFocusOut={(e: FocusEvent) => { + const current = e.currentTarget as HTMLDivElement + const target = e.relatedTarget + if (target instanceof Node && current.contains(target)) return + + setTimeout(() => { + if (!document.activeElement || !current.contains(document.activeElement)) { + setCommenting(null) + } + }, 0) + }} + /> + + )} + +
+ ) + + return ( + { + scroll = el + restoreScroll() + }} + onScroll={handleScroll} + > + + +
+ {path()} requestAnimationFrame(restoreScroll)} + /> +
+
+ +
+ {renderCode(svgContent() ?? "", "")} + +
+ {path()} +
+
+
+
+ +
+ +
+
{path()?.split("/").pop()}
+
{props.language.t("session.files.binaryContent")}
+
+
+
+ {renderCode(contents(), "pb-40")} + +
{props.language.t("common.loading")}...
+
+ {(err) =>
{err()}
}
+
+
+ ) +} diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts new file mode 100644 index 0000000000..0afc7eb6a5 --- /dev/null +++ b/packages/app/src/pages/session/helpers.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { combineCommandSections, createOpenReviewFile, focusTerminalById } from "./helpers" + +describe("createOpenReviewFile", () => { + test("opens and loads selected review file", () => { + const calls: string[] = [] + const openReviewFile = createOpenReviewFile({ + showAllFiles: () => calls.push("show"), + tabForPath: (path) => { + calls.push(`tab:${path}`) + return `file://${path}` + }, + openTab: (tab) => calls.push(`open:${tab}`), + loadFile: (path) => calls.push(`load:${path}`), + }) + + openReviewFile("src/a.ts") + + expect(calls).toEqual(["show", "tab:src/a.ts", "open:file://src/a.ts", "load:src/a.ts"]) + }) +}) + +describe("focusTerminalById", () => { + test("focuses textarea when present", () => { + document.body.innerHTML = `
` + + const focused = focusTerminalById("one") + + expect(focused).toBe(true) + expect(document.activeElement?.tagName).toBe("TEXTAREA") + }) + + test("falls back to terminal element focus", () => { + document.body.innerHTML = `
` + const terminal = document.querySelector('[data-component="terminal"]') as HTMLElement + let pointerDown = false + terminal.addEventListener("pointerdown", () => { + pointerDown = true + }) + + const focused = focusTerminalById("two") + + expect(focused).toBe(true) + expect(document.activeElement).toBe(terminal) + expect(pointerDown).toBe(true) + }) +}) + +describe("combineCommandSections", () => { + test("keeps section order stable", () => { + const result = combineCommandSections([ + [{ id: "a", title: "A" }], + [ + { id: "b", title: "B" }, + { id: "c", title: "C" }, + ], + ]) + + expect(result.map((item) => item.id)).toEqual(["a", "b", "c"]) + }) +}) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts new file mode 100644 index 0000000000..d9ce90793f --- /dev/null +++ b/packages/app/src/pages/session/helpers.ts @@ -0,0 +1,38 @@ +import type { CommandOption } from "@/context/command" + +export const focusTerminalById = (id: string) => { + const wrapper = document.getElementById(`terminal-wrapper-${id}`) + const terminal = wrapper?.querySelector('[data-component="terminal"]') + if (!(terminal instanceof HTMLElement)) return false + + const textarea = terminal.querySelector("textarea") + if (textarea instanceof HTMLTextAreaElement) { + textarea.focus() + return true + } + + terminal.focus() + terminal.dispatchEvent( + typeof PointerEvent === "function" + ? new PointerEvent("pointerdown", { bubbles: true, cancelable: true }) + : new MouseEvent("pointerdown", { bubbles: true, cancelable: true }), + ) + return true +} + +export const createOpenReviewFile = (input: { + showAllFiles: () => void + tabForPath: (path: string) => string + openTab: (tab: string) => void + loadFile: (path: string) => void +}) => { + return (path: string) => { + input.showAllFiles() + input.openTab(input.tabForPath(path)) + input.loadFile(path) + } +} + +export const combineCommandSections = (sections: readonly (readonly CommandOption[])[]) => { + return sections.flatMap((section) => section) +} diff --git a/packages/app/src/pages/session/message-gesture.test.ts b/packages/app/src/pages/session/message-gesture.test.ts new file mode 100644 index 0000000000..b2af4bb834 --- /dev/null +++ b/packages/app/src/pages/session/message-gesture.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test" +import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "./message-gesture" + +describe("normalizeWheelDelta", () => { + test("converts line mode to px", () => { + expect(normalizeWheelDelta({ deltaY: 3, deltaMode: 1, rootHeight: 500 })).toBe(120) + }) + + test("converts page mode to container height", () => { + expect(normalizeWheelDelta({ deltaY: -1, deltaMode: 2, rootHeight: 600 })).toBe(-600) + }) + + test("keeps pixel mode unchanged", () => { + expect(normalizeWheelDelta({ deltaY: 16, deltaMode: 0, rootHeight: 600 })).toBe(16) + }) +}) + +describe("shouldMarkBoundaryGesture", () => { + test("marks when nested scroller cannot scroll", () => { + expect( + shouldMarkBoundaryGesture({ + delta: 20, + scrollTop: 0, + scrollHeight: 300, + clientHeight: 300, + }), + ).toBe(true) + }) + + test("marks when scrolling beyond top boundary", () => { + expect( + shouldMarkBoundaryGesture({ + delta: -40, + scrollTop: 10, + scrollHeight: 1000, + clientHeight: 400, + }), + ).toBe(true) + }) + + test("marks when scrolling beyond bottom boundary", () => { + expect( + shouldMarkBoundaryGesture({ + delta: 50, + scrollTop: 580, + scrollHeight: 1000, + clientHeight: 400, + }), + ).toBe(true) + }) + + test("does not mark when nested scroller can consume movement", () => { + expect( + shouldMarkBoundaryGesture({ + delta: 20, + scrollTop: 200, + scrollHeight: 1000, + clientHeight: 400, + }), + ).toBe(false) + }) +}) diff --git a/packages/app/src/pages/session/message-gesture.ts b/packages/app/src/pages/session/message-gesture.ts new file mode 100644 index 0000000000..731cb1bdeb --- /dev/null +++ b/packages/app/src/pages/session/message-gesture.ts @@ -0,0 +1,21 @@ +export const normalizeWheelDelta = (input: { deltaY: number; deltaMode: number; rootHeight: number }) => { + if (input.deltaMode === 1) return input.deltaY * 40 + if (input.deltaMode === 2) return input.deltaY * input.rootHeight + return input.deltaY +} + +export const shouldMarkBoundaryGesture = (input: { + delta: number + scrollTop: number + scrollHeight: number + clientHeight: number +}) => { + const max = input.scrollHeight - input.clientHeight + if (max <= 1) return true + if (!input.delta) return false + + if (input.delta < 0) return input.scrollTop + input.delta <= 0 + + const remaining = max - input.scrollTop + return input.delta > remaining +} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx new file mode 100644 index 0000000000..f536c7061f --- /dev/null +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -0,0 +1,348 @@ +import { For, onCleanup, onMount, Show, type JSX } from "solid-js" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { InlineInput } from "@opencode-ai/ui/inline-input" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { SessionTurn } from "@opencode-ai/ui/session-turn" +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" + +export function MessageTimeline(props: { + mobileChanges: boolean + mobileFallback: JSX.Element + scroll: { overflow: boolean; bottom: boolean } + onResumeScroll: () => void + setScrollRef: (el: HTMLDivElement | undefined) => void + onScheduleScrollState: (el: HTMLDivElement) => void + onAutoScrollHandleScroll: () => void + onMarkScrollGesture: (target?: EventTarget | null) => void + hasScrollGesture: () => boolean + isDesktop: boolean + onScrollSpyScroll: () => void + onAutoScrollInteraction: (event: MouseEvent) => void + showHeader: boolean + centered: boolean + title?: string + parentID?: string + openTitleEditor: () => void + closeTitleEditor: () => void + saveTitleEditor: () => void | Promise + titleRef: (el: HTMLInputElement) => void + titleState: { + draft: string + editing: boolean + saving: boolean + menuOpen: boolean + pendingRename: boolean + } + onTitleDraft: (value: string) => void + onTitleMenuOpen: (open: boolean) => void + onTitlePendingRename: (value: boolean) => void + onNavigateParent: () => void + sessionID: string + onArchiveSession: (sessionID: string) => void + onDeleteSession: (sessionID: string) => void + t: (key: string, vars?: Record) => string + setContentRef: (el: HTMLDivElement) => void + turnStart: number + onRenderEarlier: () => void + historyMore: boolean + historyLoading: boolean + onLoadEarlier: () => void + renderedUserMessages: UserMessage[] + anchor: (id: string) => string + onRegisterMessage: (el: HTMLDivElement, id: string) => void + onUnregisterMessage: (id: string) => void + onFirstTurnMount?: () => void + lastUserMessageID?: string + expanded: Record + onToggleExpanded: (id: string) => void +}) { + let touchGesture: number | undefined + + return ( + {props.mobileFallback}
} + > +
+
+ +
+
{ + const root = e.currentTarget + const target = e.target instanceof Element ? e.target : undefined + const nested = target?.closest("[data-scrollable]") + if (!nested || nested === root) { + props.onMarkScrollGesture(root) + return + } + + if (!(nested instanceof HTMLElement)) { + props.onMarkScrollGesture(root) + return + } + + const delta = normalizeWheelDelta({ + deltaY: e.deltaY, + deltaMode: e.deltaMode, + rootHeight: root.clientHeight, + }) + if (!delta) return + + if ( + shouldMarkBoundaryGesture({ + delta, + scrollTop: nested.scrollTop, + scrollHeight: nested.scrollHeight, + clientHeight: nested.clientHeight, + }) + ) { + props.onMarkScrollGesture(root) + } + }} + onTouchStart={(e) => { + touchGesture = e.touches[0]?.clientY + }} + onTouchMove={(e) => { + const next = e.touches[0]?.clientY + const prev = touchGesture + touchGesture = next + if (next === undefined || prev === undefined) return + + const delta = prev - next + if (!delta) return + + const root = e.currentTarget + const target = e.target instanceof Element ? e.target : undefined + const nested = target?.closest("[data-scrollable]") + if (!nested || nested === root) { + props.onMarkScrollGesture(root) + return + } + + if (!(nested instanceof HTMLElement)) { + props.onMarkScrollGesture(root) + return + } + + if ( + shouldMarkBoundaryGesture({ + delta, + scrollTop: nested.scrollTop, + scrollHeight: nested.scrollHeight, + clientHeight: nested.clientHeight, + }) + ) { + props.onMarkScrollGesture(root) + } + }} + onTouchEnd={() => { + touchGesture = undefined + }} + onTouchCancel={() => { + touchGesture = undefined + }} + onPointerDown={(e) => { + if (e.target !== e.currentTarget) return + props.onMarkScrollGesture(e.currentTarget) + }} + onScroll={(e) => { + props.onScheduleScrollState(e.currentTarget) + if (!props.hasScrollGesture()) return + props.onAutoScrollHandleScroll() + props.onMarkScrollGesture(e.currentTarget) + if (props.isDesktop) props.onScrollSpyScroll() + }} + onClick={props.onAutoScrollInteraction} + class="relative min-w-0 w-full h-full overflow-y-auto session-scroller" + style={{ "--session-title-height": props.showHeader ? "40px" : "0px" }} + > + +
+
+
+ + + + + + {props.title} + + } + > + props.onTitleDraft(event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void props.saveTitleEditor() + return + } + if (event.key === "Escape") { + event.preventDefault() + props.closeTitleEditor() + } + }} + onBlur={props.closeTitleEditor} + /> + + +
+ + {(id) => ( +
+ + + + + + { + if (!props.titleState.pendingRename) return + event.preventDefault() + props.onTitlePendingRename(false) + props.openTitleEditor() + }} + > + { + props.onTitlePendingRename(true) + props.onTitleMenuOpen(false) + }} + > + {props.t("common.rename")} + + props.onArchiveSession(id())}> + {props.t("common.archive")} + + + props.onDeleteSession(id())}> + {props.t("common.delete")} + + + + +
+ )} +
+
+
+
+ +
+ 0}> +
+ +
+
+ +
+ +
+
+ + {(message) => { + if (import.meta.env.DEV && props.onFirstTurnMount) { + onMount(() => props.onFirstTurnMount?.()) + } + + return ( +
{ + props.onRegisterMessage(el, message.id) + onCleanup(() => props.onUnregisterMessage(message.id)) + }} + classList={{ + "min-w-0 w-full max-w-full": true, + "md:max-w-200 3xl:max-w-[1200px]": props.centered, + }} + > + props.onToggleExpanded(message.id)} + classes={{ + root: "min-w-0 w-full relative", + content: "flex flex-col justify-between !overflow-visible", + container: "w-full px-4 md:px-6", + }} + /> +
+ ) + }} +
+
+
+
+
+ ) +} diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx new file mode 100644 index 0000000000..a4232dd74e --- /dev/null +++ b/packages/app/src/pages/session/review-tab.tsx @@ -0,0 +1,158 @@ +import { createEffect, on, onCleanup, createSignal, type JSX } from "solid-js" +import type { FileDiff } from "@opencode-ai/sdk/v2" +import { SessionReview } from "@opencode-ai/ui/session-review" +import type { SelectedLineRange } from "@/context/file" +import { useSDK } from "@/context/sdk" +import { useLayout } from "@/context/layout" +import type { LineComment } from "@/context/comments" + +export type DiffStyle = "unified" | "split" + +export interface SessionReviewTabProps { + title?: JSX.Element + empty?: JSX.Element + diffs: () => FileDiff[] + view: () => ReturnType["view"]> + diffStyle: DiffStyle + onDiffStyleChange?: (style: DiffStyle) => void + onViewFile?: (file: string) => void + onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void + comments?: LineComment[] + focusedComment?: { file: string; id: string } | null + onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void + focusedFile?: string + onScrollRef?: (el: HTMLDivElement) => void + classes?: { + root?: string + header?: string + container?: string + } +} + +export function StickyAddButton(props: { children: JSX.Element }) { + const [stuck, setStuck] = createSignal(false) + let button: HTMLDivElement | undefined + + createEffect(() => { + const node = button + if (!node) return + + const scroll = node.parentElement + if (!scroll) return + + const handler = () => { + const rect = node.getBoundingClientRect() + const scrollRect = scroll.getBoundingClientRect() + setStuck(rect.right >= scrollRect.right && scroll.scrollWidth > scroll.clientWidth) + } + + scroll.addEventListener("scroll", handler, { passive: true }) + const observer = new ResizeObserver(handler) + observer.observe(scroll) + handler() + onCleanup(() => { + scroll.removeEventListener("scroll", handler) + observer.disconnect() + }) + }) + + return ( +
+ {props.children} +
+ ) +} + +export function SessionReviewTab(props: SessionReviewTabProps) { + let scroll: HTMLDivElement | undefined + let frame: number | undefined + let pending: { x: number; y: number } | undefined + + const sdk = useSDK() + + const readFile = async (path: string) => { + return sdk.client.file + .read({ path }) + .then((x) => x.data) + .catch(() => undefined) + } + + const restoreScroll = () => { + const el = scroll + if (!el) return + + const s = props.view().scroll("review") + if (!s) return + + if (el.scrollTop !== s.y) el.scrollTop = s.y + if (el.scrollLeft !== s.x) el.scrollLeft = s.x + } + + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + pending = { + x: event.currentTarget.scrollLeft, + y: event.currentTarget.scrollTop, + } + if (frame !== undefined) return + + frame = requestAnimationFrame(() => { + frame = undefined + + const next = pending + pending = undefined + if (!next) return + + props.view().setScroll("review", next) + }) + } + + createEffect( + on( + () => props.diffs().length, + () => { + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + onCleanup(() => { + if (frame === undefined) return + cancelAnimationFrame(frame) + }) + + return ( + { + scroll = el + props.onScrollRef?.(el) + restoreScroll() + }} + onScroll={handleScroll} + onDiffRendered={() => requestAnimationFrame(restoreScroll)} + open={props.view().review.open()} + onOpenChange={props.view().review.setOpen} + classes={{ + root: props.classes?.root ?? "pb-40", + header: props.classes?.header ?? "px-6", + container: props.classes?.container ?? "px-6", + }} + diffs={props.diffs()} + diffStyle={props.diffStyle} + onDiffStyleChange={props.onDiffStyleChange} + onViewFile={props.onViewFile} + focusedFile={props.focusedFile} + readFile={readFile} + onLineComment={props.onLineComment} + comments={props.comments} + focusedComment={props.focusedComment} + onFocusedCommentChange={props.onFocusedCommentChange} + /> + ) +} diff --git a/packages/app/src/pages/session/scroll-spy.test.ts b/packages/app/src/pages/session/scroll-spy.test.ts new file mode 100644 index 0000000000..f3e6775cb4 --- /dev/null +++ b/packages/app/src/pages/session/scroll-spy.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test" +import { createScrollSpy, pickOffsetId, pickVisibleId } from "./scroll-spy" + +const rect = (top: number, height = 80): DOMRect => + ({ + x: 0, + y: top, + top, + left: 0, + right: 800, + bottom: top + height, + width: 800, + height, + toJSON: () => ({}), + }) as DOMRect + +const setRect = (el: Element, top: number, height = 80) => { + Object.defineProperty(el, "getBoundingClientRect", { + configurable: true, + value: () => rect(top, height), + }) +} + +describe("pickVisibleId", () => { + test("prefers higher intersection ratio", () => { + const id = pickVisibleId( + [ + { id: "a", ratio: 0.2, top: 100 }, + { id: "b", ratio: 0.8, top: 300 }, + ], + 120, + ) + + expect(id).toBe("b") + }) + + test("breaks ratio ties by nearest line", () => { + const id = pickVisibleId( + [ + { id: "a", ratio: 0.5, top: 90 }, + { id: "b", ratio: 0.5, top: 140 }, + ], + 130, + ) + + expect(id).toBe("b") + }) +}) + +describe("pickOffsetId", () => { + test("uses binary search cutoff", () => { + const id = pickOffsetId( + [ + { id: "a", top: 0 }, + { id: "b", top: 200 }, + { id: "c", top: 400 }, + ], + 350, + ) + + expect(id).toBe("b") + }) +}) + +describe("createScrollSpy fallback", () => { + test("tracks active id from offsets and dirty refresh", () => { + const active: string[] = [] + const root = document.createElement("div") as HTMLDivElement + const one = document.createElement("div") + const two = document.createElement("div") + const three = document.createElement("div") + + root.append(one, two, three) + document.body.append(root) + + Object.defineProperty(root, "scrollTop", { configurable: true, writable: true, value: 250 }) + setRect(root, 0, 800) + setRect(one, -250) + setRect(two, -50) + setRect(three, 150) + + const queue: FrameRequestCallback[] = [] + const flush = () => { + const run = [...queue] + queue.length = 0 + for (const cb of run) cb(0) + } + + const spy = createScrollSpy({ + onActive: (id) => active.push(id), + raf: (cb) => (queue.push(cb), queue.length), + caf: () => {}, + IntersectionObserver: undefined, + ResizeObserver: undefined, + MutationObserver: undefined, + }) + + spy.setContainer(root) + spy.register(one, "a") + spy.register(two, "b") + spy.register(three, "c") + spy.onScroll() + flush() + + expect(spy.getActiveId()).toBe("b") + expect(active.at(-1)).toBe("b") + + root.scrollTop = 450 + setRect(one, -450) + setRect(two, -250) + setRect(three, -50) + spy.onScroll() + flush() + expect(spy.getActiveId()).toBe("c") + + root.scrollTop = 250 + setRect(one, -250) + setRect(two, 250) + setRect(three, 150) + spy.markDirty() + spy.onScroll() + flush() + expect(spy.getActiveId()).toBe("a") + + spy.destroy() + }) +}) diff --git a/packages/app/src/pages/session/scroll-spy.ts b/packages/app/src/pages/session/scroll-spy.ts new file mode 100644 index 0000000000..8c52d77dce --- /dev/null +++ b/packages/app/src/pages/session/scroll-spy.ts @@ -0,0 +1,274 @@ +type Visible = { + id: string + ratio: number + top: number +} + +type Offset = { + id: string + top: number +} + +type Input = { + onActive: (id: string) => void + raf?: (cb: FrameRequestCallback) => number + caf?: (id: number) => void + IntersectionObserver?: typeof globalThis.IntersectionObserver + ResizeObserver?: typeof globalThis.ResizeObserver + MutationObserver?: typeof globalThis.MutationObserver +} + +export const pickVisibleId = (list: Visible[], line: number) => { + if (list.length === 0) return + + const sorted = [...list].sort((a, b) => { + if (b.ratio !== a.ratio) return b.ratio - a.ratio + + const da = Math.abs(a.top - line) + const db = Math.abs(b.top - line) + if (da !== db) return da - db + + return a.top - b.top + }) + + return sorted[0]?.id +} + +export const pickOffsetId = (list: Offset[], cutoff: number) => { + if (list.length === 0) return + + let lo = 0 + let hi = list.length - 1 + let out = 0 + + while (lo <= hi) { + const mid = (lo + hi) >> 1 + const top = list[mid]?.top + if (top === undefined) break + + if (top <= cutoff) { + out = mid + lo = mid + 1 + continue + } + + hi = mid - 1 + } + + return list[out]?.id +} + +export const createScrollSpy = (input: Input) => { + const raf = input.raf ?? requestAnimationFrame + const caf = input.caf ?? cancelAnimationFrame + const CtorIO = input.IntersectionObserver ?? globalThis.IntersectionObserver + const CtorRO = input.ResizeObserver ?? globalThis.ResizeObserver + const CtorMO = input.MutationObserver ?? globalThis.MutationObserver + + let root: HTMLDivElement | undefined + let io: IntersectionObserver | undefined + let ro: ResizeObserver | undefined + let mo: MutationObserver | undefined + let frame: number | undefined + let active: string | undefined + let dirty = true + + const node = new Map() + const id = new WeakMap() + const visible = new Map() + let offset: Offset[] = [] + + const schedule = () => { + if (frame !== undefined) return + frame = raf(() => { + frame = undefined + update() + }) + } + + const refreshOffset = () => { + const el = root + if (!el) { + offset = [] + dirty = false + return + } + + const base = el.getBoundingClientRect().top + offset = [...node].map(([next, item]) => ({ + id: next, + top: item.getBoundingClientRect().top - base + el.scrollTop, + })) + offset.sort((a, b) => a.top - b.top) + dirty = false + } + + const update = () => { + const el = root + if (!el) return + + const line = el.getBoundingClientRect().top + 100 + const next = + pickVisibleId( + [...visible].map(([k, v]) => ({ + id: k, + ratio: v.ratio, + top: v.top, + })), + line, + ) ?? + (() => { + if (dirty) refreshOffset() + return pickOffsetId(offset, el.scrollTop + 100) + })() + + if (!next || next === active) return + active = next + input.onActive(next) + } + + const observe = () => { + const el = root + if (!el) return + + io?.disconnect() + io = undefined + if (CtorIO) { + try { + io = new CtorIO( + (entries) => { + for (const entry of entries) { + const item = entry.target + if (!(item instanceof HTMLElement)) continue + const key = id.get(item) + if (!key) continue + + if (!entry.isIntersecting || entry.intersectionRatio <= 0) { + visible.delete(key) + continue + } + + visible.set(key, { + ratio: entry.intersectionRatio, + top: entry.boundingClientRect.top, + }) + } + + schedule() + }, + { + root: el, + threshold: [0, 0.25, 0.5, 0.75, 1], + }, + ) + } catch { + io = undefined + } + } + + if (io) { + for (const item of node.values()) io.observe(item) + } + + ro?.disconnect() + ro = undefined + if (CtorRO) { + ro = new CtorRO(() => { + dirty = true + schedule() + }) + ro.observe(el) + for (const item of node.values()) ro.observe(item) + } + + mo?.disconnect() + mo = undefined + if (CtorMO) { + mo = new CtorMO(() => { + dirty = true + schedule() + }) + mo.observe(el, { subtree: true, childList: true, characterData: true }) + } + + dirty = true + schedule() + } + + const setContainer = (el?: HTMLDivElement) => { + if (root === el) return + + root = el + visible.clear() + active = undefined + observe() + } + + const register = (el: HTMLElement, key: string) => { + const prev = node.get(key) + if (prev && prev !== el) { + io?.unobserve(prev) + ro?.unobserve(prev) + } + + node.set(key, el) + id.set(el, key) + if (io) io.observe(el) + if (ro) ro.observe(el) + dirty = true + schedule() + } + + const unregister = (key: string) => { + const item = node.get(key) + if (!item) return + + io?.unobserve(item) + ro?.unobserve(item) + node.delete(key) + visible.delete(key) + dirty = true + } + + const markDirty = () => { + dirty = true + schedule() + } + + const clear = () => { + for (const item of node.values()) { + io?.unobserve(item) + ro?.unobserve(item) + } + + node.clear() + visible.clear() + offset = [] + active = undefined + dirty = true + } + + const destroy = () => { + if (frame !== undefined) caf(frame) + frame = undefined + clear() + io?.disconnect() + ro?.disconnect() + mo?.disconnect() + io = undefined + ro = undefined + mo = undefined + root = undefined + } + + return { + setContainer, + register, + unregister, + onScroll: schedule, + markDirty, + clear, + destroy, + getActiveId: () => active, + } +} diff --git a/packages/app/src/pages/session/session-command-helpers.ts b/packages/app/src/pages/session/session-command-helpers.ts new file mode 100644 index 0000000000..b71a7b7688 --- /dev/null +++ b/packages/app/src/pages/session/session-command-helpers.ts @@ -0,0 +1,10 @@ +export const canAddSelectionContext = (input: { + active?: string + pathFromTab: (tab: string) => string | undefined + selectedLines: (path: string) => unknown +}) => { + if (!input.active) return false + const path = input.pathFromTab(input.active) + if (!path) return false + return input.selectedLines(path) != null +} diff --git a/packages/app/src/pages/session/session-mobile-tabs.tsx b/packages/app/src/pages/session/session-mobile-tabs.tsx new file mode 100644 index 0000000000..41f0582316 --- /dev/null +++ b/packages/app/src/pages/session/session-mobile-tabs.tsx @@ -0,0 +1,36 @@ +import { Match, Show, Switch } from "solid-js" +import { Tabs } from "@opencode-ai/ui/tabs" + +export function SessionMobileTabs(props: { + open: boolean + hasReview: boolean + reviewCount: number + onSession: () => void + onChanges: () => void + t: (key: string, vars?: Record) => string +}) { + return ( + + + + + {props.t("session.tab.session")} + + + + + {props.t("session.review.filesChanged", { count: props.reviewCount })} + + {props.t("session.review.change.other")} + + + + + + ) +} diff --git a/packages/app/src/pages/session/session-prompt-dock.test.ts b/packages/app/src/pages/session/session-prompt-dock.test.ts new file mode 100644 index 0000000000..b3a9945d66 --- /dev/null +++ b/packages/app/src/pages/session/session-prompt-dock.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { questionSubtitle } from "./session-prompt-helpers" + +describe("questionSubtitle", () => { + const t = (key: string) => { + if (key === "ui.common.question.one") return "question" + if (key === "ui.common.question.other") return "questions" + return key + } + + test("returns empty for zero", () => { + expect(questionSubtitle(0, t)).toBe("") + }) + + test("uses singular label", () => { + expect(questionSubtitle(1, t)).toBe("1 question") + }) + + test("uses plural label", () => { + expect(questionSubtitle(3, t)).toBe("3 questions") + }) +}) diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx new file mode 100644 index 0000000000..6979570272 --- /dev/null +++ b/packages/app/src/pages/session/session-prompt-dock.tsx @@ -0,0 +1,137 @@ +import { For, Show, type ComponentProps } from "solid-js" +import { Button } from "@opencode-ai/ui/button" +import { BasicTool } from "@opencode-ai/ui/basic-tool" +import { PromptInput } from "@/components/prompt-input" +import { QuestionDock } from "@/components/question-dock" +import { questionSubtitle } from "@/pages/session/session-prompt-helpers" + +const questionDockRequest = (value: unknown) => value as ComponentProps["request"] + +export function SessionPromptDock(props: { + centered: boolean + questionRequest: () => { questions: unknown[] } | undefined + permissionRequest: () => { patterns: string[]; permission: string } | undefined + blocked: boolean + promptReady: boolean + handoffPrompt?: string + t: (key: string, vars?: Record) => string + responding: boolean + onDecide: (response: "once" | "always" | "reject") => void + inputRef: (el: HTMLDivElement) => void + newSessionWorktree: string + onNewSessionWorktreeReset: () => void + onSubmit: () => void + setPromptDockRef: (el: HTMLDivElement) => void +}) { + return ( +
+
+ + {(req) => { + const subtitle = questionSubtitle(req.questions.length, (key) => props.t(key)) + return ( +
+ + +
+ ) + }} +
+ + + {(perm) => ( +
+ + 0}> +
+ + {(pattern) => {pattern}} + +
+
+ +
+ {props.t("settings.permissions.tool.doom_loop.description")} +
+
+
+
+
+ + + +
+
+
+ )} +
+ + + + {props.handoffPrompt || props.t("prompt.loading")} +
+ } + > + + + +
+
+ ) +} diff --git a/packages/app/src/pages/session/session-prompt-helpers.ts b/packages/app/src/pages/session/session-prompt-helpers.ts new file mode 100644 index 0000000000..ac3234c939 --- /dev/null +++ b/packages/app/src/pages/session/session-prompt-helpers.ts @@ -0,0 +1,4 @@ +export const questionSubtitle = (count: number, t: (key: string) => string) => { + if (count === 0) return "" + return `${count} ${t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}` +} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx new file mode 100644 index 0000000000..573680dec6 --- /dev/null +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -0,0 +1,306 @@ +import { For, Match, Show, Switch, createMemo, onCleanup, type JSX, type ValidComponent } from "solid-js" +import { Tabs } from "@opencode-ai/ui/tabs" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { ResizeHandle } from "@opencode-ai/ui/resize-handle" +import { Mark } from "@opencode-ai/ui/logo" +import FileTree from "@/components/file-tree" +import { SessionContextUsage } from "@/components/session-context-usage" +import { SessionContextTab, SortableTab, FileVisual } from "@/components/session" +import { DialogSelectFile } from "@/components/dialog-select-file" +import { createFileTabListSync } from "@/pages/session/file-tab-scroll" +import { FileTabContent } from "@/pages/session/file-tabs" +import { StickyAddButton } from "@/pages/session/review-tab" +import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" +import { ConstrainDragYAxis } from "@/utils/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" +import { useComments } from "@/context/comments" +import { useCommand } from "@/context/command" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useFile, type SelectedLineRange } from "@/context/file" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { useSync } from "@/context/sync" + +export function SessionSidePanel(props: { + open: boolean + language: ReturnType + layout: ReturnType + command: ReturnType + dialog: ReturnType + file: ReturnType + comments: ReturnType + sync: ReturnType + hasReview: boolean + reviewCount: number + reviewTab: boolean + contextOpen: () => boolean + openedTabs: () => string[] + activeTab: () => string + activeFileTab: () => string | undefined + tabs: () => ReturnType["tabs"]> + openTab: (value: string) => void + showAllFiles: () => void + reviewPanel: () => JSX.Element + messages: () => unknown[] + visibleUserMessages: () => unknown[] + view: () => ReturnType["view"]> + info: () => unknown + handoffFiles: () => Record | undefined + codeComponent: NonNullable + addCommentToContext: (input: { + file: string + selection: SelectedLineRange + comment: string + preview?: string + origin?: "review" | "file" + }) => void + activeDraggable: () => string | undefined + onDragStart: (event: unknown) => void + onDragEnd: () => void + onDragOver: (event: DragEvent) => void + fileTreeTab: () => "changes" | "all" + setFileTreeTabValue: (value: string) => void + diffsReady: boolean + diffFiles: string[] + kinds: Map + activeDiff?: string + focusReviewDiff: (path: string) => void +}) { + return ( + +
+ + + + ) +} diff --git a/packages/app/src/pages/session/terminal-label.ts b/packages/app/src/pages/session/terminal-label.ts new file mode 100644 index 0000000000..6d336769b1 --- /dev/null +++ b/packages/app/src/pages/session/terminal-label.ts @@ -0,0 +1,16 @@ +export const terminalTabLabel = (input: { + title?: string + titleNumber?: number + t: (key: string, vars?: Record) => string +}) => { + const title = input.title ?? "" + const number = input.titleNumber ?? 0 + const match = title.match(/^Terminal (\d+)$/) + const parsed = match ? Number(match[1]) : undefined + const isDefaultTitle = Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number + + if (title && !isDefaultTitle) return title + if (number > 0) return input.t("terminal.title.numbered", { number }) + if (title) return title + return input.t("terminal.title") +} diff --git a/packages/app/src/pages/session/terminal-panel.test.ts b/packages/app/src/pages/session/terminal-panel.test.ts new file mode 100644 index 0000000000..43eeec32f2 --- /dev/null +++ b/packages/app/src/pages/session/terminal-panel.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import { terminalTabLabel } from "./terminal-label" + +const t = (key: string, vars?: Record) => { + if (key === "terminal.title.numbered") return `Terminal ${vars?.number}` + if (key === "terminal.title") return "Terminal" + return key +} + +describe("terminalTabLabel", () => { + test("returns custom title unchanged", () => { + const label = terminalTabLabel({ title: "server", titleNumber: 3, t }) + expect(label).toBe("server") + }) + + test("normalizes default numbered title", () => { + const label = terminalTabLabel({ title: "Terminal 2", titleNumber: 2, t }) + expect(label).toBe("Terminal 2") + }) + + test("falls back to generic title", () => { + const label = terminalTabLabel({ title: "", titleNumber: 0, t }) + expect(label).toBe("Terminal") + }) +}) diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx new file mode 100644 index 0000000000..09095d689c --- /dev/null +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -0,0 +1,169 @@ +import { createMemo, For, Show } from "solid-js" +import { Tabs } from "@opencode-ai/ui/tabs" +import { ResizeHandle } from "@opencode-ai/ui/resize-handle" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" +import { ConstrainDragYAxis } from "@/utils/solid-dnd" +import { SortableTerminalTab } from "@/components/session" +import { Terminal } from "@/components/terminal" +import { useTerminal, type LocalPTY } from "@/context/terminal" +import { useLanguage } from "@/context/language" +import { useCommand } from "@/context/command" +import { terminalTabLabel } from "@/pages/session/terminal-label" + +export function TerminalPanel(props: { + open: boolean + height: number + resize: (value: number) => void + close: () => void + terminal: ReturnType + language: ReturnType + command: ReturnType + handoff: () => string[] + activeTerminalDraggable: () => string | undefined + handleTerminalDragStart: (event: unknown) => void + handleTerminalDragOver: (event: DragEvent) => void + handleTerminalDragEnd: () => void + onCloseTab: () => void +}) { + return ( + +
+ + +
+ + {(title) => ( +
+ {title} +
+ )} +
+
+
+ {props.language.t("common.loading")} + {props.language.t("common.loading.ellipsis")} +
+
+
+ {props.language.t("terminal.loading")} +
+
+ } + > + + + +
+ props.terminal.open(id)} + class="!h-auto !flex-none" + > + + t.id)}> + + {(pty) => ( + { + props.close() + props.onCloseTab() + }} + /> + )} + + +
+ + + +
+
+
+
+ + {(pty) => ( +
+ + props.terminal.clone(pty.id)} + /> + +
+ )} +
+
+
+ + + {(draggedId) => { + const pty = createMemo(() => props.terminal.all().find((t: LocalPTY) => t.id === draggedId())) + return ( + + {(t) => ( +
+ {terminalTabLabel({ + title: t().title, + titleNumber: t().titleNumber, + t: props.language.t as ( + key: string, + vars?: Record, + ) => string, + })} +
+ )} +
+ ) + }} +
+
+
+
+
+
+ ) +} diff --git a/packages/app/src/pages/session/use-session-commands.test.ts b/packages/app/src/pages/session/use-session-commands.test.ts new file mode 100644 index 0000000000..ada1871e1c --- /dev/null +++ b/packages/app/src/pages/session/use-session-commands.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" +import { canAddSelectionContext } from "./session-command-helpers" + +describe("canAddSelectionContext", () => { + test("returns false without active tab", () => { + expect( + canAddSelectionContext({ + active: undefined, + pathFromTab: () => "src/a.ts", + selectedLines: () => ({ start: 1, end: 1 }), + }), + ).toBe(false) + }) + + test("returns false when active tab is not a file", () => { + expect( + canAddSelectionContext({ + active: "context", + pathFromTab: () => undefined, + selectedLines: () => ({ start: 1, end: 1 }), + }), + ).toBe(false) + }) + + test("returns false without selected lines", () => { + expect( + canAddSelectionContext({ + active: "file://src/a.ts", + pathFromTab: () => "src/a.ts", + selectedLines: () => null, + }), + ).toBe(false) + }) + + test("returns true when file and selection exist", () => { + expect( + canAddSelectionContext({ + active: "file://src/a.ts", + pathFromTab: () => "src/a.ts", + selectedLines: () => ({ start: 1, end: 2 }), + }), + ).toBe(true) + }) +}) diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx new file mode 100644 index 0000000000..ae845a657f --- /dev/null +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -0,0 +1,439 @@ +import { createMemo } from "solid-js" +import { useNavigate, useParams } from "@solidjs/router" +import { useCommand } from "@/context/command" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useFile, selectionFromLines, type FileSelection } from "@/context/file" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { useLocal } from "@/context/local" +import { usePermission } from "@/context/permission" +import { usePrompt } from "@/context/prompt" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { useTerminal } from "@/context/terminal" +import { DialogSelectFile } from "@/components/dialog-select-file" +import { DialogSelectModel } from "@/components/dialog-select-model" +import { DialogSelectMcp } from "@/components/dialog-select-mcp" +import { DialogFork } from "@/components/dialog-fork" +import { showToast } from "@opencode-ai/ui/toast" +import { findLast } from "@opencode-ai/util/array" +import { extractPromptFromParts } from "@/utils/prompt" +import { UserMessage } from "@opencode-ai/sdk/v2" +import { combineCommandSections } from "@/pages/session/helpers" +import { canAddSelectionContext } from "@/pages/session/session-command-helpers" + +export const useSessionCommands = (input: { + command: ReturnType + dialog: ReturnType + file: ReturnType + language: ReturnType + local: ReturnType + permission: ReturnType + prompt: ReturnType + sdk: ReturnType + sync: ReturnType + terminal: ReturnType + layout: ReturnType + params: ReturnType + navigate: ReturnType + tabs: () => ReturnType["tabs"]> + view: () => ReturnType["view"]> + info: () => { revert?: { messageID?: string }; share?: { url?: string } } | undefined + status: () => { type: string } + userMessages: () => UserMessage[] + visibleUserMessages: () => UserMessage[] + activeMessage: () => UserMessage | undefined + showAllFiles: () => void + navigateMessageByOffset: (offset: number) => void + setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void + setActiveMessage: (message: UserMessage | undefined) => void + addSelectionToContext: (path: string, selection: FileSelection) => void +}) => { + const sessionCommands = createMemo(() => [ + { + id: "session.new", + title: input.language.t("command.session.new"), + category: input.language.t("command.category.session"), + keybind: "mod+shift+s", + slash: "new", + onSelect: () => input.navigate(`/${input.params.dir}/session`), + }, + ]) + + const fileCommands = createMemo(() => [ + { + id: "file.open", + title: input.language.t("command.file.open"), + description: input.language.t("palette.search.placeholder"), + category: input.language.t("command.category.file"), + keybind: "mod+p", + slash: "open", + onSelect: () => input.dialog.show(() => ), + }, + { + id: "tab.close", + title: input.language.t("command.tab.close"), + category: input.language.t("command.category.file"), + keybind: "mod+w", + disabled: !input.tabs().active(), + onSelect: () => { + const active = input.tabs().active() + if (!active) return + input.tabs().close(active) + }, + }, + ]) + + const contextCommands = createMemo(() => [ + { + id: "context.addSelection", + title: input.language.t("command.context.addSelection"), + description: input.language.t("command.context.addSelection.description"), + category: input.language.t("command.category.context"), + keybind: "mod+shift+l", + disabled: !canAddSelectionContext({ + active: input.tabs().active(), + pathFromTab: input.file.pathFromTab, + selectedLines: input.file.selectedLines, + }), + onSelect: () => { + const active = input.tabs().active() + if (!active) return + const path = input.file.pathFromTab(active) + if (!path) return + + const range = input.file.selectedLines(path) + if (!range) { + showToast({ + title: input.language.t("toast.context.noLineSelection.title"), + description: input.language.t("toast.context.noLineSelection.description"), + }) + return + } + + input.addSelectionToContext(path, selectionFromLines(range)) + }, + }, + ]) + + const viewCommands = createMemo(() => [ + { + id: "terminal.toggle", + title: input.language.t("command.terminal.toggle"), + description: "", + category: input.language.t("command.category.view"), + keybind: "ctrl+`", + slash: "terminal", + onSelect: () => input.view().terminal.toggle(), + }, + { + id: "review.toggle", + title: input.language.t("command.review.toggle"), + description: "", + category: input.language.t("command.category.view"), + keybind: "mod+shift+r", + onSelect: () => input.view().reviewPanel.toggle(), + }, + { + id: "fileTree.toggle", + title: input.language.t("command.fileTree.toggle"), + description: "", + category: input.language.t("command.category.view"), + onSelect: () => { + const opening = !input.layout.fileTree.opened() + if (opening && !input.view().reviewPanel.opened()) input.view().reviewPanel.open() + input.layout.fileTree.toggle() + }, + }, + { + id: "terminal.new", + title: input.language.t("command.terminal.new"), + description: input.language.t("command.terminal.new.description"), + category: input.language.t("command.category.terminal"), + keybind: "ctrl+alt+t", + onSelect: () => { + if (input.terminal.all().length > 0) input.terminal.new() + input.view().terminal.open() + }, + }, + { + id: "steps.toggle", + title: input.language.t("command.steps.toggle"), + description: input.language.t("command.steps.toggle.description"), + category: input.language.t("command.category.view"), + keybind: "mod+e", + slash: "steps", + disabled: !input.params.id, + onSelect: () => { + const msg = input.activeMessage() + if (!msg) return + input.setExpanded(msg.id, (open: boolean | undefined) => !open) + }, + }, + ]) + + const messageCommands = createMemo(() => [ + { + id: "message.previous", + title: input.language.t("command.message.previous"), + description: input.language.t("command.message.previous.description"), + category: input.language.t("command.category.session"), + keybind: "mod+arrowup", + disabled: !input.params.id, + onSelect: () => input.navigateMessageByOffset(-1), + }, + { + id: "message.next", + title: input.language.t("command.message.next"), + description: input.language.t("command.message.next.description"), + category: input.language.t("command.category.session"), + keybind: "mod+arrowdown", + disabled: !input.params.id, + onSelect: () => input.navigateMessageByOffset(1), + }, + ]) + + const agentCommands = createMemo(() => [ + { + id: "model.choose", + title: input.language.t("command.model.choose"), + description: input.language.t("command.model.choose.description"), + category: input.language.t("command.category.model"), + keybind: "mod+'", + slash: "model", + onSelect: () => input.dialog.show(() => ), + }, + { + id: "mcp.toggle", + title: input.language.t("command.mcp.toggle"), + description: input.language.t("command.mcp.toggle.description"), + category: input.language.t("command.category.mcp"), + keybind: "mod+;", + slash: "mcp", + onSelect: () => input.dialog.show(() => ), + }, + { + id: "agent.cycle", + title: input.language.t("command.agent.cycle"), + description: input.language.t("command.agent.cycle.description"), + category: input.language.t("command.category.agent"), + keybind: "mod+.", + slash: "agent", + onSelect: () => input.local.agent.move(1), + }, + { + id: "agent.cycle.reverse", + title: input.language.t("command.agent.cycle.reverse"), + description: input.language.t("command.agent.cycle.reverse.description"), + category: input.language.t("command.category.agent"), + keybind: "shift+mod+.", + onSelect: () => input.local.agent.move(-1), + }, + { + id: "model.variant.cycle", + title: input.language.t("command.model.variant.cycle"), + description: input.language.t("command.model.variant.cycle.description"), + category: input.language.t("command.category.model"), + keybind: "shift+mod+d", + onSelect: () => { + input.local.model.variant.cycle() + }, + }, + ]) + + const permissionCommands = createMemo(() => [ + { + id: "permissions.autoaccept", + title: + input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory) + ? input.language.t("command.permissions.autoaccept.disable") + : input.language.t("command.permissions.autoaccept.enable"), + category: input.language.t("command.category.permissions"), + keybind: "mod+shift+a", + disabled: !input.params.id || !input.permission.permissionsEnabled(), + onSelect: () => { + const sessionID = input.params.id + if (!sessionID) return + input.permission.toggleAutoAccept(sessionID, input.sdk.directory) + showToast({ + title: input.permission.isAutoAccepting(sessionID, input.sdk.directory) + ? input.language.t("toast.permissions.autoaccept.on.title") + : input.language.t("toast.permissions.autoaccept.off.title"), + description: input.permission.isAutoAccepting(sessionID, input.sdk.directory) + ? input.language.t("toast.permissions.autoaccept.on.description") + : input.language.t("toast.permissions.autoaccept.off.description"), + }) + }, + }, + ]) + + const sessionActionCommands = createMemo(() => [ + { + id: "session.undo", + title: input.language.t("command.session.undo"), + description: input.language.t("command.session.undo.description"), + category: input.language.t("command.category.session"), + slash: "undo", + disabled: !input.params.id || input.visibleUserMessages().length === 0, + onSelect: async () => { + const sessionID = input.params.id + if (!sessionID) return + if (input.status()?.type !== "idle") { + await input.sdk.client.session.abort({ sessionID }).catch(() => {}) + } + const revert = input.info()?.revert?.messageID + const message = findLast(input.userMessages(), (x) => !revert || x.id < revert) + if (!message) return + await input.sdk.client.session.revert({ sessionID, messageID: message.id }) + const parts = input.sync.data.part[message.id] + if (parts) { + const restored = extractPromptFromParts(parts, { directory: input.sdk.directory }) + input.prompt.set(restored) + } + const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id) + input.setActiveMessage(priorMessage) + }, + }, + { + id: "session.redo", + title: input.language.t("command.session.redo"), + description: input.language.t("command.session.redo.description"), + category: input.language.t("command.category.session"), + slash: "redo", + disabled: !input.params.id || !input.info()?.revert?.messageID, + onSelect: async () => { + const sessionID = input.params.id + if (!sessionID) return + const revertMessageID = input.info()?.revert?.messageID + if (!revertMessageID) return + const nextMessage = input.userMessages().find((x) => x.id > revertMessageID) + if (!nextMessage) { + await input.sdk.client.session.unrevert({ sessionID }) + input.prompt.reset() + const lastMsg = findLast(input.userMessages(), (x) => x.id >= revertMessageID) + input.setActiveMessage(lastMsg) + return + } + await input.sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) + const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id) + input.setActiveMessage(priorMsg) + }, + }, + { + id: "session.compact", + title: input.language.t("command.session.compact"), + description: input.language.t("command.session.compact.description"), + category: input.language.t("command.category.session"), + slash: "compact", + disabled: !input.params.id || input.visibleUserMessages().length === 0, + onSelect: async () => { + const sessionID = input.params.id + if (!sessionID) return + const model = input.local.model.current() + if (!model) { + showToast({ + title: input.language.t("toast.model.none.title"), + description: input.language.t("toast.model.none.description"), + }) + return + } + await input.sdk.client.session.summarize({ + sessionID, + modelID: model.id, + providerID: model.provider.id, + }) + }, + }, + { + id: "session.fork", + title: input.language.t("command.session.fork"), + description: input.language.t("command.session.fork.description"), + category: input.language.t("command.category.session"), + slash: "fork", + disabled: !input.params.id || input.visibleUserMessages().length === 0, + onSelect: () => input.dialog.show(() => ), + }, + ]) + + const shareCommands = createMemo(() => { + if (input.sync.data.config.share === "disabled") return [] + return [ + { + id: "session.share", + title: input.language.t("command.session.share"), + description: input.language.t("command.session.share.description"), + category: input.language.t("command.category.session"), + slash: "share", + disabled: !input.params.id || !!input.info()?.share?.url, + onSelect: async () => { + if (!input.params.id) return + await input.sdk.client.session + .share({ sessionID: input.params.id }) + .then((res) => { + navigator.clipboard.writeText(res.data!.share!.url).catch(() => + showToast({ + title: input.language.t("toast.session.share.copyFailed.title"), + variant: "error", + }), + ) + }) + .then(() => + showToast({ + title: input.language.t("toast.session.share.success.title"), + description: input.language.t("toast.session.share.success.description"), + variant: "success", + }), + ) + .catch(() => + showToast({ + title: input.language.t("toast.session.share.failed.title"), + description: input.language.t("toast.session.share.failed.description"), + variant: "error", + }), + ) + }, + }, + { + id: "session.unshare", + title: input.language.t("command.session.unshare"), + description: input.language.t("command.session.unshare.description"), + category: input.language.t("command.category.session"), + slash: "unshare", + disabled: !input.params.id || !input.info()?.share?.url, + onSelect: async () => { + if (!input.params.id) return + await input.sdk.client.session + .unshare({ sessionID: input.params.id }) + .then(() => + showToast({ + title: input.language.t("toast.session.unshare.success.title"), + description: input.language.t("toast.session.unshare.success.description"), + variant: "success", + }), + ) + .catch(() => + showToast({ + title: input.language.t("toast.session.unshare.failed.title"), + description: input.language.t("toast.session.unshare.failed.description"), + variant: "error", + }), + ) + }, + }, + ] + }) + + input.command.register("session", () => + combineCommandSections([ + sessionCommands(), + fileCommands(), + contextCommands(), + viewCommands(), + messageCommands(), + agentCommands(), + permissionCommands(), + sessionActionCommands(), + shareCommands(), + ]), + ) +} diff --git a/packages/app/src/pages/session/use-session-hash-scroll.test.ts b/packages/app/src/pages/session/use-session-hash-scroll.test.ts new file mode 100644 index 0000000000..844f5451e3 --- /dev/null +++ b/packages/app/src/pages/session/use-session-hash-scroll.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import { messageIdFromHash } from "./use-session-hash-scroll" + +describe("messageIdFromHash", () => { + test("parses hash with leading #", () => { + expect(messageIdFromHash("#message-abc123")).toBe("abc123") + }) + + test("parses raw hash fragment", () => { + expect(messageIdFromHash("message-42")).toBe("42") + }) + + test("ignores non-message anchors", () => { + expect(messageIdFromHash("#review-panel")).toBeUndefined() + }) +}) diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts new file mode 100644 index 0000000000..8952bbd98b --- /dev/null +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -0,0 +1,174 @@ +import { createEffect, on, onCleanup } from "solid-js" +import { UserMessage } from "@opencode-ai/sdk/v2" + +export const messageIdFromHash = (hash: string) => { + const value = hash.startsWith("#") ? hash.slice(1) : hash + const match = value.match(/^message-(.+)$/) + if (!match) return + return match[1] +} + +export const useSessionHashScroll = (input: { + sessionKey: () => string + sessionID: () => string | undefined + messagesReady: () => boolean + visibleUserMessages: () => UserMessage[] + turnStart: () => number + currentMessageId: () => string | undefined + pendingMessage: () => string | undefined + setPendingMessage: (value: string | undefined) => void + setActiveMessage: (message: UserMessage | undefined) => void + setTurnStart: (value: number) => void + scheduleTurnBackfill: () => void + autoScroll: { pause: () => void; forceScrollToBottom: () => void } + scroller: () => HTMLDivElement | undefined + anchor: (id: string) => string + scheduleScrollState: (el: HTMLDivElement) => void + consumePendingMessage: (key: string) => string | undefined +}) => { + const clearMessageHash = () => { + if (!window.location.hash) return + window.history.replaceState(null, "", window.location.href.replace(/#.*$/, "")) + } + + const updateHash = (id: string) => { + window.history.replaceState(null, "", `#${input.anchor(id)}`) + } + + const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { + const root = input.scroller() + if (!root) return false + + const a = el.getBoundingClientRect() + const b = root.getBoundingClientRect() + const top = a.top - b.top + root.scrollTop + root.scrollTo({ top, behavior }) + return true + } + + const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { + input.setActiveMessage(message) + + const msgs = input.visibleUserMessages() + const index = msgs.findIndex((m) => m.id === message.id) + if (index !== -1 && index < input.turnStart()) { + input.setTurnStart(index) + input.scheduleTurnBackfill() + + requestAnimationFrame(() => { + const el = document.getElementById(input.anchor(message.id)) + if (!el) { + requestAnimationFrame(() => { + const next = document.getElementById(input.anchor(message.id)) + if (!next) return + scrollToElement(next, behavior) + }) + return + } + scrollToElement(el, behavior) + }) + + updateHash(message.id) + return + } + + const el = document.getElementById(input.anchor(message.id)) + if (!el) { + updateHash(message.id) + requestAnimationFrame(() => { + const next = document.getElementById(input.anchor(message.id)) + if (!next) return + if (!scrollToElement(next, behavior)) return + }) + return + } + if (scrollToElement(el, behavior)) { + updateHash(message.id) + return + } + + requestAnimationFrame(() => { + const next = document.getElementById(input.anchor(message.id)) + if (!next) return + if (!scrollToElement(next, behavior)) return + }) + updateHash(message.id) + } + + const applyHash = (behavior: ScrollBehavior) => { + const hash = window.location.hash.slice(1) + if (!hash) { + input.autoScroll.forceScrollToBottom() + const el = input.scroller() + if (el) input.scheduleScrollState(el) + return + } + + const messageId = messageIdFromHash(hash) + if (messageId) { + input.autoScroll.pause() + const msg = input.visibleUserMessages().find((m) => m.id === messageId) + if (msg) { + scrollToMessage(msg, behavior) + return + } + return + } + + const target = document.getElementById(hash) + if (target) { + input.autoScroll.pause() + scrollToElement(target, behavior) + return + } + + input.autoScroll.forceScrollToBottom() + const el = input.scroller() + if (el) input.scheduleScrollState(el) + } + + createEffect( + on(input.sessionKey, (key) => { + if (!input.sessionID()) return + const messageID = input.consumePendingMessage(key) + if (!messageID) return + input.setPendingMessage(messageID) + }), + ) + + createEffect(() => { + if (!input.sessionID() || !input.messagesReady()) return + requestAnimationFrame(() => applyHash("auto")) + }) + + createEffect(() => { + if (!input.sessionID() || !input.messagesReady()) return + + input.visibleUserMessages().length + input.turnStart() + + const targetId = input.pendingMessage() ?? messageIdFromHash(window.location.hash) + if (!targetId) return + if (input.currentMessageId() === targetId) return + + const msg = input.visibleUserMessages().find((m) => m.id === targetId) + if (!msg) return + + if (input.pendingMessage() === targetId) input.setPendingMessage(undefined) + input.autoScroll.pause() + requestAnimationFrame(() => scrollToMessage(msg, "auto")) + }) + + createEffect(() => { + if (!input.sessionID() || !input.messagesReady()) return + const handler = () => requestAnimationFrame(() => applyHash("auto")) + window.addEventListener("hashchange", handler) + onCleanup(() => window.removeEventListener("hashchange", handler)) + }) + + return { + clearMessageHash, + scrollToMessage, + applyHash, + } +} diff --git a/packages/app/src/utils/runtime-adapters.test.ts b/packages/app/src/utils/runtime-adapters.test.ts new file mode 100644 index 0000000000..9f408b8eb7 --- /dev/null +++ b/packages/app/src/utils/runtime-adapters.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test" +import { + disposeIfDisposable, + getHoveredLinkText, + getSpeechRecognitionCtor, + hasSetOption, + isDisposable, + setOptionIfSupported, +} from "./runtime-adapters" + +describe("runtime adapters", () => { + test("detects and disposes disposable values", () => { + let count = 0 + const value = { + dispose: () => { + count += 1 + }, + } + expect(isDisposable(value)).toBe(true) + disposeIfDisposable(value) + expect(count).toBe(1) + }) + + test("ignores non-disposable values", () => { + expect(isDisposable({ dispose: "nope" })).toBe(false) + expect(() => disposeIfDisposable({ dispose: "nope" })).not.toThrow() + }) + + test("sets options only when setter exists", () => { + const calls: Array<[string, unknown]> = [] + const value = { + setOption: (key: string, next: unknown) => { + calls.push([key, next]) + }, + } + expect(hasSetOption(value)).toBe(true) + setOptionIfSupported(value, "fontFamily", "Berkeley Mono") + expect(calls).toEqual([["fontFamily", "Berkeley Mono"]]) + expect(() => setOptionIfSupported({}, "fontFamily", "Berkeley Mono")).not.toThrow() + }) + + test("reads hovered link text safely", () => { + expect(getHoveredLinkText({ currentHoveredLink: { text: "https://example.com" } })).toBe("https://example.com") + expect(getHoveredLinkText({ currentHoveredLink: { text: 1 } })).toBeUndefined() + expect(getHoveredLinkText(null)).toBeUndefined() + }) + + test("resolves speech recognition constructor with webkit precedence", () => { + class SpeechCtor {} + class WebkitCtor {} + const ctor = getSpeechRecognitionCtor({ + SpeechRecognition: SpeechCtor, + webkitSpeechRecognition: WebkitCtor, + }) + expect(ctor).toBe(WebkitCtor) + }) + + test("returns undefined when no valid speech constructor exists", () => { + expect(getSpeechRecognitionCtor({ SpeechRecognition: "nope" })).toBeUndefined() + expect(getSpeechRecognitionCtor(undefined)).toBeUndefined() + }) +}) diff --git a/packages/app/src/utils/runtime-adapters.ts b/packages/app/src/utils/runtime-adapters.ts new file mode 100644 index 0000000000..4c74da5dc1 --- /dev/null +++ b/packages/app/src/utils/runtime-adapters.ts @@ -0,0 +1,39 @@ +type RecordValue = Record + +const isRecord = (value: unknown): value is RecordValue => { + return typeof value === "object" && value !== null +} + +export const isDisposable = (value: unknown): value is { dispose: () => void } => { + return isRecord(value) && typeof value.dispose === "function" +} + +export const disposeIfDisposable = (value: unknown) => { + if (!isDisposable(value)) return + value.dispose() +} + +export const hasSetOption = (value: unknown): value is { setOption: (key: string, next: unknown) => void } => { + return isRecord(value) && typeof value.setOption === "function" +} + +export const setOptionIfSupported = (value: unknown, key: string, next: unknown) => { + if (!hasSetOption(value)) return + value.setOption(key, next) +} + +export const getHoveredLinkText = (value: unknown) => { + if (!isRecord(value)) return + const link = value.currentHoveredLink + if (!isRecord(link)) return + if (typeof link.text !== "string") return + return link.text +} + +export const getSpeechRecognitionCtor = (value: unknown): (new () => T) | undefined => { + if (!isRecord(value)) return + const ctor = + typeof value.webkitSpeechRecognition === "function" ? value.webkitSpeechRecognition : value.SpeechRecognition + if (typeof ctor !== "function") return + return ctor as new () => T +} diff --git a/packages/app/src/utils/scoped-cache.test.ts b/packages/app/src/utils/scoped-cache.test.ts new file mode 100644 index 0000000000..0c6189dafe --- /dev/null +++ b/packages/app/src/utils/scoped-cache.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import { createScopedCache } from "./scoped-cache" + +describe("createScopedCache", () => { + test("evicts least-recently-used entry when max is reached", () => { + const disposed: string[] = [] + const cache = createScopedCache((key) => ({ key }), { + maxEntries: 2, + dispose: (value) => disposed.push(value.key), + }) + + const a = cache.get("a") + const b = cache.get("b") + expect(a.key).toBe("a") + expect(b.key).toBe("b") + + cache.get("a") + const c = cache.get("c") + + expect(c.key).toBe("c") + expect(cache.peek("a")?.key).toBe("a") + expect(cache.peek("b")).toBeUndefined() + expect(cache.peek("c")?.key).toBe("c") + expect(disposed).toEqual(["b"]) + }) + + test("disposes entries on delete and clear", () => { + const disposed: string[] = [] + const cache = createScopedCache((key) => ({ key }), { + dispose: (value) => disposed.push(value.key), + }) + + cache.get("a") + cache.get("b") + + const removed = cache.delete("a") + expect(removed?.key).toBe("a") + expect(cache.peek("a")).toBeUndefined() + + cache.clear() + expect(cache.peek("b")).toBeUndefined() + expect(disposed).toEqual(["a", "b"]) + }) + + test("expires stale entries with ttl and recreates on get", () => { + let clock = 0 + let count = 0 + const disposed: string[] = [] + const cache = createScopedCache((key) => ({ key, count: ++count }), { + ttlMs: 10, + now: () => clock, + dispose: (value) => disposed.push(`${value.key}:${value.count}`), + }) + + const first = cache.get("a") + expect(first.count).toBe(1) + + clock = 9 + expect(cache.peek("a")?.count).toBe(1) + + clock = 11 + expect(cache.peek("a")).toBeUndefined() + expect(disposed).toEqual(["a:1"]) + + const second = cache.get("a") + expect(second.count).toBe(2) + expect(disposed).toEqual(["a:1"]) + }) +}) diff --git a/packages/app/src/utils/scoped-cache.ts b/packages/app/src/utils/scoped-cache.ts new file mode 100644 index 0000000000..224c363c1e --- /dev/null +++ b/packages/app/src/utils/scoped-cache.ts @@ -0,0 +1,104 @@ +type ScopedCacheOptions = { + maxEntries?: number + ttlMs?: number + dispose?: (value: T, key: string) => void + now?: () => number +} + +type Entry = { + value: T + touchedAt: number +} + +export function createScopedCache(createValue: (key: string) => T, options: ScopedCacheOptions = {}) { + const store = new Map>() + const now = options.now ?? Date.now + + const dispose = (key: string, entry: Entry) => { + options.dispose?.(entry.value, key) + } + + const expired = (entry: Entry) => { + if (options.ttlMs === undefined) return false + return now() - entry.touchedAt >= options.ttlMs + } + + const sweep = () => { + if (options.ttlMs === undefined) return + for (const [key, entry] of store) { + if (!expired(entry)) continue + store.delete(key) + dispose(key, entry) + } + } + + const touch = (key: string, entry: Entry) => { + entry.touchedAt = now() + store.delete(key) + store.set(key, entry) + } + + const prune = () => { + if (options.maxEntries === undefined) return + while (store.size > options.maxEntries) { + const key = store.keys().next().value + if (!key) return + const entry = store.get(key) + store.delete(key) + if (!entry) continue + dispose(key, entry) + } + } + + const remove = (key: string) => { + const entry = store.get(key) + if (!entry) return + store.delete(key) + dispose(key, entry) + return entry.value + } + + const peek = (key: string) => { + sweep() + const entry = store.get(key) + if (!entry) return + if (!expired(entry)) return entry.value + store.delete(key) + dispose(key, entry) + } + + const get = (key: string) => { + sweep() + const entry = store.get(key) + if (entry && !expired(entry)) { + touch(key, entry) + return entry.value + } + if (entry) { + store.delete(key) + dispose(key, entry) + } + + const created = { + value: createValue(key), + touchedAt: now(), + } + store.set(key, created) + prune() + return created.value + } + + const clear = () => { + for (const [key, entry] of store) { + dispose(key, entry) + } + store.clear() + } + + return { + get, + peek, + delete: remove, + clear, + } +} diff --git a/packages/app/src/utils/server-health.test.ts b/packages/app/src/utils/server-health.test.ts new file mode 100644 index 0000000000..34c86685ae --- /dev/null +++ b/packages/app/src/utils/server-health.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test" +import { checkServerHealth } from "./server-health" + +describe("checkServerHealth", () => { + test("returns healthy response with version", async () => { + const fetch = (async () => + new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof globalThis.fetch + + const result = await checkServerHealth("http://localhost:4096", fetch) + + expect(result).toEqual({ healthy: true, version: "1.2.3" }) + }) + + test("returns unhealthy when request fails", async () => { + const fetch = (async () => { + throw new Error("network") + }) as unknown as typeof globalThis.fetch + + const result = await checkServerHealth("http://localhost:4096", fetch) + + expect(result).toEqual({ healthy: false }) + }) + + test("uses provided abort signal", async () => { + let signal: AbortSignal | undefined + const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + signal = init?.signal ?? (input instanceof Request ? input.signal : undefined) + return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) as unknown as typeof globalThis.fetch + + const abort = new AbortController() + await checkServerHealth("http://localhost:4096", fetch, { signal: abort.signal }) + + expect(signal).toBe(abort.signal) + }) +}) diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts new file mode 100644 index 0000000000..ab33460b2b --- /dev/null +++ b/packages/app/src/utils/server-health.ts @@ -0,0 +1,29 @@ +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" + +export type ServerHealth = { healthy: boolean; version?: string } + +interface CheckServerHealthOptions { + timeoutMs?: number + signal?: AbortSignal +} + +function timeoutSignal(timeoutMs: number) { + return (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(timeoutMs) +} + +export async function checkServerHealth( + url: string, + fetch: typeof globalThis.fetch, + opts?: CheckServerHealthOptions, +): Promise { + const signal = opts?.signal ?? timeoutSignal(opts?.timeoutMs ?? 3000) + const sdk = createOpencodeClient({ + baseUrl: url, + fetch, + signal, + }) + return sdk.global + .health() + .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version })) + .catch(() => ({ healthy: false })) +} diff --git a/packages/app/src/utils/speech.ts b/packages/app/src/utils/speech.ts index 201c1261bd..52fc46b693 100644 --- a/packages/app/src/utils/speech.ts +++ b/packages/app/src/utils/speech.ts @@ -1,5 +1,6 @@ import { onCleanup } from "solid-js" import { createStore } from "solid-js/store" +import { getSpeechRecognitionCtor } from "@/utils/runtime-adapters" // Minimal types to avoid relying on non-standard DOM typings type RecognitionResult = { @@ -56,9 +57,8 @@ export function createSpeechRecognition(opts?: { onFinal?: (text: string) => void onInterim?: (text: string) => void }) { - const hasSupport = - typeof window !== "undefined" && - Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition) + const ctor = getSpeechRecognitionCtor(typeof window === "undefined" ? undefined : window) + const hasSupport = Boolean(ctor) const [store, setStore] = createStore({ isRecording: false, @@ -155,10 +155,8 @@ export function createSpeechRecognition(opts?: { }, COMMIT_DELAY) } - if (hasSupport) { - const Ctor: new () => Recognition = (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition - - recognition = new Ctor() + if (ctor) { + recognition = new ctor() recognition.continuous = false recognition.interimResults = true recognition.lang = opts?.lang || (typeof navigator !== "undefined" ? navigator.language : "en-US") diff --git a/packages/app/src/utils/time.ts b/packages/app/src/utils/time.ts new file mode 100644 index 0000000000..ac709d86dd --- /dev/null +++ b/packages/app/src/utils/time.ts @@ -0,0 +1,14 @@ +export function getRelativeTime(dateString: string): string { + const date = new Date(dateString) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSeconds = Math.floor(diffMs / 1000) + const diffMinutes = Math.floor(diffSeconds / 60) + const diffHours = Math.floor(diffMinutes / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffSeconds < 60) return "Just now" + if (diffMinutes < 60) return `${diffMinutes}m ago` + if (diffHours < 24) return `${diffHours}h ago` + return `${diffDays}d ago` +} diff --git a/packages/app/src/utils/worktree.test.ts b/packages/app/src/utils/worktree.test.ts new file mode 100644 index 0000000000..8161e7ad83 --- /dev/null +++ b/packages/app/src/utils/worktree.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import { Worktree } from "./worktree" + +const dir = (name: string) => `/tmp/opencode-worktree-${name}-${crypto.randomUUID()}` + +describe("Worktree", () => { + test("normalizes trailing slashes", () => { + const key = dir("normalize") + Worktree.ready(`${key}/`) + + expect(Worktree.get(key)).toEqual({ status: "ready" }) + }) + + test("pending does not overwrite a terminal state", () => { + const key = dir("pending") + Worktree.failed(key, "boom") + Worktree.pending(key) + + expect(Worktree.get(key)).toEqual({ status: "failed", message: "boom" }) + }) + + test("wait resolves shared pending waiter when ready", async () => { + const key = dir("wait-ready") + Worktree.pending(key) + + const a = Worktree.wait(key) + const b = Worktree.wait(`${key}/`) + + expect(a).toBe(b) + + Worktree.ready(key) + + expect(await a).toEqual({ status: "ready" }) + expect(await b).toEqual({ status: "ready" }) + }) + + test("wait resolves with failure message", async () => { + const key = dir("wait-failed") + const waiting = Worktree.wait(key) + + Worktree.failed(key, "permission denied") + + expect(await waiting).toEqual({ status: "failed", message: "permission denied" }) + expect(await Worktree.wait(key)).toEqual({ status: "failed", message: "permission denied" }) + }) +}) diff --git a/packages/console/app/package.json b/packages/console/app/package.json index d162f1ab0c..2c289f78b9 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.1.51", + "version": "1.1.53", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/app/script/generate-sitemap.ts b/packages/console/app/script/generate-sitemap.ts index 6cbffcb851..bdce205b90 100755 --- a/packages/console/app/script/generate-sitemap.ts +++ b/packages/console/app/script/generate-sitemap.ts @@ -3,6 +3,7 @@ import { readdir, writeFile } from "fs/promises" import { join, dirname } from "path" import { fileURLToPath } from "url" import { config } from "../src/config.js" +import { LOCALES, route } from "../src/lib/language.js" const __dirname = dirname(fileURLToPath(import.meta.url)) const BASE_URL = config.baseUrl @@ -27,12 +28,14 @@ async function getMainRoutes(): Promise { { path: "/zen", priority: 0.8, changefreq: "weekly" }, ] - for (const route of staticRoutes) { - routes.push({ - url: `${BASE_URL}${route.path}`, - priority: route.priority, - changefreq: route.changefreq, - }) + for (const item of staticRoutes) { + for (const locale of LOCALES) { + routes.push({ + url: `${BASE_URL}${route(locale, item.path)}`, + priority: item.priority, + changefreq: item.changefreq, + }) + } } return routes @@ -50,11 +53,13 @@ async function getDocsRoutes(): Promise { const slug = file.replace(".mdx", "") const path = slug === "index" ? "/docs/" : `/docs/${slug}` - routes.push({ - url: `${BASE_URL}${path}`, - priority: slug === "index" ? 0.9 : 0.7, - changefreq: "weekly", - }) + for (const locale of LOCALES) { + routes.push({ + url: `${BASE_URL}${route(locale, path)}`, + priority: slug === "index" ? 0.9 : 0.7, + changefreq: "weekly", + }) + } } } catch (error) { console.error("Error reading docs directory:", error) diff --git a/packages/console/app/src/app.tsx b/packages/console/app/src/app.tsx index cde2f01876..3eb70606a4 100644 --- a/packages/console/app/src/app.tsx +++ b/packages/console/app/src/app.tsx @@ -6,19 +6,27 @@ import { Favicon } from "@opencode-ai/ui/favicon" import { Font } from "@opencode-ai/ui/font" import "@ibm/plex/css/ibm-plex.css" import "./app.css" +import { LanguageProvider } from "~/context/language" +import { I18nProvider } from "~/context/i18n" +import { strip } from "~/lib/language" export default function App() { return ( ( - - opencode - - - - {props.children} - + + + + opencode + + + + {props.children} + + + )} > diff --git a/packages/console/app/src/component/email-signup.tsx b/packages/console/app/src/component/email-signup.tsx index 65f81b5fc6..bd33e92006 100644 --- a/packages/console/app/src/component/email-signup.tsx +++ b/packages/console/app/src/component/email-signup.tsx @@ -2,6 +2,7 @@ import { action, useSubmission } from "@solidjs/router" import dock from "../asset/lander/dock.png" import { Resource } from "@opencode-ai/console-resource" import { Show } from "solid-js" +import { useI18n } from "~/context/i18n" const emailSignup = action(async (formData: FormData) => { "use server" @@ -23,22 +24,21 @@ const emailSignup = action(async (formData: FormData) => { export function EmailSignup() { const submission = useSubmission(emailSignup) + const i18n = useI18n() return (
-

Be the first to know when we release new products

-

Join the waitlist for early access.

+

{i18n.t("email.title")}

+

{i18n.t("email.subtitle")}

- +
-
- Almost done, check your inbox and confirm your email address -
+
{i18n.t("email.success")}
{submission.error}
diff --git a/packages/console/app/src/component/footer.tsx b/packages/console/app/src/component/footer.tsx index 27f8ddd65f..d81bf32476 100644 --- a/packages/console/app/src/component/footer.tsx +++ b/packages/console/app/src/component/footer.tsx @@ -2,12 +2,16 @@ import { createAsync } from "@solidjs/router" import { createMemo } from "solid-js" import { github } from "~/lib/github" import { config } from "~/config" +import { useLanguage } from "~/context/language" +import { useI18n } from "~/context/i18n" export function Footer() { + const language = useLanguage() + const i18n = useI18n() const githubData = createAsync(() => github()) const starCount = createMemo(() => githubData()?.stars - ? new Intl.NumberFormat("en-US", { + ? new Intl.NumberFormat(language.tag(language.locale()), { notation: "compact", compactDisplay: "short", }).format(githubData()!.stars!) @@ -18,20 +22,20 @@ export function Footer() { ) diff --git a/packages/console/app/src/component/header.tsx b/packages/console/app/src/component/header.tsx index 72e9d04189..6fa0f43ad8 100644 --- a/packages/console/app/src/component/header.tsx +++ b/packages/console/app/src/component/header.tsx @@ -19,6 +19,8 @@ import { createStore } from "solid-js/store" import { github } from "~/lib/github" import { createEffect, onCleanup } from "solid-js" import { config } from "~/config" +import { useI18n } from "~/context/i18n" +import { useLanguage } from "~/context/language" import "./header-context-menu.css" const isDarkMode = () => window.matchMedia("(prefers-color-scheme: dark)").matches @@ -36,12 +38,15 @@ const fetchSvgContent = async (svgPath: string): Promise => { export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) { const navigate = useNavigate() + const i18n = useI18n() + const language = useLanguage() const githubData = createAsync(() => github()) const starCount = createMemo(() => githubData()?.stars ? new Intl.NumberFormat("en-US", { notation: "compact", compactDisplay: "short", + maximumFractionDigits: 0, }).format(githubData()?.stars!) : config.github.starsFormatted.compact, ) @@ -118,9 +123,9 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) { return (
@@ -130,49 +135,56 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) { style={`left: ${store.contextMenuPosition.x}px; top: ${store.contextMenuPosition.y}px;`} > -