diff --git a/.github/TEAM_MEMBERS b/.github/TEAM_MEMBERS index ee2e26f452..f892eb95db 100644 --- a/.github/TEAM_MEMBERS +++ b/.github/TEAM_MEMBERS @@ -1,5 +1,4 @@ adamdotdevin -arvsrn Brendonovich fwang Hona @@ -8,14 +7,11 @@ jayair jlongster kitlangton kommander -ludvigrask MrMushrooooom nexxeln R44VC0RP rekram1-node thdxr simonklee -Slickstef11 -usrnk1 vimtor -StarpTech +starptech diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 9d29724d86..5b44517ec5 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -8,13 +8,6 @@ inputs: runs: using: "composite" steps: - # node-gyp@latest (invoked via bunx for native install scripts) requires Node >=22; - # some runner images ship an older system Node on PATH - - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: "24" - - name: Get baseline download URL id: bun-url shell: bash diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1c41a66faa..8981aad49a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,6 +6,7 @@ on: branches: - ci - dev + - v2 - beta - fix/npm-native-binary-install - snapshot-* @@ -31,6 +32,9 @@ permissions: contents: write packages: write +env: + OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }} + jobs: version: runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -122,7 +126,7 @@ jobs: - build-cli - version runs-on: blacksmith-4vcpu-windows-2025 - if: github.repository == 'anomalyco/opencode' + if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' env: AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} @@ -219,8 +223,9 @@ jobs: build-electron: needs: + - build-cli - version - if: github.repository == 'anomalyco/opencode' + if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' continue-on-error: false env: AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} @@ -315,7 +320,10 @@ jobs: env: OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} + OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }} RUST_TARGET: ${{ matrix.settings.target }} + GH_TOKEN: ${{ github.token }} + GITHUB_RUN_ID: ${{ github.run_id }} - name: Build run: bun run build @@ -339,7 +347,8 @@ jobs: env: OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} GH_TOKEN: ${{ steps.committer.outputs.token }} - CSC_KEYCHAIN: build.keychain + CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }} + CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} @@ -442,6 +451,7 @@ jobs: path: packages/opencode/dist - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + if: github.ref_name != 'v2' with: name: opencode-cli-signed-windows path: packages/opencode/dist diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c69de1d93b..534a8d78a7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,7 @@ on: push: branches: - dev + - v2 pull_request: workflow_dispatch: @@ -74,13 +75,9 @@ jobs: working-directory: packages/client run: bun run check:generated - - name: Run HttpApi exerciser gates - if: runner.os == 'Linux' - working-directory: packages/opencode - run: bun run test:httpapi - e2e: name: e2e (${{ matrix.settings.name }}) + if: github.ref_name != 'v2' && github.head_ref != 'v2' strategy: fail-fast: false matrix: diff --git a/.opencode/command/translate.md b/.opencode/command/translate.md index de18ae2ee8..8d493f4a81 100644 --- a/.opencode/command/translate.md +++ b/.opencode/command/translate.md @@ -1,6 +1,6 @@ --- description: translate English to other languages -model: opencode/gpt-5.6-sol +model: opencode/claude-opus-4-8 --- run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time. diff --git a/.opencode/plugins/orchestrator.ts b/.opencode/plugins/orchestrator.ts new file mode 100644 index 0000000000..10d4173ff5 --- /dev/null +++ b/.opencode/plugins/orchestrator.ts @@ -0,0 +1,36 @@ +export default { + id: "Orchestrator", + setup: async (ctx) => { + await ctx.agent.transform((agents) => { + agents.update("orchestrator", (agent) => { + agent.description = "Coordinates work by delegating implementation tasks to the minion subagent." + agent.mode = "primary" + agent.system = [ + "You are Orchestrator, the primary coordinating agent for this repository. You do meta work only: you coordinate, brief, and synthesize — you do not perform the work itself.", + "Delegate ALL actual work to the minion subagent — implementation, exploration, discovery, searching the codebase, reading files to understand a problem, and even trivial one-line edits. Task size is never a reason to do it yourself, and there is no 'final integration' exception.", + "You are not hard-banned from tools, but direct tool use is reserved for coordination overhead: a quick peek to phrase a better brief, a fast read-only check to verify a minion's reported result, or answering a question about coordination state. If a tool call is producing the answer or the artifact the user asked for, that call belongs to a minion, not you.", + "Exploration is work. If the user asks how something works or where something lives, delegate the investigation to a minion rather than exploring yourself.", + "Always start minion subagents in the background. Even if you have nothing else to coordinate right now, the user may assign you new work while a Minion runs, and you must stay free to receive it. Never poll; you will be notified when they finish.", + "Give each minion a clear, self-contained brief: the goal, constraints, expected output, and any files or context already known from the user or previous minion reports.", + "Synthesize minion results, decide next steps, and report back concisely.", + ].join("\n") + }) + + agents.update("minion", (agent) => { + agent.description = "Subagent that executes focused tasks delegated by Orchestrator." + agent.mode = "subagent" + agent.model = { providerID: "opencode", id: "glm-5.2" } + agent.system = [ + "You are minion, a focused execution subagent for this repository.", + "Complete the specific task delegated to you by Orchestrator using the available tools.", + "Inspect the codebase before making assumptions, make targeted changes when requested, and verify your work when feasible.", + "Follow the repository's AGENTS.md conventions: respect the style guide, run `bun typecheck` from the affected package directory after code changes, never run tests from the repo root, and do not modify packages/opencode unless the task explicitly says V1 work.", + "If the task is ambiguous or you hit a blocker, stop and report your findings instead of guessing.", + "Keep your final response concise: summarize what you did, list important files changed or findings, and call out blockers or verification gaps.", + "Do not delegate to other subagents; execute the assigned work yourself.", + ].join("\n") + agent.permissions.push({ action: "subagent", resource: "*", effect: "deny" }) + }) + }) + }, +} diff --git a/.opencode/skills/debug-opencode/SKILL.md b/.opencode/skills/debug-opencode/SKILL.md new file mode 100644 index 0000000000..fd7cae09d2 --- /dev/null +++ b/.opencode/skills/debug-opencode/SKILL.md @@ -0,0 +1,144 @@ +--- +name: debug-opencode +description: Use when interactively running, debugging, or verifying opencode's own V2 CLI/TUI or server during development in this repo — starting the dev TUI, driving it with termctrl, comparing V2 against the legacy TUI, hitting the V2 server/API directly, reading log files, or attaching Bun's inspector. +--- + +# Debugging opencode itself + +Workflow for interactively exercising the V2 CLI/TUI and server while developing in this repo. All commands below run from `packages/cli` unless noted otherwise. + +## Migration context + +- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state. +- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released legacy TUI (see "Comparing V2 against the legacy TUI" below) rather than guessing. + +## Server/client model + +opencode V2 is a client/server system, not a single monolithic process: + +- **Server process** runs the Effect HTTP API (`packages/server`) and owns all domain state: sessions, database, plugins, permissions, Location services. It's started by the `serve` command (`packages/cli/src/commands/handlers/serve.ts`). +- **TUI process** is a separate process that runs no application logic itself — it's an HTTP/SSE client of the server via the generated SDK (`createOpencodeClient` / `sdk.client.v2`). +- **Discovery**: CLI processes find the shared server through a JSON registration file at `~/.local/state/opencode/service.json` (or `service-local.json` for the local/dev channel) containing `{id, version, url, pid}`. A separate password file under `~/.config/opencode/service.json` provides HTTP Basic auth. Before reusing a registration, the client calls `GET /health` to confirm the server is alive, authenticated, and version-compatible. +- **Sharing**: because of this registration/health-check dance, many concurrent `opencode`/TUI invocations converge on one shared background daemon rather than each spawning their own. If no compatible healthy daemon is found, a new one is spawned detached (`serve --service`) and registers itself. +- **`bun dev service start|status|stop|restart`** manages this shared background daemon's lifecycle directly — useful when you need to force a fresh server, confirm one is running, or kill a stuck one. +- **Standalone mode** (`--standalone`) opts a single invocation out of the shared daemon: it spawns a private one-off `serve --stdio --port 0` child tied to that invocation's lifetime, with its own random password. Use this to isolate a debugging session from your other running opencode sessions. +- Every log line is tagged `role=server` or `role=cli` and a per-process `run=`, so you can distinguish server-side and client-side activity in one shared log file (see "Logs" below) even when both roles are interleaved from concurrent processes. + +## Starting the dev TUI + +- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI. +- Run commands from `packages/cli`. Use `bun dev` for most debugging so the TUI starts with a private V2 server. + +## Interactive debugging with termctrl + +- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots. +- Use a dedicated session name and do not reuse or kill an unrelated session. + +```bash +termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev +termctrl wait opencode-v2-dev "Ask anything" --timeout 20000 +termctrl show opencode-v2-dev +``` + +- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`. +- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input. + +```bash +termctrl send opencode-v2-dev 'text:example prompt' enter +termctrl send opencode-v2-dev ctrl-c +``` + +- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits. +- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed. + +```bash +termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png +``` + +- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again: + +```bash +termctrl resize opencode-v2-dev --cols 100 --rows 30 +termctrl show opencode-v2-dev +``` + +- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change. +- To exercise background-service behavior, use `bun dev service start`, `bun dev service status`, and `bun dev service stop`. +- Always clean up the Terminal Control session when the check is complete: + +```bash +termctrl stop opencode-v2-dev +``` + +## Comparing V2 against the legacy TUI + +Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states: + +```bash +# From packages/cli: local V2 TUI +termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev + +# Released legacy TUI behavior reference +termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest + +termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png +termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png +``` + +- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints. +- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`. + +## Server/API debugging + +- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI. +- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering. +- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path: + +```bash +bun dev api get /health +bun dev api get /openapi.json +bun dev api --param key=value +``` + +- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`. +- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control. +- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters. + +## Logs + +- Log files live under `~/.local/share/opencode/log/`. In a local/dev checkout the active file is `opencode-local.log`; `opencode.log` is used for non-local (released) channel installs. Both are append-only, shared across every CLI and server process on the machine. +- Each line is structured `key=value` text: `timestamp`, `level`, `run=` (per-process run ID), `message`, and a `role=cli` or `role=server` tag. Use `run=` to isolate one process's activity and `role=` to separate client-side from server-side log lines, since a shared daemon interleaves many processes' output in one file. +- Tail the live file while reproducing an issue instead of guessing from stale output: + +```bash +tail -f ~/.local/share/opencode/log/opencode-local.log +``` + +- Filter to one run or role when the file is noisy: + +```bash +grep 'run=8fc3b1d5' ~/.local/share/opencode/log/opencode-local.log +grep 'role=server' ~/.local/share/opencode/log/opencode-local.log +``` + +- `OPENCODE_LOG_LEVEL` controls verbosity (default `INFO`); set it before starting `bun dev` or `serve` to get `DEBUG` output for a specific repro. +- `OPENCODE_PRINT_LOGS=1` additionally tees log output to stderr of the process that emitted it, which is useful when a process fails before you'd think to check the shared log file. +- `termctrl logs ` surfaces stdout/stderr for a Terminal Control session specifically (e.g. inspector output or startup failures before the TUI renderer starts) — use the log file above for anything emitted by a separate server/daemon process instead. + +## Debugger + +- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL: + +```bash +termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \ + bun run --inspect=ws://localhost:6499/ src/index.ts +``` + +- Use `--inspect-wait` or `--inspect-brk` when execution must pause until the debugger attaches. +- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI. + +## Verification + +- Run `bun typecheck` from `packages/cli` after CLI adapter changes. +- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root. +- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state. diff --git a/.opencode/skills/sample-skill/SKILL.md b/.opencode/skills/sample-skill/SKILL.md new file mode 100644 index 0000000000..87b9e96907 --- /dev/null +++ b/.opencode/skills/sample-skill/SKILL.md @@ -0,0 +1,31 @@ +--- +name: sample-skill +description: Use when the user says sample skill, skill demo, or asks how an opencode SKILL.md should be structured; demonstrates a tiny project-local skill with practical assistant workflow guidance. +--- + +# Sample Skill + +This is a minimal project-local opencode skill. It exists as a reference for how a skill is structured and as a tiny reusable workflow the assistant can load when the user asks for a skill example. + +## When To Use + +- Use when the user asks for a sample skill or skill template. +- Use when demonstrating the required `SKILL.md` frontmatter and body format. +- Do not use for unrelated coding tasks just because a skill exists. + +## Workflow + +- Confirm the specific outcome the user wants if the request is ambiguous. +- Inspect the relevant files before changing anything. +- Make the smallest correct change. +- Verify the result with a focused read, typecheck, test, or other lightweight check when available. +- Summarize the changed files and any required restart or reload step. + +## Example Response Style + +When this skill is relevant, keep responses direct and actionable: + +```text +I created a project-local skill at .opencode/skills/sample-skill/SKILL.md. +Restart opencode for the new skill to be discovered by future sessions. +``` diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index e861e1e467..f25ca48b38 100644 --- a/.opencode/tool/github-triage.ts +++ b/.opencode/tool/github-triage.ts @@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin" const TEAM = { tui: ["kommander", "simonklee"], desktop_web: ["Hona", "Brendonovich"], - core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"], + core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"], inference: ["fwang", "MrMushrooooom", "starptech"], windows: ["Hona"], } as const diff --git a/AGENTS.md b/AGENTS.md index cd2327e888..649f0ae055 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ - To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`. - After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly. - Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server. +- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required. - The default branch in this repo is `dev`. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. diff --git a/README.zh.md b/README.zh.md index 352e13ccaa..93f81e83cd 100644 --- a/README.zh.md +++ b/README.zh.md @@ -125,4 +125,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换: --- -**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode) +**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode) diff --git a/README.zht.md b/README.zht.md index f09358b133..59d9709bd3 100644 --- a/README.zht.md +++ b/README.zht.md @@ -125,4 +125,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。 --- -**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode) +**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode) diff --git a/artifacts/glm52-rise-video/.gitignore b/artifacts/glm52-rise-video/.gitignore deleted file mode 100644 index 9d38bb2591..0000000000 --- a/artifacts/glm52-rise-video/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules -.remotion -out/frame-*.png diff --git a/artifacts/glm52-rise-video/bun.lock b/artifacts/glm52-rise-video/bun.lock deleted file mode 100644 index cac009ae81..0000000000 --- a/artifacts/glm52-rise-video/bun.lock +++ /dev/null @@ -1,483 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "glm52-rise-video", - "dependencies": { - "@remotion/cli": "^4.0.384", - "react": "^19.2.3", - "react-dom": "^19.2.3", - "remotion": "^4.0.384", - }, - "devDependencies": { - "@types/react": "^19.2.8", - "@types/react-dom": "^19.2.3", - "typescript": "^5.8.2", - }, - }, - }, - "packages": { - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - - "@babel/parser": ["@babel/parser@7.24.1", "", { "bin": "./bin/babel-parser.js" }, "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg=="], - - "@babel/types": ["@babel/types@7.24.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" } }, "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w=="], - - "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - - "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], - - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - - "@mediabunny/aac-encoder": ["@mediabunny/aac-encoder@1.47.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-JNzgdJoHMFFnv5imi1+dmjZMudsJ1zNCUCEkKjBh90cqGhAFg8xu4V2gsyxE2i6oq/YpWx23P+OvoANLSMcDzA=="], - - "@mediabunny/flac-encoder": ["@mediabunny/flac-encoder@1.47.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-VpKmJO0xlYcFCRD6JvJlMbNQ6d/6YMHdO1gFIqWlZABHjSSL6BquNNEgWSCv5vdF8ELDBwIYBVZEslakIB/7GA=="], - - "@mediabunny/mp3-encoder": ["@mediabunny/mp3-encoder@1.47.0", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-JyzZyGeGRm2HVUQaGJ/VZT9OG+kG00mA8SLP/f3CO7+qWAmBncKc16WYXWHbZEo8Jn/ZGA6De32S5R2bTQbDqA=="], - - "@module-federation/error-codes": ["@module-federation/error-codes@0.22.0", "", {}, "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug=="], - - "@module-federation/runtime": ["@module-federation/runtime@0.22.0", "", { "dependencies": { "@module-federation/error-codes": "0.22.0", "@module-federation/runtime-core": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA=="], - - "@module-federation/runtime-core": ["@module-federation/runtime-core@0.22.0", "", { "dependencies": { "@module-federation/error-codes": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA=="], - - "@module-federation/runtime-tools": ["@module-federation/runtime-tools@0.22.0", "", { "dependencies": { "@module-federation/runtime": "0.22.0", "@module-federation/webpack-bundler-runtime": "0.22.0" } }, "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA=="], - - "@module-federation/sdk": ["@module-federation/sdk@0.22.0", "", {}, "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g=="], - - "@module-federation/webpack-bundler-runtime": ["@module-federation/webpack-bundler-runtime@0.22.0", "", { "dependencies": { "@module-federation/runtime": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA=="], - - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="], - - "@remotion/bundler": ["@remotion/bundler@4.0.483", "", { "dependencies": { "@remotion/media-parser": "4.0.483", "@remotion/studio": "4.0.483", "@remotion/studio-shared": "4.0.483", "@remotion/timeline-utils": "4.0.483", "@rspack/core": "1.7.11", "@rspack/plugin-react-refresh": "1.6.1", "css-loader": "7.1.4", "esbuild": "0.28.1", "react-refresh": "0.18.0", "remotion": "4.0.483", "style-loader": "4.0.0", "webpack": "5.105.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-5fQRMYgr2sxZPBOThnIVuFwHBq/FhusJ+Ke6xopga8io8ecK/mj9GaRXC3tgfMa+YWuu8ZSXZ0U9EsMZjjP5YQ=="], - - "@remotion/canvas-capture": ["@remotion/canvas-capture@4.0.483", "", { "dependencies": { "mediabunny": "1.47.0", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JABgbfvXwjFp1E61tlDN9gVAH8OoeF4kUUELNwG9kcNOE4ex8+vCYEO6VKIaXZ4l8A1PEAQ5EK//NheTJ80gfw=="], - - "@remotion/cli": ["@remotion/cli@4.0.483", "", { "dependencies": { "@remotion/bundler": "4.0.483", "@remotion/media-utils": "4.0.483", "@remotion/player": "4.0.483", "@remotion/renderer": "4.0.483", "@remotion/studio": "4.0.483", "@remotion/studio-server": "4.0.483", "@remotion/studio-shared": "4.0.483", "dotenv": "17.3.1", "minimist": "1.2.6", "prompts": "2.4.2", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" }, "bin": { "remotion": "remotion-cli.js", "remotionb": "remotionb-cli.js", "remotiond": "remotiond-cli.js" } }, "sha512-z4TQcgTfQbSIV4GQpCvIihDL77M+leQelbxv962PmfSKTT7z3XnNN732PcZ4sei2+Xg+d2SD95K4tI5XBYs5Gw=="], - - "@remotion/compositor-darwin-arm64": ["@remotion/compositor-darwin-arm64@4.0.483", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CkEJXouGEoCs5PX3KOufkXrlT/Kgn0ZF7SEQ4meCYGYMZD+QPVtsmS1MXH8pQ4RHlFO6Tk7g0CIHMT3Y4yzpuA=="], - - "@remotion/compositor-darwin-x64": ["@remotion/compositor-darwin-x64@4.0.483", "", { "os": "darwin", "cpu": "x64" }, "sha512-vDZK5U8FYbHGMjFC2lUgJRBz8KXwbwFSvEQi8FATD0roNg75SoaEJWrqAOIVv8v5eBjPvo18znDc36NLRYn1Eg=="], - - "@remotion/compositor-linux-arm64-gnu": ["@remotion/compositor-linux-arm64-gnu@4.0.483", "", { "os": "linux", "cpu": "arm64" }, "sha512-1LzfYDY/DQxy3MIc9pCbTn1ObcBDcyI6MzODOwe1bTZl8TZxdUUt1M3hDrQ60AB6iHwruBAnzT9dnogiMIdSew=="], - - "@remotion/compositor-linux-arm64-musl": ["@remotion/compositor-linux-arm64-musl@4.0.483", "", { "os": "linux", "cpu": "arm64" }, "sha512-nG1btxg0HXuLzulIqAg9NnxWyewZNrA0iLe82HdniaJjeWLORreGo4cEEJz1SjbDpQuPEY562XGkTO2DLKK5mA=="], - - "@remotion/compositor-linux-x64-gnu": ["@remotion/compositor-linux-x64-gnu@4.0.483", "", { "os": "linux", "cpu": "x64" }, "sha512-CV4h6r2rNYh4P8utUzP6RM2vW2vq43nA3IvVOwRSuh9LwJh/7rwG53Oco4HanY/sstaNuymGpT57wdXwONU5CQ=="], - - "@remotion/compositor-linux-x64-musl": ["@remotion/compositor-linux-x64-musl@4.0.483", "", { "os": "linux", "cpu": "x64" }, "sha512-moKvavbkoz5nteJZA4K6LMgTTlewhwIbtO7DYARZ3LK3ClGUKnYfFcmaPS15hMun02JPTOMIWbrlaszdR75mxA=="], - - "@remotion/compositor-win32-x64-msvc": ["@remotion/compositor-win32-x64-msvc@4.0.483", "", { "os": "win32", "cpu": "x64" }, "sha512-eDpWe6Vy7ySHugwxlgaeohqdBwR1etxiymXzT13cgbPw2+p6d55z1tJ7VPTXyfu0o+UdyRTauWqD4msoxN0wow=="], - - "@remotion/licensing": ["@remotion/licensing@4.0.483", "", {}, "sha512-GRtjykrxMmB5Cjpq1oVqeYIRXI95vid6afX0LTLfp1shD6MR4lTZRoRuopxrrZp1E4XFGXoY64b6dlIudgTyBQ=="], - - "@remotion/media-parser": ["@remotion/media-parser@4.0.483", "", {}, "sha512-iva9Eof7QQX+3hGxH/N8pGi80wgdGuzOr825Zn7IuEe7IWdtRYWlFpCEghmyfhrwgaWa7RY51wg77ycR3c3nRg=="], - - "@remotion/media-utils": ["@remotion/media-utils@4.0.483", "", { "dependencies": { "mediabunny": "1.47.0", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4T4WQh/U95kCBzgmz/EL/HL8Zeox/AKAL5ufsehrZFgwlcvH1EsKNJH/RWaAtNPRLF+pskEOgFzPYaczHeI8hg=="], - - "@remotion/player": ["@remotion/player@4.0.483", "", { "dependencies": { "remotion": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-yyIQ9vinUqqS0fQeL52bPCWQIr8qDXCqQ4iys8P7dSZncdu8V29p4W2a0/4eKV8bv5+mjnJQ/U6nl9NxBF1hdA=="], - - "@remotion/renderer": ["@remotion/renderer@4.0.483", "", { "dependencies": { "@remotion/licensing": "4.0.483", "@remotion/streaming": "4.0.483", "execa": "5.1.1", "remotion": "4.0.483", "source-map": "0.8.0-beta.0", "ws": "8.21.0" }, "optionalDependencies": { "@remotion/compositor-darwin-arm64": "4.0.483", "@remotion/compositor-darwin-x64": "4.0.483", "@remotion/compositor-linux-arm64-gnu": "4.0.483", "@remotion/compositor-linux-arm64-musl": "4.0.483", "@remotion/compositor-linux-x64-gnu": "4.0.483", "@remotion/compositor-linux-x64-musl": "4.0.483", "@remotion/compositor-win32-x64-msvc": "4.0.483" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-eSKdQqm8rcl+GNyOhFsBgcPYWMcxTJWHL5xaNrd037piCaL3sdJ57OEO/AbfEvvOO14zwUvlsONKjlkLlEVyKg=="], - - "@remotion/streaming": ["@remotion/streaming@4.0.483", "", {}, "sha512-96rqrk+l5AfriHOqmqxMn8rI6UJfFY7r5UMDZoSKqXWzTOwj+0VWHTRl9ilZQMXhYACaMSA/5yrL9jgQEK8Yuw=="], - - "@remotion/studio": ["@remotion/studio@4.0.483", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@remotion/canvas-capture": "4.0.483", "@remotion/media-utils": "4.0.483", "@remotion/player": "4.0.483", "@remotion/renderer": "4.0.483", "@remotion/studio-shared": "4.0.483", "@remotion/timeline-utils": "4.0.483", "@remotion/web-renderer": "4.0.483", "@remotion/zod-types": "4.0.483", "mediabunny": "1.47.0", "memfs": "3.4.3", "open": "8.4.2", "remotion": "4.0.483", "semver": "7.5.3", "zod": "4.3.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-gNa1Lw8NmHl0jwFb5fk7rzA+WFTJQpGDT/49A38t9jpEwoF+h1mKTgY8gA202apzjBHrEECdFE450npnBOUpUw=="], - - "@remotion/studio-server": ["@remotion/studio-server@4.0.483", "", { "dependencies": { "@babel/parser": "7.24.1", "@babel/types": "7.24.0", "@remotion/bundler": "4.0.483", "@remotion/renderer": "4.0.483", "@remotion/studio-shared": "4.0.483", "memfs": "3.4.3", "open": "8.4.2", "prettier": "3.8.1", "recast": "0.23.11", "remotion": "4.0.483", "semver": "7.5.3" } }, "sha512-2FSLbVN5N8bgtQPX+RQRbis557tJJnVTOZuEBZndfC9NOoPdARlSgEnwVer9tBNczUlc6B3KrPxyPSlS3UjL+Q=="], - - "@remotion/studio-shared": ["@remotion/studio-shared@4.0.483", "", { "dependencies": { "remotion": "4.0.483" } }, "sha512-aXxBYUgsWgphHVq7T7bz+ICMGzfii7pcX1IxS5hbLMLJHqlDtk9g+qcxm4kJ2Fjj/Gb5gERKiNzYym8WZTIM2Q=="], - - "@remotion/timeline-utils": ["@remotion/timeline-utils@4.0.483", "", { "dependencies": { "mediabunny": "1.47.0" } }, "sha512-KTTMdpNA5uRLX+iisAITO+V9cJBk17F8EWvuUnAEVmAFOQTbGIyPI9HNw5pZmL7SlzOJdFjAtksTjipeYGkEug=="], - - "@remotion/web-renderer": ["@remotion/web-renderer@4.0.483", "", { "dependencies": { "@mediabunny/aac-encoder": "1.47.0", "@mediabunny/flac-encoder": "1.47.0", "@mediabunny/mp3-encoder": "1.47.0", "@remotion/licensing": "4.0.483", "mediabunny": "1.47.0", "remotion": "4.0.483" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-14iURfVWA/hcF/rJvkid1ZR5nmnVhkTIf071FabCFi6kdViOSVBugSglJ8tUPvWVbWmxlXGDGQD9CSU18IATww=="], - - "@remotion/zod-types": ["@remotion/zod-types@4.0.483", "", { "dependencies": { "remotion": "4.0.483" }, "peerDependencies": { "zod": "4.3.6" } }, "sha512-KIVYIzFpZBDB0wxGGQu5tDBr4XOYRzvrPXjmo6O/FI5Xx7O8hgvJmBs9VDkEwonAOft86sg79hI19F1DTX1p4w=="], - - "@rspack/binding": ["@rspack/binding@1.7.11", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "1.7.11", "@rspack/binding-darwin-x64": "1.7.11", "@rspack/binding-linux-arm64-gnu": "1.7.11", "@rspack/binding-linux-arm64-musl": "1.7.11", "@rspack/binding-linux-x64-gnu": "1.7.11", "@rspack/binding-linux-x64-musl": "1.7.11", "@rspack/binding-wasm32-wasi": "1.7.11", "@rspack/binding-win32-arm64-msvc": "1.7.11", "@rspack/binding-win32-ia32-msvc": "1.7.11", "@rspack/binding-win32-x64-msvc": "1.7.11" } }, "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q=="], - - "@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@1.7.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig=="], - - "@rspack/binding-darwin-x64": ["@rspack/binding-darwin-x64@1.7.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA=="], - - "@rspack/binding-linux-arm64-gnu": ["@rspack/binding-linux-arm64-gnu@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA=="], - - "@rspack/binding-linux-arm64-musl": ["@rspack/binding-linux-arm64-musl@1.7.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw=="], - - "@rspack/binding-linux-x64-gnu": ["@rspack/binding-linux-x64-gnu@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ=="], - - "@rspack/binding-linux-x64-musl": ["@rspack/binding-linux-x64-musl@1.7.11", "", { "os": "linux", "cpu": "x64" }, "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg=="], - - "@rspack/binding-wasm32-wasi": ["@rspack/binding-wasm32-wasi@1.7.11", "", { "dependencies": { "@napi-rs/wasm-runtime": "1.0.7" }, "cpu": "none" }, "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ=="], - - "@rspack/binding-win32-arm64-msvc": ["@rspack/binding-win32-arm64-msvc@1.7.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA=="], - - "@rspack/binding-win32-ia32-msvc": ["@rspack/binding-win32-ia32-msvc@1.7.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw=="], - - "@rspack/binding-win32-x64-msvc": ["@rspack/binding-win32-x64-msvc@1.7.11", "", { "os": "win32", "cpu": "x64" }, "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q=="], - - "@rspack/core": ["@rspack/core@1.7.11", "", { "dependencies": { "@module-federation/runtime-tools": "0.22.0", "@rspack/binding": "1.7.11", "@rspack/lite-tapable": "1.1.0" }, "peerDependencies": { "@swc/helpers": ">=0.5.1" }, "optionalPeers": ["@swc/helpers"] }, "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew=="], - - "@rspack/lite-tapable": ["@rspack/lite-tapable@1.1.0", "", {}, "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw=="], - - "@rspack/plugin-react-refresh": ["@rspack/plugin-react-refresh@1.6.1", "", { "dependencies": { "error-stack-parser": "^2.1.4", "html-entities": "^2.6.0" }, "peerDependencies": { "react-refresh": ">=0.10.0 <1.0.0", "webpack-hot-middleware": "2.x" }, "optionalPeers": ["webpack-hot-middleware"] }, "sha512-eqqW5645VG3CzGzFgNg5HqNdHVXY+567PGjtDhhrM8t67caxmsSzRmT5qfoEIfBcGgFkH9vEg7kzXwmCYQdQDw=="], - - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], - - "@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.11", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ=="], - - "@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="], - - "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], - - "@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="], - - "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - - "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - - "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="], - - "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], - - "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - - "@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="], - - "@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="], - - "@webassemblyjs/helper-api-error": ["@webassemblyjs/helper-api-error@1.13.2", "", {}, "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ=="], - - "@webassemblyjs/helper-buffer": ["@webassemblyjs/helper-buffer@1.14.1", "", {}, "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA=="], - - "@webassemblyjs/helper-numbers": ["@webassemblyjs/helper-numbers@1.13.2", "", { "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA=="], - - "@webassemblyjs/helper-wasm-bytecode": ["@webassemblyjs/helper-wasm-bytecode@1.13.2", "", {}, "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA=="], - - "@webassemblyjs/helper-wasm-section": ["@webassemblyjs/helper-wasm-section@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/wasm-gen": "1.14.1" } }, "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw=="], - - "@webassemblyjs/ieee754": ["@webassemblyjs/ieee754@1.13.2", "", { "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw=="], - - "@webassemblyjs/leb128": ["@webassemblyjs/leb128@1.13.2", "", { "dependencies": { "@xtuc/long": "4.2.2" } }, "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw=="], - - "@webassemblyjs/utf8": ["@webassemblyjs/utf8@1.13.2", "", {}, "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ=="], - - "@webassemblyjs/wasm-edit": ["@webassemblyjs/wasm-edit@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/helper-wasm-section": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-opt": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1", "@webassemblyjs/wast-printer": "1.14.1" } }, "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ=="], - - "@webassemblyjs/wasm-gen": ["@webassemblyjs/wasm-gen@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg=="], - - "@webassemblyjs/wasm-opt": ["@webassemblyjs/wasm-opt@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", "@webassemblyjs/wasm-gen": "1.14.1", "@webassemblyjs/wasm-parser": "1.14.1" } }, "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw=="], - - "@webassemblyjs/wasm-parser": ["@webassemblyjs/wasm-parser@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", "@webassemblyjs/ieee754": "1.13.2", "@webassemblyjs/leb128": "1.13.2", "@webassemblyjs/utf8": "1.13.2" } }, "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ=="], - - "@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="], - - "@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="], - - "@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="], - - "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], - - "acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="], - - "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - - "ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], - - "ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], - - "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], - - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.40", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw=="], - - "browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="], - - "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - - "caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="], - - "chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="], - - "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], - - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - - "css-loader": ["css-loader@7.1.4", "", { "dependencies": { "icss-utils": "^5.1.0", "postcss": "^8.4.40", "postcss-modules-extract-imports": "^3.1.0", "postcss-modules-local-by-default": "^4.0.5", "postcss-modules-scope": "^3.2.0", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.2.0", "semver": "^7.6.3" }, "peerDependencies": { "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", "webpack": "^5.27.0" }, "optionalPeers": ["@rspack/core", "webpack"] }, "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw=="], - - "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], - - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - - "define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], - - "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], - - "electron-to-chromium": ["electron-to-chromium@1.5.379", "", {}, "sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA=="], - - "enhanced-resolve": ["enhanced-resolve@5.24.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw=="], - - "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="], - - "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], - - "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], - - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - - "eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], - - "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], - - "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], - - "estraverse": ["estraverse@4.3.0", "", {}, "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="], - - "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], - - "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], - - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - - "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], - - "fs-monkey": ["fs-monkey@1.0.3", "", {}, "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q=="], - - "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], - - "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], - - "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - - "html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="], - - "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], - - "icss-utils": ["icss-utils@5.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA=="], - - "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], - - "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - - "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], - - "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], - - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], - - "loader-runner": ["loader-runner@4.3.2", "", {}, "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w=="], - - "lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="], - - "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], - - "mediabunny": ["mediabunny@1.47.0", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-XQMZAcaKPkJ7hQ/Q2fvBdl3ZazQl2WVxDysUbJWh4PuAnLoerdsQBdPTDWdUdK6hh26LQ8Ue94MLLnmpWvlUYg=="], - - "memfs": ["memfs@3.4.3", "", { "dependencies": { "fs-monkey": "1.0.3" } }, "sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg=="], - - "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], - - "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - - "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - - "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - - "minimist": ["minimist@1.2.6", "", {}, "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="], - - "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], - - "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], - - "node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="], - - "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], - - "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - - "open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], - - "postcss-modules-extract-imports": ["postcss-modules-extract-imports@3.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q=="], - - "postcss-modules-local-by-default": ["postcss-modules-local-by-default@4.2.0", "", { "dependencies": { "icss-utils": "^5.0.0", "postcss-selector-parser": "^7.0.0", "postcss-value-parser": "^4.1.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw=="], - - "postcss-modules-scope": ["postcss-modules-scope@3.2.1", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA=="], - - "postcss-modules-values": ["postcss-modules-values@4.0.0", "", { "dependencies": { "icss-utils": "^5.0.0" }, "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ=="], - - "postcss-selector-parser": ["postcss-selector-parser@7.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg=="], - - "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], - - "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], - - "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], - - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - - "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], - - "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], - - "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], - - "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], - - "remotion": ["remotion@4.0.483", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-lf4xq4Twn75TQeTavFLrTE4zdck7EKBSx07ZrPv4om40Sist+3G0kq7NNzSjMeJ6G2Wx6vI+0oICD/vv4sGWtg=="], - - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - - "schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], - - "semver": ["semver@7.5.3", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ=="], - - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - - "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - - "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], - - "source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="], - - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - - "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], - - "stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="], - - "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], - - "style-loader": ["style-loader@4.0.0", "", { "peerDependencies": { "webpack": "^5.27.0" } }, "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA=="], - - "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - - "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - - "terser": ["terser@5.48.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q=="], - - "terser-webpack-plugin": ["terser-webpack-plugin@5.6.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ=="], - - "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], - - "to-fast-properties": ["to-fast-properties@2.0.0", "", {}, "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog=="], - - "tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="], - - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], - - "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - - "watchpack": ["watchpack@2.5.2", "", { "dependencies": { "graceful-fs": "^4.1.2" } }, "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg=="], - - "webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="], - - "webpack": ["webpack@5.105.0", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.19.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.5.1", "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw=="], - - "webpack-sources": ["webpack-sources@3.5.0", "", {}, "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ=="], - - "whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="], - - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - - "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - - "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - - "css-loader/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - - "esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - - "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - - "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - } -} diff --git a/artifacts/glm52-rise-video/out/flash-share.mp4 b/artifacts/glm52-rise-video/out/flash-share.mp4 deleted file mode 100644 index 3e182b4c11..0000000000 Binary files a/artifacts/glm52-rise-video/out/flash-share.mp4 and /dev/null differ diff --git a/artifacts/glm52-rise-video/out/glm-52-broke-out.mp4 b/artifacts/glm52-rise-video/out/glm-52-broke-out.mp4 deleted file mode 100644 index eb456d0345..0000000000 Binary files a/artifacts/glm52-rise-video/out/glm-52-broke-out.mp4 and /dev/null differ diff --git a/artifacts/glm52-rise-video/out/june-totals.png b/artifacts/glm52-rise-video/out/june-totals.png deleted file mode 100644 index 678376dbed..0000000000 Binary files a/artifacts/glm52-rise-video/out/june-totals.png and /dev/null differ diff --git a/artifacts/glm52-rise-video/out/minimax-climb.mp4 b/artifacts/glm52-rise-video/out/minimax-climb.mp4 deleted file mode 100644 index 8ca6b678ae..0000000000 Binary files a/artifacts/glm52-rise-video/out/minimax-climb.mp4 and /dev/null differ diff --git a/artifacts/glm52-rise-video/out/novel-1984.mp4 b/artifacts/glm52-rise-video/out/novel-1984.mp4 deleted file mode 100644 index b94a899257..0000000000 Binary files a/artifacts/glm52-rise-video/out/novel-1984.mp4 and /dev/null differ diff --git a/artifacts/glm52-rise-video/out/nz-sheep.mp4 b/artifacts/glm52-rise-video/out/nz-sheep.mp4 deleted file mode 100644 index 64d382da26..0000000000 Binary files a/artifacts/glm52-rise-video/out/nz-sheep.mp4 and /dev/null differ diff --git a/artifacts/glm52-rise-video/package.json b/artifacts/glm52-rise-video/package.json deleted file mode 100644 index fb08574eb2..0000000000 --- a/artifacts/glm52-rise-video/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "glm52-rise-video", - "private": true, - "type": "module", - "scripts": { - "render": "remotion render src/index.tsx GLM52Rise out/glm-52-broke-out.mp4 --codec h264 --pixel-format yuv420p", - "render:sheep": "remotion render src/index.tsx NZSheep out/nz-sheep.mp4 --codec h264 --pixel-format yuv420p", - "render:novel": "remotion render src/index.tsx NovelTokens out/novel-1984.mp4 --codec h264 --pixel-format yuv420p", - "render:flash": "remotion render src/index.tsx FlashShare out/flash-share.mp4 --codec h264 --pixel-format yuv420p", - "render:minimax": "remotion render src/index.tsx MiniMaxClimb out/minimax-climb.mp4 --codec h264 --pixel-format yuv420p", - "still:june": "remotion still src/index.tsx JuneTotals out/june-totals.png --frame=0" - }, - "dependencies": { - "@remotion/cli": "^4.0.384", - "react": "^19.2.3", - "react-dom": "^19.2.3", - "remotion": "^4.0.384" - }, - "devDependencies": { - "@types/react": "^19.2.8", - "@types/react-dom": "^19.2.3", - "typescript": "^5.8.2" - } -} diff --git a/artifacts/glm52-rise-video/public/book.jpg b/artifacts/glm52-rise-video/public/book.jpg deleted file mode 100644 index 662ca244cd..0000000000 Binary files a/artifacts/glm52-rise-video/public/book.jpg and /dev/null differ diff --git a/artifacts/glm52-rise-video/public/sheep.jpg b/artifacts/glm52-rise-video/public/sheep.jpg deleted file mode 100644 index d9ed232a89..0000000000 Binary files a/artifacts/glm52-rise-video/public/sheep.jpg and /dev/null differ diff --git a/artifacts/glm52-rise-video/src/data.ts b/artifacts/glm52-rise-video/src/data.ts deleted file mode 100644 index d9fab2a631..0000000000 --- a/artifacts/glm52-rise-video/src/data.ts +++ /dev/null @@ -1,156 +0,0 @@ -// Verified against the production opencode-stats PlanetScale DB. -// Scope: tier='Go' (OpenCode Go), dataset='zen', client='all', source='all', grain='day'. -// metric = total_tokens. Daily token volume per model; GLM-5.2 (zhipu) launched Jun 17. -// Segments per day (tokens): glm-5.2, deepseek-v4-flash, deepseek-v4-pro, minimax-m3, all others. - -export type Day = { - date: string - total: number - glm: number - dsf: number - dsp: number - mm: number - others: number -} - -export const days: Day[] = [ - { - date: "Jun 12", - total: 2_283_799_449_383, - glm: 0, - dsf: 1_176_701_653_509, - dsp: 569_527_034_307, - mm: 159_016_250_684, - others: 378_554_510_883, - }, - { - date: "Jun 13", - total: 2_008_462_388_420, - glm: 0, - dsf: 995_338_131_997, - dsp: 445_817_536_548, - mm: 211_743_241_967, - others: 355_563_477_908, - }, - { - date: "Jun 14", - total: 2_007_785_405_251, - glm: 0, - dsf: 983_954_176_228, - dsp: 428_151_999_341, - mm: 262_476_527_930, - others: 333_202_701_752, - }, - { - date: "Jun 15", - total: 2_694_736_103_062, - glm: 0, - dsf: 1_255_893_953_859, - dsp: 632_223_338_376, - mm: 352_507_442_991, - others: 454_111_367_836, - }, - { - date: "Jun 16", - total: 2_838_153_758_908, - glm: 0, - dsf: 1_336_625_283_800, - dsp: 676_480_415_730, - mm: 305_268_829_013, - others: 519_779_230_365, - }, - { - date: "Jun 17", - total: 2_778_964_711_109, - glm: 70_095_977_043, - dsf: 1_339_831_523_555, - dsp: 660_414_395_220, - mm: 251_302_096_157, - others: 457_320_719_134, - }, - { - date: "Jun 18", - total: 2_806_992_430_656, - glm: 201_130_231_172, - dsf: 1_295_599_996_869, - dsp: 595_665_008_776, - mm: 322_205_104_324, - others: 392_392_089_515, - }, - { - date: "Jun 19", - total: 2_419_611_630_232, - glm: 199_086_413_910, - dsf: 1_115_750_468_802, - dsp: 475_965_869_304, - mm: 303_586_698_735, - others: 325_222_179_481, - }, - { - date: "Jun 20", - total: 2_188_278_916_865, - glm: 193_931_516_396, - dsf: 1_050_194_681_012, - dsp: 395_303_435_278, - mm: 281_998_000_337, - others: 266_851_283_842, - }, - { - date: "Jun 21", - total: 2_042_309_961_344, - glm: 181_894_043_118, - dsf: 985_164_570_580, - dsp: 368_194_079_542, - mm: 259_812_551_324, - others: 247_244_716_780, - }, - { - date: "Jun 22", - total: 2_893_934_325_663, - glm: 301_759_048_475, - dsf: 1_298_124_282_989, - dsp: 581_012_596_194, - mm: 371_581_117_839, - others: 341_457_280_166, - }, - { - date: "Jun 23", - total: 3_109_009_321_480, - glm: 282_277_235_158, - dsf: 1_423_571_678_821, - dsp: 627_374_654_587, - mm: 429_416_300_508, - others: 346_369_452_406, - }, - { - date: "Jun 24", - total: 2_939_149_971_595, - glm: 256_497_442_533, - dsf: 1_373_583_023_234, - dsp: 601_270_997_775, - mm: 391_586_493_231, - others: 316_212_014_822, - }, - { - date: "Jun 25", - total: 3_029_641_552_948, - glm: 256_279_657_734, - dsf: 1_481_084_002_776, - dsp: 602_077_167_287, - mm: 375_985_302_874, - others: 314_215_422_277, - }, -] - -export const launchIndex = 5 // Jun 17, first day of GLM-5.2 usage -// GLM-5.2 weekly token volume, Jun 19-25 (sum of glm): 1,671,725,357,324 = 1.67T -export const glmWeekTokensT = 1.672 - -// stacked segments, bottom -> top. GLM-5.2 is the hero (blue); the rest are the field it cut into. -export const segments = [ - { key: "glm", label: "GLM-5.2", color: "#3b5cf6", hero: true }, - { key: "dsf", label: "deepseek-v4-flash", color: "#9ca3ad" }, - { key: "dsp", label: "deepseek-v4-pro", color: "#b3b9c1" }, - { key: "mm", label: "minimax-m3", color: "#c8cdd3" }, - { key: "others", label: "other models", color: "#dde0e4" }, -] as const diff --git a/artifacts/glm52-rise-video/src/flash.tsx b/artifacts/glm52-rise-video/src/flash.tsx deleted file mode 100644 index 5d7aaef9bf..0000000000 --- a/artifacts/glm52-rise-video/src/flash.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import React from "react" -import { AbsoluteFill, Easing, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion" - -// stats.opencode.ai design tokens (light theme) -const c = { - bg: "#ffffff", - ink: "#161616", - muted: "#5c5c5c", - faint: "#808080", - line: "#e6e6e6", - dot: "#e4e4e4", - gray: "#aab0b8", - accent: "#3b5cf6", - accentHi: "#5b78ff", -} -const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' - -const DOT_MASK = - "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")" - -// verified: OpenCode Go, week of Jun 22-28, 2026 (2026-W26). share = % of 19.64T total Go tokens. -const bars = [ - { label: "deepseek-v4-flash", share: 48.3, hero: true }, - { label: "deepseek-v4-pro", share: 19.4 }, - { label: "minimax-m3", share: 13.0 }, - { label: "glm-5.2", share: 8.3 }, - { label: "mimo-v2.5", share: 4.3 }, - { label: "kimi-k2.7-code", share: 2.6 }, - { label: "other models", share: 4.1 }, -] - -function DataWordmark({ height = 30 }: { height?: number }) { - return ( - - - - - - - - - ) -} - -export function FlashShare() { - const frame = useCurrentFrame() - const { fps } = useVideoConfig() - - const grow = (i: number) => - Math.min( - 1, - Math.max(0, spring({ frame: frame - 18 - i * 7, fps, config: { damping: 18, stiffness: 120, mass: 0.6 } })), - ) - - return ( - -
- {/* header */} -
- -
JUN 22–28, 2026
-
- - {/* headline (static) */} -
-
- OPENCODE GO · SHARE OF TOKENS -
-
-
DeepSeek V4 Flash
-
- 48% -
-
-
- - {/* bar chart */} -
- {bars.map((b, i) => { - const g = grow(i) - const pct = b.share * g - return ( -
-
- {b.label} -
-
- {/* dotted 100% track — height is a multiple of the 12px tile, anchored bottom, so dots never clip */} -
- {/* fill */} -
-
-
- {Math.round(pct)}% -
-
- ) - })} -
- - {/* footer */} -
-
- - DeepSeek V4 Flash · 9.48T tokens · 83.6M requests -
-
opencode.ai/data
-
-
- - ) -} diff --git a/artifacts/glm52-rise-video/src/index.tsx b/artifacts/glm52-rise-video/src/index.tsx deleted file mode 100644 index 3265151ac5..0000000000 --- a/artifacts/glm52-rise-video/src/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Composition, registerRoot } from "remotion" -import { GLM52Rise } from "./video" -import { NZSheep } from "./sheep" -import { NovelTokens } from "./novel" -import { FlashShare } from "./flash" -import { MiniMaxClimb } from "./minimax" -import { JuneTotals } from "./june" - -function Root() { - return ( - <> - - - - - - - - ) -} - -registerRoot(Root) diff --git a/artifacts/glm52-rise-video/src/june.tsx b/artifacts/glm52-rise-video/src/june.tsx deleted file mode 100644 index aa3f9de9be..0000000000 --- a/artifacts/glm52-rise-video/src/june.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import React from "react" -import { AbsoluteFill } from "remotion" - -const c = { - bg: "#ffffff", - ink: "#161616", - muted: "#5c5c5c", - faint: "#808080", - line: "#e6e6e6", - dot: "#dcdcdc", - accent: "#3b5cf6", -} -const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' - -const DOT_MASK = - "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")" - -// verified: OpenCode Go (tier=Go, dataset=zen), June 1-30, 2026. -// 72.78T tokens · 651.4M requests · 11.42M sessions -> rounded headline figures. - -function DataWordmark({ height = 30 }: { height?: number }) { - return ( - - - - - - - - - ) -} - -function DotBand() { - return ( -
- ) -} - -function Metric({ value, label }: { value: string; label: string }) { - return ( -
-
- {value} -
-
{label}
-
- ) -} - -export function JuneTotals() { - return ( - -
- {/* header */} -
- -
MONTHLY RECAP
-
- - - {/* hero */} -
-
OPENCODE GO · JUNE 2026
-
-
- 73T -
-
tokens processed
-
- - {/* supporting metrics */} -
- - -
-
- - {/* footer */} - -
-
opencode.ai/data
-
-
-
- ) -} diff --git a/artifacts/glm52-rise-video/src/minimax.tsx b/artifacts/glm52-rise-video/src/minimax.tsx deleted file mode 100644 index 44cf09a1e7..0000000000 --- a/artifacts/glm52-rise-video/src/minimax.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import React from "react" -import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion" - -const c = { - bg: "#ffffff", - ink: "#161616", - muted: "#5c5c5c", - faint: "#808080", - line: "#e6e6e6", - dot: "#e4e4e4", - accent: "#3b5cf6", - accentHi: "#5b78ff", - accentDim: "#aebcf3", -} -const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' - -const DOT_MASK = - "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")" - -// verified: minimax-m3, OpenCode Go (tier=Go, dataset=zen), weekly total_tokens. -// W26 2.559T is +23.2% / +482.3B vs W25 2.077T. -const weeks = [ - { label: "May 25", t: 0.008 }, - { label: "Jun 1", t: 0.429 }, - { label: "Jun 8", t: 1.192 }, - { label: "Jun 15", t: 2.077 }, - { label: "Jun 22", t: 2.559, latest: true }, -] -const AXIS_MAX = 3.0 - -function DataWordmark({ height = 30 }: { height?: number }) { - return ( - - - - - - - - - ) -} - -const CHART_H = 420 // multiple of 12 so the dotted tracks never clip - -export function MiniMaxClimb() { - const frame = useCurrentFrame() - const { fps } = useVideoConfig() - - const grow = (i: number) => - Math.min( - 1, - Math.max(0, spring({ frame: frame - 22 - i * 9, fps, config: { damping: 18, stiffness: 110, mass: 0.6 } })), - ) - - return ( - -
- {/* header */} -
- -
JUN 22–28, 2026
-
- - {/* headline (static) */} -
-
- OPENCODE GO · WEEKLY TOKENS -
-
-
MiniMax M3
-
-
- +23.2% -
-
- WEEK OVER WEEK -
-
-
-
- - {/* column chart */} -
-
- {weeks.map((w, i) => { - const g = grow(i) - const h = Math.round((w.t / AXIS_MAX) * CHART_H * g) - return ( -
- {/* value + bar */} -
- {/* dotted track */} -
- {/* fill */} -
- {/* value label */} -
- {w.t.toFixed(2)}T -
-
- {/* week label */} -
- {w.label} -
-
- ) - })} -
-
- - {/* footer */} -
-
- - 2.56T tokens last week · +482.3B added -
-
opencode.ai/data
-
-
- - ) -} diff --git a/artifacts/glm52-rise-video/src/novel.tsx b/artifacts/glm52-rise-video/src/novel.tsx deleted file mode 100644 index 9d649de4bb..0000000000 --- a/artifacts/glm52-rise-video/src/novel.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import React from "react" -import { AbsoluteFill, Easing, Img, interpolate, staticFile, useCurrentFrame } from "remotion" - -const c = { - white: "#ffffff", - dim: "rgba(255,255,255,0.74)", -} -const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' - -// verified: OpenCode Go, week of Jun 22-28, 2026 (2026-W26) -// 19,642,742,937,105 tokens / 173,651,197 requests = 113,116 tokens/request -const AVG = 113116 -const K = Math.round(AVG / 1000) // 113 - -const nf = new Intl.NumberFormat("en-US") - -function DataWordmark({ height = 30 }: { height?: number }) { - return ( - - - - - - - - - ) -} - -export function NovelTokens() { - const frame = useCurrentFrame() - - const k = Math.round( - K * - interpolate(frame, [18, 92], [0, 1], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - easing: Easing.out(Easing.cubic), - }), - ) - - const zoom = interpolate(frame, [0, 150], [1.06, 1.12], { - extrapolateRight: "clamp", - easing: Easing.inOut(Easing.quad), - }) - - return ( - - - - {/* legibility scrims */} -
- -
- {/* header */} -
- -
JUN 22–28, 2026
-
- - {/* bottom block */} -
-
- OPENCODE GO · LAST WEEK -
-
- {k}K -
-
tokens per request
- -
-
{nf.format(AVG)} tokens / request · last week
-
opencode.ai/data
-
-
-
- - ) -} diff --git a/artifacts/glm52-rise-video/src/sheep.tsx b/artifacts/glm52-rise-video/src/sheep.tsx deleted file mode 100644 index 53abb39ee5..0000000000 --- a/artifacts/glm52-rise-video/src/sheep.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React from "react" -import { AbsoluteFill, Easing, Img, interpolate, staticFile, useCurrentFrame } from "remotion" - -const c = { - white: "#ffffff", - dim: "rgba(255,255,255,0.72)", - faint: "rgba(255,255,255,0.55)", -} -const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' - -// verified: NZ OpenCode Go, week of Jun 22-28, 2026 (2026-W26) -const TOKENS = 40_915_594_381 // 40.9B -const SHEEP = 23_600_000 // 23.6M -const PER_SHEEP = Math.round(TOKENS / SHEEP) // 1,734 -const nf = new Intl.NumberFormat("en-US") - -// the correct opencode "DATA" wordmark (white, over photo) -function DataWordmark({ height = 30 }: { height?: number }) { - return ( - - - - - - - - - ) -} - -export function NZSheep() { - const frame = useCurrentFrame() - - const count = Math.round( - PER_SHEEP * - interpolate(frame, [18, 90], [0, 1], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - easing: Easing.out(Easing.cubic), - }), - ) - - // slow Ken Burns push-in (scale up only — never reveals an edge) - const zoom = interpolate(frame, [0, 150], [1.06, 1.12], { - extrapolateRight: "clamp", - easing: Easing.inOut(Easing.quad), - }) - - return ( - - {/* the sheep, staring */} - - - {/* legibility scrims */} -
- - {/* content */} -
- {/* header */} -
- -
JUN 22–28, 2026
-
- - {/* bottom block */} -
-
- OPENCODE GO · NEW ZEALAND -
-
- {nf.format(count)} -
-
tokens per sheep
- -
-
40.9B tokens ÷ 23.6M sheep · last week
-
opencode.ai/data
-
-
-
- - ) -} diff --git a/artifacts/glm52-rise-video/src/video.tsx b/artifacts/glm52-rise-video/src/video.tsx deleted file mode 100644 index 41466a7e6c..0000000000 --- a/artifacts/glm52-rise-video/src/video.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import React from "react" -import { AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig } from "remotion" -import { days, launchIndex, glmWeekTokensT, segments } from "./data" - -// stats.opencode.ai design tokens (light theme) -const c = { - bg: "#ffffff", - ink: "#161616", - muted: "#5c5c5c", - faint: "#808080", - line: "#e6e6e6", - dot: "#ededed", - accent: "#3b5cf6", - accentHi: "#5b78ff", -} - -const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' -const W = 1080 - -const DOT_MASK = - "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E\")" - -const field = segments.filter((s) => !s.hero) -const glmColor = segments.find((s) => s.hero)!.color - -const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)) - -// the correct opencode "DATA" wordmark (from stats.opencode.ai header) -function DataWordmark({ height = 30, color = c.ink }: { height?: number; color?: string }) { - return ( - - - - - - - - - ) -} - -export function GLM52Rise() { - const frame = useCurrentFrame() - const { fps } = useVideoConfig() - - // ---------- virtual camera ---------- - // open zoomed-in on the field, pan right while the blue fills, then pull back to reveal. - const K = [0, 32, 150, 206, 240] - const ease = Easing.inOut(Easing.cubic) - const opt = { extrapolateLeft: "clamp" as const, extrapolateRight: "clamp" as const, easing: ease } - const s = interpolate(frame, K, [1.82, 1.72, 1.72, 1.0, 1.0], opt) - let fx = interpolate(frame, K, [420, 438, 760, 540, 540], opt) - let fy = interpolate(frame, K, [664, 664, 664, 540, 540], opt) - // keep the framing inside the 1080 canvas so edges never reveal black - fx = clamp(fx, 540 / s, W - 540 / s) - fy = clamp(fy, 540 / s, W - 540 / s) - const camera = `translate(${540 - fx * s}px, ${540 - fy * s}px) scale(${s})` - - // ---------- blue sweep (synced to the pan) ---------- - const p = interpolate(frame, [32, 150], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: ease }) - const revealAmount = p * (days.length - launchIndex) + 0.35 - const fillOf = (i: number) => clamp(revealAmount - (i - launchIndex), 0, 1) - - // token number climbs as the camera pulls back and the headline re-enters frame - const tokensT = - glmWeekTokensT * - interpolate(frame, [150, 202], [0, 1], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - easing: Easing.out(Easing.cubic), - }) - - // chart geometry - const chartH = 440 - const chartW = 936 - const gap = 14 - const EXAGGERATE = 2.876 // broken y-axis: GLM-5.2 magnified ~2.9x for emphasis - - return ( - -
-
- {/* header */} -
- -
JUN 12–25, 2026
-
- - {/* headline (static) */} -
-
- GLM-5.2 broke out -
-
- From 0 to {tokensT.toFixed(2)}T tokens in a week. -
-
- - {/* stacked chart */} -
-
- {/* faint dotted backdrop */} -
- - {/* columns */} -
- {days.map((d, i) => { - const glmShare = d.glm / d.total - const blueH = Math.round(glmShare * EXAGGERATE * chartH) - const filled = Math.round(blueH * fillOf(i)) - const slotGap = filled > 2 ? 5 : 0 - const fieldH = chartH - filled - slotGap - const fieldTotal = d.dsf + d.dsp + d.mm + d.others - return ( -
- {/* gray field of other models (already in place) */} -
- {field.map((seg) => ( -
- ))} -
- {/* GLM-5.2, animates in */} - {filled > 2 && ( -
- )} -
- ) - })} -
-
-
- - {/* day axis */} -
- {days.map((d, i) => ( -
- {i === 0 || i === launchIndex || i === days.length - 1 ? d.date : ""} -
- ))} -
- - {/* footer */} -
-
- - GLM-5.2 -
-
opencode.ai/data
-
-
-
- - ) -} diff --git a/artifacts/glm52-rise-video/sst-env.d.ts b/artifacts/glm52-rise-video/sst-env.d.ts deleted file mode 100644 index 64441936d7..0000000000 --- a/artifacts/glm52-rise-video/sst-env.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* This file is auto-generated by SST. Do not edit. */ -/* tslint:disable */ -/* eslint-disable */ -/* deno-fmt-ignore-file */ -/* biome-ignore-all lint: auto-generated */ - -/// - -import "sst" -export {} \ No newline at end of file diff --git a/bun.lock b/bun.lock index e905ada8c5..ae547d8692 100644 --- a/bun.lock +++ b/bun.lock @@ -24,22 +24,19 @@ "prettier": "3.6.2", "semver": "^7.6.0", "sst": "catalog:", - "turbo": "2.10.2", + "turbo": "2.8.13", }, }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { - "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", "@dnd-kit/dom": "0.5.0", "@dnd-kit/helpers": "0.5.0", "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", - "@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz", "@opencode-ai/core": "workspace:*", - "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -66,7 +63,7 @@ "diff": "catalog:", "effect": "catalog:", "fuzzysort": "catalog:", - "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", + "ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553", "luxon": "catalog:", "marked": "catalog:", "marked-shiki": "catalog:", @@ -74,7 +71,6 @@ "shiki": "catalog:", "solid-js": "catalog:", "solid-list": "catalog:", - "solid-presence": "0.2.0", "tailwindcss": "catalog:", }, "devDependencies": { @@ -96,12 +92,13 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.18.11", + "version": "1.17.13", "bin": { - "lildax": "./bin/lildax.cjs", + "opencode2": "./bin/opencode2.cjs", }, "dependencies": { "@effect/platform-node": "catalog:", + "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/server": "workspace:*", @@ -110,12 +107,15 @@ "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "effect": "catalog:", + "jsonc-parser": "3.3.1", + "semver": "catalog:", "solid-js": "catalog:", }, "devDependencies": { "@opencode-ai/script": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", + "@types/semver": "catalog:", "@typescript/native-preview": "catalog:", }, }, @@ -142,23 +142,9 @@ "effect", ], }, - "packages/codemode": { - "name": "@opencode-ai/codemode", - "version": "1.18.11", - "dependencies": { - "acorn": "8.15.0", - "effect": "catalog:", - "typescript": "catalog:", - }, - "devDependencies": { - "@tsconfig/bun": "catalog:", - "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:", - }, - }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -194,7 +180,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -221,7 +207,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -243,7 +229,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -267,7 +253,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -287,7 +273,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.18.11", + "version": "1.17.13", "bin": { "opencode": "./bin/opencode", }, @@ -295,7 +281,7 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.88", + "@ai-sdk/azure": "3.0.49", "@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", @@ -303,21 +289,22 @@ "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", - "@ai-sdk/mistral": "3.0.51", - "@ai-sdk/openai": "3.0.84", + "@ai-sdk/mistral": "3.0.27", + "@ai-sdk/openai": "3.0.53", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", - "@ai-sdk/xai": "3.0.102", + "@ai-sdk/xai": "3.0.82", "@aws-sdk/credential-providers": "3.1057.0", "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@ff-labs/fff-bun": "0.9.4", "@lydell/node-pty": "catalog:", + "@modelcontextprotocol/sdk": "1.29.0", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", "@opencode-ai/effect-drizzle-sqlite": "workspace:*", @@ -339,7 +326,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.12.1", + "gitlab-ai-provider": "6.10.0", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -381,16 +368,16 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "@zip.js/zip.js": "2.7.62", - "drizzle-orm": "catalog:", "effect": "catalog:", "electron-context-menu": "4.1.2", "electron-log": "^5", "electron-store": "11.0.2", "electron-updater": "6.8.9", "electron-window-state": "^5.0.3", + "marked": "^15", }, "devDependencies": { "@actions/artifact": "4.0.0", @@ -435,7 +422,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -449,7 +436,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "effect": "catalog:", }, @@ -461,7 +448,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -493,7 +480,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -509,7 +496,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -540,7 +527,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -559,7 +546,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.18.11", + "version": "1.17.13", "bin": { "opencode": "./bin/opencode", }, @@ -570,22 +557,22 @@ "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", "@ai-sdk/anthropic": "3.0.82", - "@ai-sdk/azure": "3.0.88", - "@ai-sdk/cerebras": "2.0.60", + "@ai-sdk/azure": "3.0.49", + "@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/gateway": "3.0.104", "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", - "@ai-sdk/mistral": "3.0.51", - "@ai-sdk/openai": "3.0.84", + "@ai-sdk/mistral": "3.0.27", + "@ai-sdk/openai": "3.0.53", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", "@ai-sdk/provider": "3.0.8", "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", - "@ai-sdk/xai": "3.0.102", + "@ai-sdk/xai": "3.0.82", "@aws-sdk/credential-providers": "3.1057.0", "@clack/prompts": "1.0.0-alpha.1", "@effect/opentelemetry": "catalog:", @@ -596,7 +583,7 @@ "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", - "@opencode-ai/codemode": "workspace:*", + "@opencode-ai/client": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -632,7 +619,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.12.1", + "gitlab-ai-provider": "6.10.0", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -690,9 +677,12 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "@ai-sdk/provider": "3.0.8", + "@opencode-ai/llm": "workspace:*", + "@opencode-ai/protocol": "workspace:*", + "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", "zod": "catalog:", @@ -701,15 +691,16 @@ "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", + "@tsconfig/bun": "catalog:", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.4.5", - "@opentui/keymap": ">=0.4.5", - "@opentui/solid": ">=0.4.5", + "@opentui/core": ">=0.3.4", + "@opentui/keymap": ">=0.3.4", + "@opentui/solid": ">=0.3.4", }, "optionalPeers": [ "@opentui/core", @@ -719,6 +710,7 @@ }, "packages/protocol": { "name": "@opencode-ai/protocol", + "version": "1.17.11", "dependencies": { "@opencode-ai/schema": "workspace:*", "effect": "catalog:", @@ -727,10 +719,12 @@ "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", + "typescript": "catalog:", }, }, "packages/schema": { "name": "@opencode-ai/schema", + "version": "1.17.11", "dependencies": { "effect": "catalog:", }, @@ -738,6 +732,7 @@ "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", + "typescript": "catalog:", }, }, "packages/script": { @@ -755,6 +750,7 @@ "dependencies": { "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", + "@opencode-ai/plugin": "workspace:*", "@opencode-ai/server": "workspace:*", "effect": "catalog:", }, @@ -766,7 +762,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "cross-spawn": "catalog:", }, @@ -781,7 +777,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -796,10 +792,9 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "@kobalte/core": "catalog:", - "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -811,6 +806,7 @@ "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", "@solidjs/meta": "catalog:", + "@solidjs/router": "catalog:", "diff": "catalog:", "dompurify": "3.3.1", "fuzzysort": "catalog:", @@ -840,7 +836,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -853,10 +849,9 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "@ibm/plex": "6.4.1", - "@kobalte/core": "catalog:", "@opencode-ai/stats-core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -887,7 +882,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -906,7 +901,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -938,7 +933,6 @@ "@types/node": "catalog:", "@types/react": "18.0.25", "react": "18.2.0", - "react-dom": "18.2.0", "solid-js": "catalog:", "storybook": "^10.2.13", "storybook-solidjs-vite": "^10.0.9", @@ -948,8 +942,9 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { + "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/sdk": "workspace:*", @@ -957,6 +952,7 @@ "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", + "@solid-primitives/event-bus": "1.1.2", "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", @@ -975,7 +971,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1001,7 +997,6 @@ "remend": "catalog:", "shiki": "catalog:", "solid-list": "catalog:", - "solid-sonner": "catalog:", "strip-ansi": "7.1.2", }, "devDependencies": { @@ -1027,7 +1022,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.18.11", + "version": "1.17.13", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1070,19 +1065,18 @@ ], "patchedDependencies": { "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", - "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", - "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", - "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", - "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", + "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", + "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", - "@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch", }, "overrides": { "@opentui/core": "catalog:", @@ -1093,7 +1087,6 @@ }, "catalog": { "@cloudflare/workers-types": "4.20251008.0", - "@corvu/drawer": "0.2.4", "@effect/opentelemetry": "4.0.0-beta.83", "@effect/platform-node": "4.0.0-beta.83", "@effect/sql-sqlite-bun": "4.0.0-beta.83", @@ -1104,9 +1097,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.4.5", - "@opentui/keymap": "0.4.5", - "@opentui/solid": "0.4.5", + "@opentui/core": "0.3.4", + "@opentui/keymap": "0.3.4", + "@opentui/solid": "0.3.4", "@pierre/diffs": "1.2.10", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1117,7 +1110,7 @@ "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@tailwindcss/vite": "4.1.11", - "@tanstack/solid-virtual": "3.13.32", + "@tanstack/solid-virtual": "3.13.28", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", "@types/bun": "1.3.13", @@ -1137,7 +1130,7 @@ "hono": "4.10.7", "hono-openapi": "1.1.2", "luxon": "3.6.1", - "marked": "17.0.6", + "marked": "17.0.1", "marked-shiki": "1.2.1", "opentui-spinner": "0.0.7", "remeda": "2.26.0", @@ -1146,7 +1139,6 @@ "shiki": "4.2.0", "solid-js": "1.9.10", "solid-list": "0.3.0", - "solid-sonner": "0.3.1", "sst": "4.13.1", "tailwindcss": "4.1.11", "typescript": "5.8.2", @@ -1180,7 +1172,7 @@ "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="], - "@ai-sdk/azure": ["@ai-sdk/azure@3.0.88", "", { "dependencies": { "@ai-sdk/deepseek": "2.0.47", "@ai-sdk/openai": "3.0.84", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RRjZkB1lYplh8dpBarnvkl1j7sYLHsyXua7erL3oNcMK7fHcl4bPO5C7iQhD1O/DqD/zCceDifnege1s+8yEvw=="], + "@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="], "@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="], @@ -1190,7 +1182,7 @@ "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="], - "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="], + "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="], "@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EtvsWfGrqx3OhzJdoi82qH+4yzEPPKZr2utyQ+w8cHKoFeg0+8Lou9Z3uixy73WEwz8Z1+AR8QT9fZ64AWGYPA=="], @@ -1204,7 +1196,7 @@ "@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="], - "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="], + "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="], "@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], @@ -1220,7 +1212,7 @@ "@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="], - "@ai-sdk/xai": ["@ai-sdk/xai@3.0.102", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.56", "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NeQyOR7OCqDMgaLS4uNX/ep/HrwUzzFYLzXQSRoqLy2jsnqxAJhsgltRwAwf+ADjyPBIAKEOestWnIQA+LrLrQ=="], + "@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -1476,10 +1468,6 @@ "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="], - "@corvu/dialog": ["@corvu/dialog@0.2.4", "", { "dependencies": { "@corvu/utils": "~0.4.2", "solid-dismissible": "~0.1.1", "solid-focus-trap": "~0.1.8", "solid-presence": "~0.2.0", "solid-prevent-scroll": "~0.1.10" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-n54vJq+fOy8GVrnYBdJpD6JXNuyx7LOeMrRxwzAvZnYGpW8+AA12tnb/P/2emJj/HjOO5otheGKb0breshdFlA=="], - - "@corvu/drawer": ["@corvu/drawer@0.2.4", "", { "dependencies": { "@corvu/dialog": "~0.2.4", "@corvu/utils": "~0.4.2", "@solid-primitives/memo": "^1.4.1", "solid-transition-size": "~0.1.4" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-7jQoGZ8ROB9CmXam2nMY2wEskU3IoFwZQywkF/7vrBc/edGsPv7mOVQ1GN6G+4nd7nrMZ3UtqHAhmRh9V0azlw=="], - "@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -1952,8 +1940,6 @@ "@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"], - "@opencode-ai/codemode": ["@opencode-ai/codemode@workspace:packages/codemode"], - "@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"], "@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"], @@ -2044,27 +2030,27 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="], + "@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="], - "@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="], + "@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="], - "@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="], + "@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -2680,8 +2666,6 @@ "@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="], - "@solid-primitives/memo": ["@solid-primitives/memo@1.5.0", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nSMHpFdnOMP88t7lqtktUXJlhQdJk0BQs2v3jUqcn+OtFwUm27Oa/coKl1BHDxL/65jR5drjqxxvuhaIpiUi0w=="], - "@solid-primitives/props": ["@solid-primitives/props@3.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-XzG6en9gSFwmvbKcATm2BxL63HegZ+BAG5fmHi8jyBppQHcaths7ffz+6vYvwYy3nlgLa20ufJLj7tst+PcHFA=="], "@solid-primitives/refs": ["@solid-primitives/refs@1.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA=="], @@ -2786,9 +2770,9 @@ "@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="], - "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.32", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-yhX4A4Kgn+wyTg6Mmu8+zwoMTwjz4K1ucvLfRJ8f0rPGDDAIqSaf0v6oU0yT9+SvrjmUaZQ0VX7g4byexbhNng=="], + "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], @@ -2806,18 +2790,6 @@ "@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="], - "@turbo/darwin-64": ["@turbo/darwin-64@2.10.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-wBM3ObqOWnKUDmg7QfUFDkDHPFUAJmrYlYqmEM8jMPAPA/I6wRJIbWimeQUqhOiQ8xPKhzyWM+xaiUP0wz8FEQ=="], - - "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/Cq0joWnuMjDPfhjbFP4sv+C/7gkQ415zlaO4XUzD5EZxbtrKgXKvuuydMvogG8GeUnN1aDltW71RlmEfpjbyw=="], - - "@turbo/linux-64": ["@turbo/linux-64@2.10.2", "", { "os": "linux", "cpu": "x64" }, "sha512-mMsf5IIhiKuceEXNstd25IbadjBXZ0amxzFOqliEzJX6HyeeHdBQPVSY583PWqYDyqM/FB8d5ZjkthfBSeuH3Q=="], - - "@turbo/linux-arm64": ["@turbo/linux-arm64@2.10.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Wcng1i2kaKmXutmwxT9MUoYZvdaIekXAdlGr4+0TpgbhGLw7nDuEcRBFrxb5BbRoX1d1q8SpdRxLc45TvDZIdQ=="], - - "@turbo/windows-64": ["@turbo/windows-64@2.10.2", "", { "os": "win32", "cpu": "x64" }, "sha512-SsNhM7Ho7EpAdwtrJKBOic9Hso23vu6Dp0gAfLOvUFjPzurr/sGQlXZEvr6z89ne4RDOypTwz5CBDrixpMKtXw=="], - - "@turbo/windows-arm64": ["@turbo/windows-arm64@2.10.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gf+S7ICAdimT/n02bOuVWKvhHnct/HYjZg3oBNIz5hZ9ZyWHbQim9J3P5Qip8WpX0ksxF7eaBVziJCuLnjhqDg=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], @@ -3052,9 +3024,9 @@ "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=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -3222,7 +3194,7 @@ "bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="], - "body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="], @@ -3258,7 +3230,7 @@ "builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="], - "bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="], + "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], @@ -3390,7 +3362,7 @@ "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], - "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -3400,7 +3372,7 @@ "cookie-es": ["cookie-es@2.0.1", "", {}, "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA=="], - "cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="], + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], @@ -3708,7 +3680,7 @@ "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], - "express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], @@ -3766,7 +3738,7 @@ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - "finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], @@ -3804,7 +3776,7 @@ "framer-motion": ["framer-motion@8.5.5", "", { "dependencies": { "@motionone/dom": "^10.15.3", "hey-listen": "^1.0.8", "tslib": "^2.4.0" }, "optionalDependencies": { "@emotion/is-prop-valid": "^0.8.2" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-5IDx5bxkjWHWUF3CVJoSyUVOtrbAxtzYBBowRE2uYI/6VYhkEBD+rbTHEGuUmbGHRj6YqqSfoG7Aa1cLyWCrBA=="], - "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], @@ -3850,13 +3822,13 @@ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], - "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#83c0a07", {}, "anomalyco-ghostty-web-83c0a07", "sha512-Lf2v1agHkVUpMpHBWWuCZrhOEmcwwin5/Hboc9rZwQ7/CKkIh5rU1r1CvfLlhkMoFv+ed8z52RZ8hkzGZZj3MQ=="], + "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#513463a", {}, "anomalyco-ghostty-web-513463a", "sha512-GZR8LSmgGzViWnBJrqRI8MpAZRCJxhcr1Hi9Tyeh7YRooHZQjK9J97FQRD3tbBaM2wjq05gzGY2UEsG+JtZeBw=="], "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@6.12.1", "", { "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": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Qn5iHqvjG8yktI5MWaUgdRR94l7O4WtYW0CAbhsCh1Tj0Fei/DeprOYPVyf4Nht1Ix6U2PXSYM32QOHI6Z2TDw=="], + "gitlab-ai-provider": ["gitlab-ai-provider@6.10.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": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-oWEZ06rDO6JjB7INHO882wyBAQqCZVHiDHwCs5M+VPmdDj8TzhGXcYesA2CcV5RoI5lfHLKwGp5uKFB62VWpqw=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], @@ -4318,7 +4290,7 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], "marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="], @@ -4368,11 +4340,11 @@ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], - "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="], - "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], @@ -4858,7 +4830,7 @@ "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], @@ -5044,7 +5016,7 @@ "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], - "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="], @@ -5054,7 +5026,7 @@ "seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="], - "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=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], @@ -5114,26 +5086,18 @@ "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], - "solid-dismissible": ["solid-dismissible@0.1.1", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-9kcKBJIMdS+586cA1g63HYWxKh3h89leeNHbPZ1csYjuni+NvPBtNr11l0iEX2AKKEt6FHk6qNhc/gjoYAW1pA=="], - - "solid-focus-trap": ["solid-focus-trap@0.1.9", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-LTyNki6GUJPRLXV5uMWPkYClB07SUMubbr2EkAddiR0CJCF/I283txilMU9RURSr/P8EewMfXWu2o3aWrK7A5A=="], - "solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="], "solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="], - "solid-presence": ["solid-presence@0.2.0", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-YM92o+jvpzX3XGaD4rLYmq/Kc2ZVh47GSCLEufHBFQQIurvZTs8SoGJxO8BJGNDxBKdcS8F3dYhW1SDXp4BNjA=="], + "solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="], "solid-prevent-scroll": ["solid-prevent-scroll@0.1.10", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-KplGPX2GHiWJLZ6AXYRql4M127PdYzfwvLJJXMkO+CMb8Np4VxqDAg5S8jLdwlEuBis/ia9DKw2M8dFx5u8Mhw=="], "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], - "solid-sonner": ["solid-sonner@0.3.1", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-F/+zi9yKJTHh5hX1UGJfkDvyC+F34Vi3jgy44NJwOKCgic1QtAon0b1iT9OsDO77RTgR+PCil+3Y5B8T2Owy1Q=="], - "solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="], - "solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="], - "solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="], "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], @@ -5356,7 +5320,19 @@ "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], - "turbo": ["turbo@2.10.2", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.2", "@turbo/darwin-arm64": "2.10.2", "@turbo/linux-64": "2.10.2", "@turbo/linux-arm64": "2.10.2", "@turbo/windows-64": "2.10.2", "@turbo/windows-arm64": "2.10.2" }, "bin": { "turbo": "bin/turbo" } }, "sha512-wTExrNrRjB8qzIcg+ZLm0A3GFNLDsWNwdS/RBXB0FPrBDyzk3i96Yx+TxWZC7a0k1SIreFB8ciUbxjmEqTH8IQ=="], + "turbo": ["turbo@2.8.13", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.13", "turbo-darwin-arm64": "2.8.13", "turbo-linux-64": "2.8.13", "turbo-linux-arm64": "2.8.13", "turbo-windows-64": "2.8.13", "turbo-windows-arm64": "2.8.13" }, "bin": { "turbo": "bin/turbo" } }, "sha512-nyM99hwFB9/DHaFyKEqatdayGjsMNYsQ/XBNO6MITc7roncZetKb97MpHxWf3uiU+LB9c9HUlU3Jp2Ixei2k1A=="], + + "turbo-darwin-64": ["turbo-darwin-64@2.8.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-PmOvodQNiOj77+Zwoqku70vwVjKzL34RTNxxoARjp5RU5FOj/CGiC6vcDQhNtFPUOWSAaogHF5qIka9TBhX4XA=="], + + "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kI+anKcLIM4L8h+NsM7mtAUpElkCOxv5LgiQVQR8BASyDFfc8Efj5kCk3cqxuxOvIqx0sLfCX7atrHQ2kwuNJQ=="], + + "turbo-linux-64": ["turbo-linux-64@2.8.13", "", { "os": "linux", "cpu": "x64" }, "sha512-j29KnQhHyzdzgCykBFeBqUPS4Wj7lWMnZ8CHqytlYDap4Jy70l4RNG46pOL9+lGu6DepK2s1rE86zQfo0IOdPw=="], + + "turbo-linux-arm64": ["turbo-linux-arm64@2.8.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-OEl1YocXGZDRDh28doOUn49QwNe82kXljO1HXApjU0LapkDiGpfl3jkAlPKxEkGDSYWc8MH5Ll8S16Rf5tEBYg=="], + + "turbo-windows-64": ["turbo-windows-64@2.8.13", "", { "os": "win32", "cpu": "x64" }, "sha512-717bVk1+Pn2Jody7OmWludhEirEe0okoj1NpRbSm5kVZz/yNN/jfjbxWC6ilimXMz7xoMT3IDfQFJsFR3PMANA=="], + + "turbo-windows-arm64": ["turbo-windows-arm64@2.8.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-R819HShLIT0Wj6zWVnIsYvSNtRNj1q9VIyaUz0P24SMcLCbQZIm1sV09F4SDbg+KCCumqD2lcaR2UViQ8SnUJA=="], "turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="], @@ -5366,7 +5342,7 @@ "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], - "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], @@ -5614,6 +5590,8 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], @@ -5662,11 +5640,7 @@ "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - "@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], - - "@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - - "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], + "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], @@ -5678,9 +5652,9 @@ "@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], + "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], "@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], @@ -5706,9 +5680,7 @@ "@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - - "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="], + "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], @@ -5722,11 +5694,7 @@ "@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cQrN6OUn/jvsY3OdsU6Wn+ss7vp1iwIcakZKSlSRMnYqShBfyT7Qht+eqmgxs7w9ttrw6FAG6o11AiBs+iEsTA=="], - - "@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.13", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-ZPtVYt5QIJzOta1kdUiDuCx4HhFkvNPv/rvmZ2b1iXwybYjJsCnNYR4PAw4kW7rgVfDARvHXcU64efWuqNp6bw=="], - - "@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bjYld/2KGPLt78kpqbya+fD4LYS7BqVQJyUjE3qAHrYB0FR2Q90BaWEVIBZaguTWXf/A8L6uG1zO1v9TxVlGWg=="], + "@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -5740,8 +5708,6 @@ "@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="], - "@astrojs/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - "@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "@astrojs/sitemap/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], @@ -5950,22 +5916,14 @@ "@jsx-email/doiuse-email/htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="], - "@kobalte/core/solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="], - "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], - "@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], - "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - "@modelcontextprotocol/sdk/hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="], "@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], - "@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - "@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], @@ -6034,9 +5992,9 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], - "@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="], + "@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], + "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], @@ -6044,6 +6002,8 @@ "@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], + "@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], "@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], @@ -6052,8 +6012,6 @@ "@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="], - "@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], @@ -6062,8 +6020,6 @@ "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], - "@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], - "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], @@ -6104,8 +6060,12 @@ "@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], + "@slack/bolt/express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="], + "@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "@slack/bolt/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], + "@slack/oauth/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="], "@slack/socket-mode/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="], @@ -6172,24 +6132,12 @@ "@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], - "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - - "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], - "ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.107", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@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-8nT08pGPy25rleJNk56ep00UHK6kCtCmu+ZNqVVSSPDieADlIZqcaN1iRXAFBoCH0Fb9F6C2EjFDaySdsargfQ=="], "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="], - "ai-gateway-provider/@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="], - - "ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="], - - "ai-gateway-provider/@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="], - "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], - "ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="], - "ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="], "ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], @@ -6218,8 +6166,6 @@ "astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="], - "astro/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - "astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="], "astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="], @@ -6240,10 +6186,6 @@ "babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], - "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - - "body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], - "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "builder-util/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -6310,8 +6252,6 @@ "engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], - "esast-util-from-js/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - "esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -6324,16 +6264,10 @@ "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - - "express/path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="], - "fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], - "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], @@ -6376,8 +6310,6 @@ "md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="], - "micromark-extension-mdxjs/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], @@ -6410,9 +6342,7 @@ "nypm/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], - "opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="], - - "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], + "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], @@ -6462,8 +6392,6 @@ "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], - "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -6472,10 +6400,6 @@ "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - - "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], - "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], "sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -6488,8 +6412,6 @@ "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], - "solid-transition-size/@corvu/utils": ["@corvu/utils@0.3.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.7" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ZWlyWEE8qV9+CB9OAyo2bTrZGXQN9ZeM+JfYv89zoR+lRACKTDuoOZEdiyL8Uc7U5dUSH1uTqKhTTnaHWb+wZA=="], - "sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="], "sst/aws4fetch": ["aws4fetch@1.0.18", "", {}, "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="], @@ -6508,8 +6430,6 @@ "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - "terser/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], @@ -6524,12 +6444,10 @@ "tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="], - "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], - "unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - "unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], @@ -6544,8 +6462,6 @@ "verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], - "vite-plugin-dynamic-import/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - "vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], "vitest/@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="], @@ -6626,8 +6542,6 @@ "@ai-sdk/vercel/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@ai-sdk/xai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "@astrojs/check/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -6800,28 +6714,6 @@ "@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - "@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - - "@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], - - "@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], - - "@modelcontextprotocol/sdk/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - - "@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - - "@modelcontextprotocol/sdk/express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - - "@modelcontextprotocol/sdk/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], - - "@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], - - "@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], - - "@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - - "@modelcontextprotocol/sdk/express/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], - "@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], "@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], @@ -6920,10 +6812,6 @@ "@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], - "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - - "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], - "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], @@ -6948,6 +6836,34 @@ "@shikijs/stream/@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], + "@slack/bolt/express/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + + "@slack/bolt/express/body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="], + + "@slack/bolt/express/content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], + + "@slack/bolt/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "@slack/bolt/express/cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="], + + "@slack/bolt/express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "@slack/bolt/express/finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="], + + "@slack/bolt/express/fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + + "@slack/bolt/express/merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], + + "@slack/bolt/express/path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="], + + "@slack/bolt/express/send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], + + "@slack/bolt/express/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=="], + + "@slack/bolt/express/type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], + + "@slack/bolt/raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + "@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], @@ -6970,8 +6886,6 @@ "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@storybook/csf-plugin/unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - "@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], @@ -6980,8 +6894,6 @@ "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], - "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], @@ -6994,18 +6906,6 @@ "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], - - "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - - "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - - "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], - - "ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - - "ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], - "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -7058,8 +6958,6 @@ "babel-plugin-module-resolver/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], @@ -7118,12 +7016,8 @@ "esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - "express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], - "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], @@ -7150,16 +7044,6 @@ "opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], - "opencode/@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.54", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OyXt0zK8y2/ZIyWlbxTv2r1M7AK227S+Gl4BYOEF42q0wz1n5m4fwR8L4Fy/MQ4Ho6xje47MPsFcRdIqIyP6Rw=="], - - "opencode/@ai-sdk/cerebras/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="], - - "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="], - - "opencode/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - - "opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], - "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], @@ -7170,8 +7054,6 @@ "rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "storybook/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -7186,8 +7068,6 @@ "tw-to-css/tailwindcss/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], - "type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], @@ -7380,10 +7260,6 @@ "@jsx-email/cli/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - - "@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - "@octokit/auth-app/@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/auth-app/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], @@ -7408,8 +7284,6 @@ "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], - "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], @@ -7418,6 +7292,20 @@ "@sentry/bundler-plugin-core/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "@slack/bolt/express/accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "@slack/bolt/express/accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + + "@slack/bolt/express/body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + + "@slack/bolt/express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "@slack/bolt/express/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + + "@slack/bolt/express/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + + "@slack/bolt/express/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "@slack/web-api/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], @@ -7426,12 +7314,6 @@ "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], @@ -7500,10 +7382,6 @@ "js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "opencode/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], @@ -7556,6 +7434,10 @@ "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@slack/bolt/express/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "@slack/bolt/express/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="], "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="], diff --git a/infra/console.ts b/infra/console.ts index 79556f5e0c..5fbf35de00 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -288,12 +288,8 @@ new sst.cloudflare.x.SolidStart("Console", { server: { placement: { region: "aws:us-east-2" }, transform: { - worker: (args) => { - args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [ - ...(flags ?? []), - "global_fetch_strictly_public", - ]) - args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }] + worker: { + tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }], }, }, }, diff --git a/infra/lake.ts b/infra/lake.ts index d4e46168e3..dd11ca38f9 100644 --- a/infra/lake.ts +++ b/infra/lake.ts @@ -67,10 +67,6 @@ const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", { configuration: { enforceWorkgroupConfiguration: true, publishCloudwatchMetricsEnabled: true, - // Athena bills $5/TB scanned; kill any query that would scan more than 2 TB - // so a regression cannot silently burn money. Stats sync full passes scan - // ~250 GB as of 2026-07. - bytesScannedCutoffPerQuery: 2 * 1024 ** 4, resultConfiguration: { outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`, }, diff --git a/infra/stage.ts b/infra/stage.ts index f0db797448..8d80eefed8 100644 --- a/infra/stage.ts +++ b/infra/stage.ts @@ -8,25 +8,6 @@ export const zoneID = "430ba34c138cfb5360826c4909f99be8" export const awsStage = $app.stage === "production" ? "production" : "dev" export const deployAws = $app.stage === awsStage -if ($app.stage === "production") { - new cloudflare.DnsRecord("TrustCenter", { - zoneId: zoneID, - name: "trust.opencode.ai", - type: "CNAME", - content: "3a69a5bb27875189.vercel-dns-016.com", - proxied: false, - ttl: 60, - }) - - new cloudflare.DnsRecord("TrustCenterVerification", { - zoneId: zoneID, - name: "opencode.ai", - type: "TXT", - content: "compai-domain-verification=org_6993a99c6200a2d642bb115d", - ttl: 60, - }) -} - new cloudflare.RegionalHostname("RegionalHostname", { hostname: domain, regionKey: "us", diff --git a/infra/stats.ts b/infra/stats.ts index 10d37119f0..b5b0e1c600 100644 --- a/infra/stats.ts +++ b/infra/stats.ts @@ -185,9 +185,7 @@ export const statSync = new sst.aws.Service("StatsSyncService", { cluster: lakeCluster, architecture: "arm64", cpu: "0.25 vCPU", - // 0.5 GB caused an OOM crash loop: every restart immediately re-ran the 4 Athena - // stats queries (~$5/pass) every ~5 minutes instead of hourly. - memory: "2 GB", + memory: "0.5 GB", image: { context: ".", dockerfile: "packages/stats/server/Dockerfile", diff --git a/nix/desktop.nix b/nix/desktop.nix index 2df62f7a1c..d0d7fa7eca 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -8,8 +8,6 @@ makeWrapper, writableTmpDirAsHomeHook, autoPatchelfHook, - copyDesktopItems, - makeDesktopItem, opencode, }: let @@ -29,12 +27,9 @@ stdenv.mkDerivation (finalAttrs: { nodejs makeWrapper writableTmpDirAsHomeHook - ] - ++ lib.optionals stdenv.hostPlatform.isLinux [ + ] ++ lib.optionals stdenv.hostPlatform.isLinux [ autoPatchelfHook - copyDesktopItems - ] - ++ lib.optionals stdenv.hostPlatform.isDarwin [ + ] ++ lib.optionals stdenv.hostPlatform.isDarwin [ # Ad-hoc sign the .app: --config.mac.identity=null below skips signing. darwin.autoSignDarwinBinariesHook ]; @@ -43,37 +38,20 @@ stdenv.mkDerivation (finalAttrs: { (lib.getLib stdenv.cc.cc) ]; - desktopItems = lib.optional stdenv.hostPlatform.isLinux (makeDesktopItem { - name = "ai.opencode.desktop"; - desktopName = "OpenCode"; - exec = "opencode-desktop %U"; - icon = "ai.opencode.desktop"; - # Electron 41 derives X11 WM_CLASS from app.name. - startupWMClass = "OpenCode"; - categories = [ "Development" ]; - }); - env = opencode.env // { ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; }; - postPatch = - # NOTE: Relax Bun version check to be a warning instead of an error - '' - substituteInPlace packages/script/src/index.ts \ - --replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \ - 'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}' - '' - # https://github.com/electron/electron/issues/31121 - # mac builds use a .app bundle which doesnt have this issue - + lib.optionalString stdenv.isLinux '' - BASE_PATH=packages/desktop - FILES=(src/main/windows.ts) - for file in "''${FILES[@]}"; do - substituteInPlace $BASE_PATH/$file \ - --replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'" - done - ''; + # https://github.com/electron/electron/issues/31121 + # mac builds use a .app bundle which doesnt have this issue + postPatch = lib.optionalString stdenv.isLinux '' + BASE_PATH=packages/desktop + FILES=(src/main/windows.ts) + for file in "''${FILES[@]}"; do + substituteInPlace $BASE_PATH/$file \ + --replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'" + done + ''; preBuild = '' cp -r "${electron.dist}" $HOME/.electron-dist @@ -98,38 +76,27 @@ stdenv.mkDerivation (finalAttrs: { runHook postBuild ''; - installPhase = '' - runHook preInstall - '' - + lib.optionalString stdenv.hostPlatform.isDarwin '' - mkdir -p $out/Applications - mv dist/mac*/*.app $out/Applications - makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop - '' - + lib.optionalString stdenv.hostPlatform.isLinux '' - mkdir -p $out/opt/opencode-desktop - cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop - install -Dm644 resources/icons/32x32.png \ - "$out/share/icons/hicolor/32x32/apps/ai.opencode.desktop.png" - install -Dm644 resources/icons/64x64.png \ - "$out/share/icons/hicolor/64x64/apps/ai.opencode.desktop.png" - install -Dm644 resources/icons/128x128.png \ - "$out/share/icons/hicolor/128x128/apps/ai.opencode.desktop.png" - install -Dm644 resources/icons/128x128@2x.png \ - "$out/share/icons/hicolor/256x256/apps/ai.opencode.desktop.png" - install -Dm644 resources/icons/icon.png \ - "$out/share/icons/hicolor/512x512/apps/ai.opencode.desktop.png" - install -Dm644 resources/ai.opencode.desktop.metainfo.xml \ - "$out/share/metainfo/ai.opencode.desktop.metainfo.xml" - makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \ - --inherit-argv0 \ - --set ELECTRON_FORCE_IS_PACKAGED 1 \ - --add-flags $out/opt/opencode-desktop/resources/app.asar \ - --add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}" - '' - + '' - runHook postInstall - ''; + installPhase = + '' + runHook preInstall + '' + + lib.optionalString stdenv.hostPlatform.isDarwin '' + mkdir -p $out/Applications + mv dist/mac*/*.app $out/Applications + makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop + '' + + lib.optionalString stdenv.hostPlatform.isLinux '' + mkdir -p $out/opt/opencode-desktop + cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop + makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \ + --inherit-argv0 \ + --set ELECTRON_FORCE_IS_PACKAGED 1 \ + --add-flags $out/opt/opencode-desktop/resources/app.asar \ + --add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}" + '' + + '' + runHook postInstall + ''; autoPatchelfIgnoreMissingDeps = [ "libc.musl-x86_64.so.1" diff --git a/nix/hashes.json b/nix/hashes.json index 25f0e76812..4571c20822 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-a7NyYa9vRUEqDfZNDPXXmFO58RDEgioyuGSl5CPBvxo=", - "aarch64-linux": "sha256-l4OJtSEllHvRhktjcaJYwkXBSaJvsIoyoLusbZfYMcM=", - "aarch64-darwin": "sha256-IIl0BQGs1/HLFh0auiQjiwfSQ2nfHcK2G2BAphYW59c=", - "x86_64-darwin": "sha256-vVeuPyd4ZIRYrHouphTuEb4rkRZLKKTAHe840jNh9rU=" + "x86_64-linux": "sha256-zjYaBa+TKuHVBO0z0YqOOFLOxpxj2hB6Iq1o+BXBeVc=", + "aarch64-linux": "sha256-S2QDDoKcg8QCqjxCnMmLp/3xmiVHAbCJ0CuHQRjDBbE=", + "aarch64-darwin": "sha256-GtdfHbk4fhvosgo6iEjXrWSxteK+eNrT0P9swEccbew=", + "x86_64-darwin": "sha256-zmWFdPSTS/DpU40CgHA/KKiSIs0PlI092TzLv/cG0jE=" } } diff --git a/package.json b/package.json index 2a8eef6356..226a86ff2c 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,12 @@ "$schema": "https://json.schemastore.org/package.json", "name": "opencode", "description": "AI-powered development tool", + "version": "0.0.0", "private": true, "type": "module", "packageManager": "bun@1.3.14", "scripts": { - "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", + "dev": "bun run --cwd packages/cli --conditions=browser src/index.ts", "dev:desktop": "bun --cwd packages/desktop dev", "dev:web": "bun --cwd packages/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", @@ -19,7 +20,6 @@ "prepare": "husky", "random": "echo 'Random script'", "sso": "aws sso login --sso-session=opencode --no-browser", - "translate:app": "bun run script/translate-app.ts", "test": "echo 'do not run tests from root' && exit 1" }, "workspaces": { @@ -40,14 +40,13 @@ "@octokit/rest": "22.0.0", "@hono/standard-validator": "0.2.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.4.5", - "@opentui/keymap": "0.4.5", - "@opentui/solid": "0.4.5", - "@tanstack/solid-virtual": "3.13.32", + "@opentui/core": "0.3.4", + "@opentui/keymap": "0.3.4", + "@opentui/solid": "0.3.4", + "@tanstack/solid-virtual": "3.13.28", "@shikijs/stream": "4.2.0", "ulid": "3.0.1", "@kobalte/core": "0.13.11", - "@corvu/drawer": "0.2.4", "@types/luxon": "3.7.1", "@types/node": "24.12.2", "@types/semver": "7.7.1", @@ -70,7 +69,7 @@ "hono-openapi": "1.1.2", "fuzzysort": "3.1.0", "luxon": "3.6.1", - "marked": "17.0.6", + "marked": "17.0.1", "marked-shiki": "1.2.1", "remend": "1.3.0", "@playwright/test": "1.59.1", @@ -90,7 +89,6 @@ "@sentry/solid": "10.36.0", "@sentry/vite-plugin": "4.6.0", "solid-js": "1.9.10", - "solid-sonner": "0.3.1", "vite-plugin-solid": "2.11.10", "@lydell/node-pty": "1.2.0-beta.12" } @@ -107,7 +105,7 @@ "prettier": "3.6.2", "semver": "^7.6.0", "sst": "catalog:", - "turbo": "2.10.2" + "turbo": "2.8.13" }, "dependencies": { "@aws-sdk/client-s3": "3.933.0", @@ -144,20 +142,19 @@ "@types/node": "catalog:" }, "patchedDependencies": { - "@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch", "@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", - "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", - "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", + "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", - "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", - "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch" + "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", + "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch" } } diff --git a/packages/app/V1_API_MIGRATION.md b/packages/app/V1_API_MIGRATION.md deleted file mode 100644 index 2850f10740..0000000000 --- a/packages/app/V1_API_MIGRATION.md +++ /dev/null @@ -1,220 +0,0 @@ -# V1 API Migration Checklist - -The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name. - -## Events - -- [x] Replace `GET /global/event` with `GET /api/event`. - - `src/context/server-sdk.tsx` -- [x] Reduce current granular session and message events into the existing app projections. - - `src/context/server-session-v2-reducer.ts` - - `src/context/server-session.ts` -- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`. - - `src/context/global-sync/event-reducer.ts` - - `src/context/server-session.ts` - - `src/context/notification.tsx` - - `src/pages/session/usage-exceeded-dialogs.tsx` -- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`. - - `src/context/global-sync/event-reducer.ts` - - `src/context/server-session.ts` -- [x] Adapt current permission and question events to the existing request model. - - `src/context/global-sync/event-reducer.ts` - - `src/context/permission.tsx` -- [x] Consume current file watcher events. - - `src/context/file.tsx` -- [x] Consume current VCS events. - - `src/context/global-sync/event-reducer.ts` - - `src/pages/session.tsx` -- [x] Consume current `pty.exited` events. - - `src/context/terminal.tsx` -- [ ] Migrate LSP and reference events. - - `src/context/global-sync/event-reducer.ts` - -## Sessions - -- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events. - - `src/context/server-sync.tsx` -- [x] Migrate session listing from `GET /session`. - - `src/context/server-sync.tsx` - - `src/context/directory-sync.ts` - - `src/pages/layout.tsx` -- [x] Migrate the remaining direct session read from `GET /session/:sessionID`. - - `src/components/titlebar.tsx` -- [x] Migrate session updates from `PATCH /session/:sessionID`. - - `src/context/directory-sync.ts` - - `src/context/layout.tsx` - - `src/pages/home.tsx` - - `src/pages/layout.tsx` - - `src/pages/session/timeline/message-timeline.tsx` - - `src/components/titlebar-tab-nav.tsx` - - Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`. -- [x] Migrate session deletion from `DELETE /session/:sessionID`. - - `src/pages/session/timeline/message-timeline.tsx` -- [x] Remove session diff loading from `GET /session/:sessionID/diff`. - - Historical Session diffs remain unavailable until the current API defines their snapshot semantics. -- [x] Migrate abort from `POST /session/:sessionID/abort`. - - `src/components/prompt-input/submit.ts` - - `src/pages/session/use-session-commands.tsx` - - `src/pages/session.tsx` -- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`. - - `src/pages/session/use-session-commands.tsx` - - `src/pages/session.tsx` -- [x] Replace `POST /session/:sessionID/summarize` with the current compact API. - - `src/pages/session/use-session-commands.tsx` -- [x] Migrate slash commands from `POST /session/:sessionID/command`. - - `src/components/prompt-input/submit.ts` -- [x] Migrate shell execution from `POST /session/:sessionID/shell`. - - `src/components/prompt-input/submit.ts` -- [x] Migrate session fork from `POST /session/:sessionID/fork`. - - `src/components/dialog-fork.tsx` -- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`. - - `src/pages/session/use-session-commands.tsx` - - `src/pages/session/timeline/message-timeline.tsx` - - Blocked: the current API has no sharing contract or implementation. - -## Session Compatibility Fallbacks - -These calls are retained as fallback adapters. The current production path supplies the current session and message APIs. - -- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary. - - `src/context/server-session.ts` -- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary. - - `src/context/server-session.ts` -- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary. - - `src/context/server-session.ts` - -## Filesystem - -- [ ] Migrate file listing from `GET /file`. - - `src/context/file.tsx` -- [ ] Migrate file reads from `GET /file/content`. - - `src/context/file.tsx` - - `src/pages/session/review-tab.tsx` - - `src/pages/session/v2/review-panel-v2.tsx` -- [x] Migrate path discovery from `GET /path` to `GET /api/path`. - - `src/context/global-sync/bootstrap.ts` - - `src/components/dialog-select-directory.tsx` - - `src/components/dialog-select-directory-v2.tsx` - -## Projects And Worktrees - -- [x] Migrate project listing from `GET /project` to `GET /api/project`. - - `src/context/global-sync/bootstrap.ts` -- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`. - - `src/context/global-sync/bootstrap.ts` -- [ ] Migrate Git initialization from `POST /project/git/init`. - - `src/pages/session.tsx` -- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`. - - `src/context/layout.tsx` - - `src/components/edit-project.ts` - - `src/pages/layout.tsx` -- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`. - - `src/pages/layout.tsx` - - `src/components/prompt-input/submit.ts` - - Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain. -- [ ] Migrate instance disposal from `POST /instance/dispose`. - - `src/pages/layout.tsx` - -## VCS - -- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`. - - `src/context/global-sync/bootstrap.ts` -- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`. - - `src/pages/session.tsx` -- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`. - - `src/pages/layout.tsx` - -## Configuration And Authentication - -- [ ] Migrate global configuration reads from `GET /global/config`. - - `src/context/global-sync/bootstrap.ts` -- [ ] Migrate directory configuration reads from `GET /config`. - - `src/context/global-sync/bootstrap.ts` -- [ ] Migrate global configuration updates from `PATCH /global/config`. - - `src/context/server-sync.tsx` -- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`. - - `src/components/dialog-connect-provider.tsx` -- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`. - - `src/components/dialog-connect-provider.tsx` -- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`. - - Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`. - - `src/components/dialog-connect-provider.tsx` - - `src/components/dialog-custom-provider.tsx` - - `src/components/settings-providers.tsx` - - `src/components/settings-v2/providers.tsx` -- [ ] Migrate global disposal from `POST /global/dispose`. - - `src/components/dialog-connect-provider.tsx` - - `src/components/settings-providers.tsx` - - `src/components/settings-v2/providers.tsx` - -## Permissions And Questions - -- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`. - - `src/context/global-sync/bootstrap.ts` - - `src/context/permission.tsx` -- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`. - - `src/context/permission.tsx` - - `src/pages/session/composer/session-composer-state.ts` -- [x] Migrate question listing from `GET /question` to `GET /api/question/request`. - - `src/context/global-sync/bootstrap.ts` -- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`. - - `src/pages/session/composer/session-question-dock.tsx` - -## Commands, MCP, LSP, And References - -- [x] Migrate command listing from `GET /command` to `GET /api/command`. - - `src/context/global-sync/bootstrap.ts` - - `src/context/server-sync.tsx` -- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`. - - `src/context/server-sync.tsx` -- [ ] Replace legacy MCP authentication with the Integration OAuth workflow. - - `src/context/server-sync.tsx` -- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`. - - `src/context/server-sync.tsx` -- [ ] Migrate LSP status from `GET /lsp`. - - `src/context/server-sync.tsx` -- [x] Move `GET /api/reference` off the legacy generated SDK transport. - - `src/context/global-sync/bootstrap.ts` - -## Search - -- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`. - - `src/components/command-palette.ts` - - `src/components/dialog-command-palette-v2.tsx` - -## PTY And Terminal - -- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`. - - `src/context/terminal.tsx` - - `src/components/terminal.tsx` -- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`. - - `src/components/settings-general.tsx` - - `src/components/settings-v2/general.tsx` -- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`. - - `src/components/terminal.tsx` -- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`. - - `src/components/terminal.tsx` - -## Legacy Types And Adapters - -These are not V1 network requests, but they keep the UI coupled to V1 data contracts. - -- [ ] Replace the current-session-to-legacy-session adapter. - - `src/utils/session.ts` -- [ ] Replace the current-message-to-legacy-message-and-part adapter. - - `src/utils/session-message.ts` -- [ ] Replace current agent, provider, and model adapters to legacy SDK structures. - - `src/context/global-sync/utils.ts` -- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering. -- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone. - - `package.json` - -## Test Infrastructure - -- [ ] Replace V1 endpoint mocks with current API mocks. - - `e2e/utils/mock-server.ts` -- [x] Replace `/global/event` and `/event` interception with current event transport handling. - - `e2e/utils/sse-transport.ts` -- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests. - - `e2e/performance/timeline-stability/fixture.ts` -- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests. diff --git a/packages/app/e2e/performance/timeline-stability/README.md b/packages/app/e2e/performance/timeline-stability/README.md deleted file mode 100644 index 3e9def2757..0000000000 --- a/packages/app/e2e/performance/timeline-stability/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# Timeline Layout Continuity - -Run from `packages/app`: - -```sh -bun run test:stability -``` - -The suite runs a production build in one Chromium worker. Selected scenarios use deterministic 4x CPU stress after application readiness. This is a stress profile, not emulation of a specific device. - -## What It Proves - -The continuity probe samples DOM-derived layout and visibility state across browser render opportunities. Tests declare explicit contracts such as: - -- Preserve a visible semantic anchor while the user is away from the bottom. -- Preserve end anchoring while active content grows or new content appears. -- Keep adjacent visible rows ordered without material overlap. -- Keep user-selected disclosure state through updates and virtualization. -- Avoid a sampled blank interval while one visible surface replaces another. -- Preserve logical row and control identity where local state or focus depends on it. -- Keep keyboard, wheel, and nested-scroll ownership consistent during remeasurement. - -The suite exercises real browser reducer, projection, component, virtualizer, layout, focus, and interaction code. The backend and event producer are controlled fixtures. - -## What It Does Not Prove - -The pass/fail oracle does not inspect every compositor-presented pixel. A sample taken after `requestAnimationFrame` is a DOM/layout observation, not proof that every sampled state was displayed or that every displayed frame was sampled. - -The suite does not provide complete coverage for: - -- Compositor-only or raster-only glitches. -- Color, contrast, canvas, WebGL, masks, irregular clips, or arbitrary occlusion. -- Physical display refresh rates, native OS scaling, or a named low-end device. -- TCP packetization, proxy buffering, or the complete real server/provider pipeline. - -Playwright video, trace, screenshots, and observation JSON are diagnostic evidence. They are not pixel baselines and do not participate in normal pass/fail decisions. - -For optional before/violation/after screenshots, set `OPENCODE_STABILITY_CAPTURE=1`. Capture is opt-in because compositor readback can perturb timing. - -## Test Layers - -- **Projection:** admitted rows, grouping, labels, and final visible states. -- **Local state:** disclosure state, identity, duplicate delivery, and virtualization restoration. -- **Interaction:** wheel, keyboard, nested scrolling, actionability, and focus behavior. -- **Layout continuity:** anchoring, adjacency, responsive reflow, and visible surface handoffs. -- **Reducer hardening:** validly shaped but intentionally reordered, duplicated, removed, or replaced events. -- **Oracle contract:** pure analyzer and browser sampler calibration tests. - -Production-lifecycle fixtures should model states emitted by the current producer. Impossible or reordered sequences belong in reducer-hardening tests and must not be described as normal provider behavior. - -## Diagnostics - -Failures retain: - -- `video.webm` -- `trace.zip` -- failure screenshot -- sampled DOM/layout trace JSON -- event markers and summarized violations - -The analyzer records both unclipped layout bounds and ancestor-clipped visible intersections. Scrollbar and raw `scrollTop` changes alone do not fail continuity checks; user-visible semantic anchor movement does. diff --git a/packages/app/e2e/performance/timeline-stability/adverse.spec.ts b/packages/app/e2e/performance/timeline-stability/adverse.spec.ts deleted file mode 100644 index 7ce1be9064..0000000000 --- a/packages/app/e2e/performance/timeline-stability/adverse.spec.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - defineVisualRegions, - reportVisualStability, - startVisualProbe, - stopVisualProbe, - visualPlan, -} from "../../utils/visual-stability" -import { - assistantMessage, - partUpdated, - setupTimeline, - shell, - textPart, - toolPart, - userMessage, - waitForVisualSettle, - type TimelineMessage, -} from "./fixture" - -test.describe("timeline adverse visual stability", () => { - test("does not pull a scrolled-away user while an active shell grows", async ({ page }, testInfo) => { - const activeShellID = "prt_adverse_01_shell" - const messages = [ - ...history(24), - userMessage(), - assistantMessage([shell(activeShellID, "running")], { completed: false }), - ] - const timeline = await setupTimeline(page, { - messages, - settings: { shellToolPartsExpanded: true }, - cpuRate: 4, - eventRetry: 30, - }) - const scroller = page.locator(".scroll-view__viewport", { - has: page.locator('[data-timeline-row="AssistantPart"]'), - }) - await scroller.evaluate((element) => { - element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -450 })) - element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 450) - }) - await page.waitForTimeout(150) - await expect - .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) - .toBeGreaterThan(100) - const anchor = await scroller.evaluate((element) => { - const view = element.getBoundingClientRect() - return [...element.querySelectorAll("[data-timeline-key]")].find((row) => { - const rect = row.getBoundingClientRect() - return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40 - })?.dataset.timelineKey - }) - expect(anchor).toBeTruthy() - await waitForVisualSettle(page, [`[data-timeline-key="${anchor}"]`]) - - const regions = defineVisualRegions({ - anchor: { selector: `[data-timeline-key="${anchor}"]` }, - }) - await startVisualProbe(page, regions) - await timeline.send(partUpdated(shell(activeShellID, "running", lines(1))), 180) - await timeline.send(partUpdated(shell(activeShellID, "running", lines(10))), 90) - await timeline.send(partUpdated(shell(activeShellID, "running", lines(50))), 350) - await timeline.send(partUpdated(shell(activeShellID, "completed", lines(50))), 500) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "scrolled-away-shell", - trace, - visualPlan(regions, [ - { type: "required", regions: ["anchor"] }, - { type: "unique", regions: ["anchor"] }, - { type: "stable", regions: ["anchor"] }, - { type: "fixed", regions: ["anchor"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - ]), - ) - }) - - test("preserves an explicit shell state across virtualization", async ({ page }) => { - const targetID = "prt_virtual_shell" - const messages = [ - userMessage(undefined, { id: "msg_0000_virtual_user", created: 1700000000000 }), - assistantMessage([shell(targetID, "completed", lines(20))], { - id: "msg_0001_virtual_assistant", - parentID: "msg_0000_virtual_user", - created: 1700000001000, - }), - ...history(35, 10), - ] - await setupTimeline(page, { messages, settings: { shellToolPartsExpanded: false } }) - const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - await scroller.evaluate((element) => { - element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1_000 })) - element.scrollTop = 0 - }) - await page.waitForTimeout(300) - const trigger = page.locator(`[data-timeline-part-id="${targetID}"] [data-slot="collapsible-trigger"]`) - await expect(trigger).toBeVisible() - await trigger.click() - await expect(trigger).toHaveAttribute("aria-expanded", "true") - - await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight)) - await expect(page.locator(`[data-timeline-part-id="${targetID}"]`)).toHaveCount(0) - await scroller.evaluate((element) => (element.scrollTop = 0)) - await expect(trigger).toBeVisible() - await expect(trigger).toHaveAttribute("aria-expanded", "true") - }) - - test("keeps narrow viewport rows ordered during long shell growth", async ({ page }, testInfo) => { - const shellID = "prt_narrow_01_shell" - const followingID = "prt_narrow_02_following" - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage( - [shell(shellID, "running"), textPart(followingID, "A narrow following row that wraps across lines.")], - { - completed: false, - }, - ), - ], - settings: { shellToolPartsExpanded: true }, - viewport: { width: 430, height: 800 }, - cpuRate: 4, - }) - await waitForVisualSettle(page, [ - `[data-timeline-part-id="${shellID}"]`, - `[data-timeline-part-id="${followingID}"]`, - ]) - const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { - selector: `[data-timeline-part-id="${followingID}"]`, - closest: '[data-timeline-row="AssistantPart"]', - }, - }) - await startVisualProbe(page, regions) - await timeline.send(partUpdated(shell(shellID, "running", wideLines(10))), 100) - await timeline.send(partUpdated(shell(shellID, "running", wideLines(50))), 300) - await timeline.send(partUpdated(shell(shellID, "completed", wideLines(50))), 500) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "narrow-shell", - trace, - visualPlan( - regions, - [ - { type: "required", regions: ["shell", "following"] }, - { type: "unique", regions: ["shell", "following"] }, - { type: "stable", regions: ["shell", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - { type: "preserve-bottom-anchor" }, - { type: "flow", regions: ["shell", "following"] }, - ], - { perMarker: true }, - ), - ) - }) - - test("keeps visible rows ordered while resizing desktop to narrow and back", async ({ page }, testInfo) => { - const shellID = "prt_resize_01_shell" - const contextIDs = ["prt_resize_02_read", "prt_resize_03_glob"] - const followingID = "prt_resize_04_following" - await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - shell(shellID, "completed", wideLines(15)), - toolPart(contextIDs[0]!, "read", "completed", { filePath: "src/a.ts" }), - toolPart(contextIDs[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }), - textPart(followingID, "Following responsive timeline content that wraps on narrow screens."), - ]), - ], - settings: { shellToolPartsExpanded: true }, - cpuRate: 4, - seedHistory: true, - }) - const group = `[data-timeline-part-ids="${contextIDs.join(",")}"]` - const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' }, - following: { - selector: `[data-timeline-part-id="${followingID}"]`, - closest: '[data-timeline-row="AssistantPart"]', - }, - }) - await startVisualProbe(page, regions) - await page.setViewportSize({ width: 430, height: 800 }) - await page.waitForTimeout(500) - await page.setViewportSize({ width: 900, height: 800 }) - await page.waitForTimeout(500) - await page.setViewportSize({ width: 1400, height: 900 }) - await page.waitForTimeout(500) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "responsive-resize", - trace, - visualPlan(regions, [ - { type: "required", regions: ["shell", "context", "following"] }, - { type: "unique", regions: ["shell", "context", "following"] }, - { type: "stable", regions: ["shell", "context", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 4, maxReversals: 4 }, - { type: "label-stability", regions: "all" }, - { type: "flow", regions: ["shell", "context", "following"] }, - ]), - ) - }) -}) - -function history(count: number, offset = 0): TimelineMessage[] { - return Array.from({ length: count }, (_, index) => { - const value = index + offset - const prefix = `msg_0${String(value).padStart(3, "0")}_history` - const userID = `${prefix}_a_user` - return [ - userMessage(undefined, { id: userID, created: 1699990000000 + value * 10_000 }), - assistantMessage( - [ - textPart( - `prt_history_${String(value).padStart(3, "0")}`, - `Historical response ${value}. ${"Stable history content. ".repeat(8)}`, - ), - ], - { - id: `${prefix}_b_assistant`, - parentID: userID, - created: 1699990001000 + value * 10_000, - }, - ), - ] - }).flat() -} - -function lines(count: number) { - return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") -} - -function wideLines(count: number) { - return Array.from({ length: count }, (_, index) => `line ${index + 1} ${"wide-output-".repeat(20)}`).join("\n") -} diff --git a/packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts deleted file mode 100644 index b3438b77c0..0000000000 --- a/packages/app/e2e/performance/timeline-stability/context-matrix.spec.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - defineVisualRegions, - reportVisualStability, - startVisualProbe, - stopVisualProbe, - visualPlan, -} from "../../utils/visual-stability" -import { - assistantID, - assistantMessage, - event, - partUpdated, - setupTimeline, - textPart, - toolPart, - userMessage, - waitForVisualSettle, -} from "./fixture" - -const inputs = { - read: { filePath: "src/a.ts", offset: 0, limit: 120 }, - glob: { path: ".", pattern: "**/*.ts" }, - grep: { path: ".", pattern: "stable", include: "*.ts" }, - list: { path: "src" }, -} - -test("appends context operations while the group is expanded", async ({ page }, testInfo) => { - const firstID = "prt_append_01_read" - const followingID = "prt_append_99_following" - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([toolPart(firstID, "read", "running", inputs.read), textPart(followingID, "Following append")], { - completed: false, - }), - ], - cpuRate: 4, - }) - const initialGroup = `[data-timeline-part-ids="${firstID}"]` - await page.locator(`${initialGroup} [data-slot="collapsible-trigger"]`).click() - await waitForVisualSettle(page, [initialGroup, `[data-timeline-part-id="${followingID}"]`]) - const regions = defineVisualRegions({ - context: { - selector: '[data-timeline-part-ids^="prt_append_01_read"]', - closest: '[data-timeline-row="AssistantPart"]', - }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - await startVisualProbe(page, regions) - await timeline.send(partUpdated(toolPart("prt_append_02_glob", "glob", "running", inputs.glob)), 180) - await timeline.send(partUpdated(toolPart("prt_append_03_grep", "grep", "completed", inputs.grep)), 240) - await timeline.send(partUpdated(toolPart("prt_append_04_list", "list", "completed", inputs.list)), 500) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "context-append", - trace, - visualPlan( - regions, - [ - { type: "required", regions: ["context", "following"] }, - { type: "unique", regions: ["context", "following"] }, - { type: "stable", regions: ["context", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: ["following"], maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - { type: "preserve-bottom-anchor" }, - { type: "flow", regions: ["context", "following"] }, - ], - { perMarker: true }, - ), - ) - await expect( - page.locator( - '[data-timeline-part-ids="prt_append_01_read,prt_append_02_glob,prt_append_03_grep,prt_append_04_list"]', - ), - ).toBeVisible() - await expect( - page.locator('[data-timeline-part-ids^="prt_append_01_read"] [data-slot="collapsible-trigger"]'), - ).toHaveAttribute("aria-expanded", "true") -}) - -test("splits and merges context groups when a middle text part changes", async ({ page }, testInfo) => { - const textID = "prt_split_02_text" - const followingID = "prt_split_99_following" - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - toolPart("prt_split_01_read", "read", "completed", inputs.read), - textPart(textID, "Boundary"), - toolPart("prt_split_03_glob", "glob", "completed", inputs.glob), - textPart(followingID, "Following split groups"), - ]), - ], - cpuRate: 4, - }) - const regions = defineVisualRegions({ - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - await startVisualProbe(page, regions) - await timeline.send( - event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: textID }), - 500, - ) - await expect(page.locator('[data-timeline-part-ids="prt_split_01_read,prt_split_03_glob"]')).toBeVisible() - await timeline.send(partUpdated(textPart(textID, "Boundary restored")), 500) - await expect(page.locator('[data-timeline-part-ids="prt_split_01_read"]')).toBeVisible() - await expect(page.locator('[data-timeline-part-ids="prt_split_03_glob"]')).toBeVisible() - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "context-split-merge", - trace, - visualPlan( - regions, - [ - { type: "required", regions: ["following"] }, - { type: "unique", regions: ["following"] }, - { type: "stable", regions: ["following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 1 }, - { type: "label-stability", regions: "all" }, - ], - { perMarker: true }, - ), - ) -}) - -test("removing the first context member replaces the group once without overlapping following content", async ({ - page, -}, testInfo) => { - const ids = ["prt_key_01_read", "prt_key_02_glob", "prt_key_03_grep"] - const followingID = "prt_key_99_following" - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - toolPart(ids[0]!, "read", "completed", inputs.read), - toolPart(ids[1]!, "glob", "completed", inputs.glob), - toolPart(ids[2]!, "grep", "completed", inputs.grep), - textPart(followingID, "Following replaced group"), - ]), - ], - cpuRate: 4, - }) - const original = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`) - const originalRowKey = await original.evaluate((element) => - element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key"), - ) - await original.locator('[data-slot="collapsible-trigger"]').click() - await expect(original.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true") - const regions = defineVisualRegions({ - context: { - selector: '[data-timeline-part-ids*="prt_key_02_glob"]', - closest: '[data-timeline-row="AssistantPart"]', - }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - await startVisualProbe(page, regions) - await timeline.send( - event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: ids[0] }), - 500, - ) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "context-first-remove", - trace, - visualPlan(regions, [ - { type: "required", regions: ["context", "following"] }, - { type: "unique", regions: ["context", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - { type: "flow", regions: ["context", "following"] }, - ]), - ) - await expect(page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)).toBeVisible() - expect( - await page - .locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`) - .evaluate((element) => element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key")), - ).toBe(originalRowKey) - await expect( - page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"] [data-slot="collapsible-trigger"]`), - ).toHaveAttribute("aria-expanded", "true") -}) diff --git a/packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts deleted file mode 100644 index 3455438a73..0000000000 --- a/packages/app/e2e/performance/timeline-stability/environment-matrix.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { test } from "@playwright/test" -import { - defineVisualRegions, - reportVisualStability, - startVisualProbe, - stopVisualProbe, - visualPlan, -} from "../../utils/visual-stability" -import { - assistantMessage, - partUpdated, - setupTimeline, - shell, - textPart, - userMessage, - waitForVisualSettle, -} from "./fixture" - -// Fractional scaling exercises different browser rounding than the baseline. -for (const deviceScaleFactor of [1, 1.25]) { - test(`keeps shell growth ordered at device scale ${deviceScaleFactor}`, async ({ page }, testInfo) => { - const shellID = `prt_dpr_${String(deviceScaleFactor).replace(".", "_")}_01_shell` - const followingID = `prt_dpr_${String(deviceScaleFactor).replace(".", "_")}_02_following` - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([shell(shellID, "running"), textPart(followingID, "Following scaled shell")], { - completed: false, - }), - ], - settings: { shellToolPartsExpanded: true }, - cpuRate: 4, - deviceScaleFactor, - seedHistory: true, - }) - await waitForVisualSettle(page, [ - `[data-timeline-part-id="${shellID}"]`, - `[data-timeline-part-id="${followingID}"]`, - ]) - const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { - selector: `[data-timeline-part-id="${followingID}"]`, - closest: '[data-timeline-row="AssistantPart"]', - }, - }) - await startVisualProbe(page, regions) - await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 180) - await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 500) - const trace = await stopVisualProbe(page) - await reportVisualStability(testInfo, `dpr-${deviceScaleFactor}`, trace, shellPlan(regions)) - }) -} - -for (const reducedMotion of [true]) { - test(`keeps shell and status transitions ordered with reduced motion ${reducedMotion}`, async ({ - page, - }, testInfo) => { - const shellID = `prt_motion_${reducedMotion}_01_shell` - const followingID = `prt_motion_${reducedMotion}_02_following` - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([shell(shellID, "running"), textPart(followingID, "Following motion profile")], { - completed: false, - }), - ], - settings: { shellToolPartsExpanded: true }, - reducedMotion, - cpuRate: 4, - seedHistory: true, - }) - await waitForVisualSettle(page, [ - `[data-timeline-part-id="${shellID}"]`, - `[data-timeline-part-id="${followingID}"]`, - ]) - const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { - selector: `[data-timeline-part-id="${followingID}"]`, - closest: '[data-timeline-row="AssistantPart"]', - }, - }) - await startVisualProbe(page, regions) - await timeline.send(partUpdated(shell(shellID, "completed", lines(10))), 500) - const trace = await stopVisualProbe(page) - await reportVisualStability(testInfo, `reduced-motion-${reducedMotion}`, trace, shellPlan(regions)) - }) -} - -function lines(count: number) { - return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") -} - -function shellPlan>( - regions: Regions & Record<"shell" | "following", { selector: string }>, -) { - return visualPlan( - regions, - [ - { type: "required", regions: ["shell", "following"] }, - { type: "unique", regions: ["shell", "following"] }, - { type: "stable", regions: ["shell", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - { type: "preserve-bottom-anchor" }, - { type: "flow", regions: ["shell", "following"] }, - ], - { perMarker: true }, - ) -} diff --git a/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts deleted file mode 100644 index e0f0d72233..0000000000 --- a/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { test } from "@playwright/test" -import { - defineVisualRegions, - reportVisualStability, - startVisualProbe, - stopVisualProbe, - visualPlan, -} from "../../utils/visual-stability" -import { - assistantMessage, - partUpdated, - setupTimeline, - textPart, - toolPart, - userMessage, - waitForVisualSettle, -} from "./fixture" - -const profiles = [ - { name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } }, - { - name: "multi patch", - tool: "apply_patch", - input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] }, - }, -] as const - -for (const profile of profiles) { - test(`stabilizes ${profile.name} pending to completed`, async ({ page }, testInfo) => { - const partID = `prt_file_matrix_${profiles.indexOf(profile)}` - const followingID = `prt_file_matrix_following_${profiles.indexOf(profile)}` - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage( - [ - toolPart(partID, profile.tool, "pending", profile.input), - textPart(followingID, `Following ${profile.name}`), - ], - { completed: false }, - ), - ], - settings: { editToolPartsExpanded: true }, - cpuRate: 4, - }) - await waitForVisualSettle(page, [`[data-timeline-part-id="${partID}"]`, `[data-timeline-part-id="${followingID}"]`]) - const regions = defineVisualRegions({ - tool: { selector: `[data-timeline-part-id="${partID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { - selector: `[data-timeline-part-id="${followingID}"]`, - closest: '[data-timeline-row="AssistantPart"]', - }, - }) - await startVisualProbe(page, regions) - await timeline.send(partUpdated(toolPart(partID, profile.tool, "running", profile.input)), 180) - await timeline.send(partUpdated(completedPart(partID, profile)), 900) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - `file-${profile.name}`, - trace, - visualPlan( - regions, - [ - { type: "required", regions: ["tool", "following"] }, - { type: "unique", regions: ["tool", "following"] }, - { type: "stable", regions: ["tool", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 }, - { type: "label-stability", regions: "all" }, - { type: "preserve-bottom-anchor" }, - { type: "flow", regions: ["tool", "following"] }, - ], - { perMarker: true }, - ), - ) - }) -} - -function completedPart(partID: string, profile: (typeof profiles)[number]) { - if (profile.tool === "edit") { - return toolPart(partID, profile.tool, "completed", profile.input, { - metadata: { - filediff: { - file: "src/edit.ts", - additions: 50, - deletions: 50, - before: source(50, false), - after: source(50, true), - }, - }, - }) - } - const files = [ - patchFile("src/a.ts", "update"), - patchFile("src/b.ts", "add"), - patchFile("src/old.ts", "delete"), - { ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" }, - ] - return toolPart(partID, profile.tool, "completed", profile.input, { metadata: { files } }) -} - -function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") { - return { - filePath, - relativePath: filePath, - type, - additions: type === "delete" ? 0 : 20, - deletions: type === "add" ? 0 : 20, - before: type === "add" ? undefined : source(20, false), - after: type === "delete" ? undefined : source(20, true), - } -} - -function source(count: number, changed: boolean) { - return Array.from( - { length: count }, - (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`, - ).join("") -} diff --git a/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts b/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts deleted file mode 100644 index 798bf0df3b..0000000000 --- a/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - defineVisualRegions, - reportVisualStability, - startVisualProbe, - stopVisualProbe, - visualPlan, -} from "../../utils/visual-stability" -import { - assistantMessage, - partUpdated, - setupTimeline, - textPart, - toolPart, - userMessage, - waitForVisualSettle, -} from "./fixture" - -test("adds patch files incrementally without resetting outer expansion", async ({ page }, testInfo) => { - const patchID = "prt_incremental_01_patch" - const followingID = "prt_incremental_02_following" - const first = patchFile("src/a.ts", "update") - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage( - [ - toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }), - textPart(followingID, "Following incremental patch"), - ], - { completed: false }, - ), - ], - settings: { editToolPartsExpanded: true }, - cpuRate: 4, - seedHistory: true, - }) - const trigger = page.locator(`[data-timeline-part-id="${patchID}"] [data-slot="collapsible-trigger"]`).first() - await expect(trigger).toHaveAttribute("aria-expanded", "true") - await waitForVisualSettle(page, [`[data-timeline-part-id="${patchID}"]`, `[data-timeline-part-id="${followingID}"]`]) - const regions = defineVisualRegions({ - patch: { selector: `[data-timeline-part-id="${patchID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - await startVisualProbe(page, regions) - const second = patchFile("src/b.ts", "add") - const third = patchFile("src/old.ts", "delete") - await timeline.send( - partUpdated( - toolPart( - patchID, - "apply_patch", - "running", - { files: [first.filePath, second.filePath] }, - { metadata: { files: [first, second] } }, - ), - ), - 240, - ) - await timeline.send( - partUpdated( - toolPart( - patchID, - "apply_patch", - "completed", - { files: [first.filePath, second.filePath, third.filePath] }, - { metadata: { files: [first, second, third] } }, - ), - ), - 800, - ) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "incremental-patch", - trace, - visualPlan( - regions, - [ - { type: "required", regions: ["patch", "following"] }, - { type: "unique", regions: ["patch", "following"] }, - { type: "stable", regions: ["patch", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: ["following"], maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - { type: "preserve-bottom-anchor" }, - { type: "flow", regions: ["patch", "following"] }, - ], - { perMarker: true }, - ), - ) - await expect(trigger).toHaveAttribute("aria-expanded", "true") - await expect(page.locator('[data-scope="apply-patch"] [data-type="delete"]')).toBeVisible() -}) - -function patchFile(filePath: string, type: "add" | "update" | "delete") { - return { - filePath, - relativePath: filePath, - type, - additions: type === "delete" ? 0 : 4, - deletions: type === "add" ? 0 : 3, - before: type === "add" ? undefined : source(false), - after: type === "delete" ? undefined : source(true), - } -} - -function source(changed: boolean) { - return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join( - "", - ) -} diff --git a/packages/app/e2e/performance/timeline-stability/fixture.test.ts b/packages/app/e2e/performance/timeline-stability/fixture.test.ts deleted file mode 100644 index b003645e64..0000000000 --- a/packages/app/e2e/performance/timeline-stability/fixture.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { - assistantMessage, - event, - toolPart, - userMessage, - validateTimelineEvent, - validateTimelineMessages, - type PartSeed, -} from "./fixture" - -describe("timeline fixture validation", () => { - test("accepts a valid timeline", () => { - expect(validateTimelineMessages([userMessage(), assistantMessage()])).toHaveLength(2) - }) - - test("rejects malformed SDK values at runtime", () => { - expect(() => - assistantMessage([], { - error: { name: "APIError", data: { message: "failed" } } as never, - }), - ).toThrow() - expect(() => - validateTimelineEvent({ - directory: "C:/OpenCode/TimelineStability", - payload: { - id: "evt_invalid_status", - type: "session.status", - properties: { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } }, - }, - }), - ).toThrow() - }) - - test("rejects duplicate IDs and orphan assistants", () => { - expect(() => validateTimelineMessages([userMessage(), userMessage()])).toThrow(/duplicate message ID/) - expect(() => - validateTimelineMessages([userMessage(), assistantMessage([], { parentID: "msg_missing_parent" })]), - ).toThrow(/parent user/) - }) - - test("assigns deterministic event IDs", () => { - const first = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "busy" } }) - const second = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "idle" } }) - expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/) - expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1) - }) -}) - -if (false) { - const userSeed = { id: "prt_type_user", type: "text", text: "typed" } satisfies PartSeed<"user"> - userMessage([userSeed]) - - // @ts-expect-error Tool completion fields are not valid while pending. - toolPart("prt_invalid_pending", "bash", "pending", {}, { output: "impossible" }) - // @ts-expect-error Tool completion fields are not valid while running. - toolPart("prt_invalid_running", "bash", "running", {}, { output: "impossible" }) - // @ts-expect-error Tool error fields are not valid after completion. - toolPart("prt_invalid_completed", "bash", "completed", {}, { error: "impossible" }) - - assistantMessage([ - // @ts-expect-error Agent references belong to user messages, not assistant messages. - { id: "prt_invalid_owner", type: "agent", name: "explore", source: { value: "@explore", start: 0, end: 8 } }, - ]) - - // @ts-expect-error Retry status events require message and next. - event("session.status", { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } }) -} diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts deleted file mode 100644 index df67da5a66..0000000000 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ /dev/null @@ -1,566 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { Event } from "@opencode-ai/schema/event" -import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event" -import { SessionV1 } from "@opencode-ai/schema/session-v1" -import type { - AssistantMessage, - GlobalEvent, - Message, - Part, - Session, - SessionStatus, - ToolPart, - ToolState, - UserMessage, -} from "@opencode-ai/sdk/v2/client" -import { expect, type Page } from "@playwright/test" -import { Schema } from "effect" -import { mockOpenCodeServer } from "../../utils/mock-server" -import { installSseTransport } from "../../utils/sse-transport" -import { expectSessionTitle } from "../../utils/waits" - -export const directory = "C:/OpenCode/TimelineStability" -export const projectID = "proj_timeline_stability" -export const sessionID = "ses_timeline_stability" -export const userID = "msg_1000_timeline_user" -export const assistantID = "msg_1001_timeline_assistant" -export const title = "Timeline visual stability" -export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } - -type TimelinePayload = Extract< - GlobalEvent["payload"], - { - type: - | "message.updated" - | "message.removed" - | "message.part.updated" - | "message.part.removed" - | "message.part.delta" - | "session.status" - } -> - -type DeepReadonly = Value extends readonly unknown[] - ? { readonly [Key in keyof Value]: DeepReadonly } - : Value extends object - ? { readonly [Key in keyof Value]: DeepReadonly } - : Value - -export type TimelineEvent = DeepReadonly & { payload: TimelinePayload }> -export type EventPayload = TimelineEvent -export type ToolStatus = ToolState["status"] -export type TimelineMessage = { info: UserMessage; parts: Part[] } | { info: AssistantMessage; parts: Part[] } - -type UserPart = Extract -type AssistantPart = Exclude -type OwnedPart = Owner extends "user" ? UserPart : AssistantPart -export type PartSeed = - OwnedPart extends infer Candidate - ? Candidate extends Part - ? Omit - : never - : never - -type ToolOptions = State extends "pending" - ? { output?: never; title?: never; metadata?: never; error?: never } - : State extends "running" - ? { title?: string; metadata?: Record; output?: never; error?: never } - : State extends "error" - ? { error?: string; metadata?: Record; output?: never; title?: never } - : { output?: string; title?: string; metadata?: Record; error?: never } - -const decodeOptions = { errors: "all", onExcessProperty: "error" } as const -const decodeMessage = Schema.decodeUnknownSync(SessionV1.WithParts) -const decodePart = Schema.decodeUnknownSync(SessionV1.Part) -const decodeStatus = Schema.decodeUnknownSync(SessionStatusEvent.Info) -const timelineEventSchema = Schema.Union([ - eventSchema("message.updated", SessionV1.Event.MessageUpdated.data), - eventSchema("message.removed", SessionV1.Event.MessageRemoved.data), - eventSchema("message.part.updated", SessionV1.Event.PartUpdated.data), - eventSchema("message.part.removed", SessionV1.Event.PartRemoved.data), - eventSchema("message.part.delta", SessionV1.Event.PartDelta.data), - eventSchema("session.status", SessionStatusEvent.Status.data), -]) -const decodeEvent = Schema.decodeUnknownSync(timelineEventSchema) -let eventSequence = 0 - -export async function setupTimeline( - page: Page, - input: { - messages?: TimelineMessage[] - settings?: Record - sessions?: Session[] - cpuRate?: number - viewport?: { width: number; height: number } - eventRetry?: number - reducedMotion?: boolean - locale?: string - deviceScaleFactor?: number - seedHistory?: boolean - protocol?: "v1" | "v2" - } = {}, -) { - const sessions = input.sessions ?? [session()] - const messages = validateTimelineMessages([ - ...(input.seedHistory ? historyMessages(18) : []), - ...(input.messages ?? [userMessage(), assistantMessage()]), - ]) - const active = messages.findLast((message) => message.info.role === "assistant") - const initialStatus = decodeStatus( - active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" }, - decodeOptions, - ) - const transport = await installSseTransport(page, { - server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, - retry: input.eventRetry ?? 20, - }) - await mockOpenCodeServer(page, { - protocol: input.protocol, - directory, - project: project(), - provider: provider(), - sessions, - sessionStatus: { [sessionID]: initialStatus }, - pageMessages: () => ({ - items: messages, - }), - }) - await page.addInitScript((settings) => { - localStorage.setItem( - "settings.v3", - JSON.stringify({ - general: { - editToolPartsExpanded: false, - shellToolPartsExpanded: false, - showReasoningSummaries: false, - showSessionProgressBar: true, - ...settings, - }, - }), - ) - if (settings.newLayoutDesigns === false) { - localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" })) - } - }, input.settings ?? {}) - if (input.locale) { - await page.addInitScript((locale) => { - localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale })) - }, input.locale) - } - if (input.reducedMotion) await page.emulateMedia({ reducedMotion: "reduce" }) - await page.setViewportSize(input.viewport ?? { width: 1400, height: 900 }) - if (input.deviceScaleFactor) { - const devtools = await page.context().newCDPSession(page) - const viewport = input.viewport ?? { width: 1400, height: 900 } - await devtools.send("Emulation.setDeviceMetricsOverride", { - width: viewport.width, - height: viewport.height, - deviceScaleFactor: input.deviceScaleFactor, - mobile: false, - }) - } - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - await transport.waitForConnection() - await expectSessionTitle(page, title) - if (input.cpuRate && input.cpuRate > 1) { - const devtools = await page.context().newCDPSession(page) - await devtools.send("Emulation.setCPUThrottlingRate", { rate: input.cpuRate }) - } - - return { - transport, - async send(event: TimelineEvent, delay = 0) { - const valid = validateTimelineEvent(event) - await transport.send(valid, { marker: describeEvent(valid) }) - if (delay) await page.waitForTimeout(delay) - }, - async sendAll(sequence: { event: TimelineEvent; delay: number }[]) { - for (const item of sequence) { - const valid = validateTimelineEvent(item.event) - await transport.send(valid, { marker: describeEvent(valid) }) - await page.waitForTimeout(item.delay) - } - }, - async settle(frames = 3) { - await page.evaluate( - (frames) => - new Promise((resolve) => { - let remaining = frames - const tick = () => { - remaining-- - if (remaining <= 0) return resolve() - requestAnimationFrame(tick) - } - requestAnimationFrame(tick) - }), - frames, - ) - }, - async waitForPart(partID: string) { - await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible() - }, - } -} - -function describeEvent(event: EventPayload) { - if (event.payload.type === "message.part.updated") { - const part = event.payload.properties.part - return [ - event.payload.type, - part.id, - part.type === "tool" ? part.tool : part.type, - part.type === "tool" ? part.state.status : undefined, - ] - .filter(Boolean) - .join(":") - } - if (event.payload.type === "session.status") { - const status = event.payload.properties.status - return [event.payload.type, status.type, status.type === "retry" ? status.attempt : undefined] - .filter((value) => value !== undefined) - .join(":") - } - return event.payload.type -} - -export function event( - type: Type, - properties: Extract["properties"], -): TimelineEvent -export function event(type: TimelinePayload["type"], properties: TimelinePayload["properties"]): TimelineEvent { - return validateTimelineEvent({ - directory, - payload: { id: `evt_timeline_${String(++eventSequence).padStart(4, "0")}`, type, properties }, - }) -} - -export function validateTimelineEvent(input: unknown): TimelineEvent { - return decodeEvent(input, decodeOptions) -} - -export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] { - input.forEach((message) => decodeMessage(message, decodeOptions)) - const messages = [...input] - const messageIDs = new Set() - const partIDs = new Set() - const users = new Set(messages.filter((message) => message.info.role === "user").map((message) => message.info.id)) - - messages.forEach((message) => { - if (messageIDs.has(message.info.id)) - throw new Error(`Timeline fixture has duplicate message ID: ${message.info.id}`) - messageIDs.add(message.info.id) - if (message.info.role === "assistant" && !users.has(message.info.parentID)) - throw new Error(`Timeline assistant ${message.info.id} must reference a parent user in the fixture`) - message.parts.forEach((part) => { - if (part.sessionID !== message.info.sessionID || part.messageID !== message.info.id) - throw new Error(`Timeline part ${part.id} ownership does not match message ${message.info.id}`) - if (message.info.role === "user" && !["text", "file", "agent", "subtask"].includes(part.type)) - throw new Error(`Timeline user message ${message.info.id} cannot own ${part.type} part ${part.id}`) - if (message.info.role === "assistant" && ["agent", "subtask"].includes(part.type)) - throw new Error(`Timeline assistant message ${message.info.id} cannot own ${part.type} part ${part.id}`) - if (partIDs.has(part.id)) throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`) - partIDs.add(part.id) - }) - }) - return messages -} - -export async function waitForVisualSettle(page: Page, selectors: string[], stableFrames = 3) { - await page.waitForFunction( - ({ selectors, stableFrames }) => { - const elements = selectors.map((selector) => document.querySelector(selector)) - if (elements.some((element) => !element)) return false - return new Promise((resolve) => { - let stable = 0 - let previous = "" - const sample = () => { - const signature = JSON.stringify( - elements.map((element) => { - const rect = element!.getBoundingClientRect() - return [Math.round(rect.top * 10), Math.round(rect.bottom * 10), Math.round(rect.height * 10)] - }), - ) - stable = signature === previous ? stable + 1 : 0 - previous = signature - const ordered = elements - .slice(1) - .every( - (element, index) => - elements[index]!.getBoundingClientRect().bottom <= element!.getBoundingClientRect().top + 0.5, - ) - if (stable >= stableFrames && ordered) return resolve(true) - requestAnimationFrame(sample) - } - requestAnimationFrame(sample) - }) - }, - { selectors, stableFrames }, - ) -} - -export function historyMessages(count: number): TimelineMessage[] { - return Array.from({ length: count }, (_, index) => { - const value = String(index).padStart(4, "0") - const historyUserID = `msg_0${value}_history_a_user` - return [ - userMessage(undefined, { id: historyUserID, created: 1690000000000 + index * 10_000 }), - assistantMessage( - [ - { - id: `prt_0${value}_history_text`, - type: "text", - text: `Historical response ${index}. ${"Existing session content keeps the virtual timeline realistic. ".repeat(5)}`, - }, - ], - { - id: `msg_0${value}_history_b_assistant`, - parentID: historyUserID, - created: 1690000001000 + index * 10_000, - }, - ), - ] - }).flat() -} - -export function partUpdated(part: Part | PartSeed<"assistant">) { - const owned = "messageID" in part ? part : { ...part, sessionID, messageID: assistantID } - decodePart(owned, decodeOptions) - return event("message.part.updated", { - sessionID, - part: owned, - time: 1700000002000, - }) -} - -export function partDelta(partID: string, delta: string, messageID = assistantID) { - return event("message.part.delta", { sessionID, messageID, partID, field: "text", delta }) -} - -export function messageUpdated(info: Message) { - return event("message.updated", { sessionID, info }) -} - -export function status(type: SessionStatus["type"], attempt = 1) { - return event("session.status", { - sessionID, - status: type === "retry" ? { type, attempt, message: "Rate limited", next: 1700000010000 } : { type }, - }) -} - -export function userMessage( - parts?: PartSeed<"user">[], - input: { id?: string; summary?: UserMessage["summary"]; created?: number } = {}, -): Extract { - const id = input.id ?? userID - const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })] - const message = { - info: { - id, - sessionID, - role: "user", - time: { created: input.created ?? 1700000000000 }, - summary: input.summary ?? { diffs: [] }, - agent: "build", - model, - }, - parts: seeds.map((part) => ({ - ...part, - sessionID, - messageID: id, - })), - } satisfies Extract - decodeMessage(message, decodeOptions) - return message -} - -export function assistantMessage( - parts: PartSeed<"assistant">[] = [], - input: { - id?: string - parentID?: string - completed?: boolean - error?: AssistantMessage["error"] - created?: number - } = {}, -): Extract { - const id = input.id ?? assistantID - const message = { - info: { - id, - sessionID, - role: "assistant", - time: { - created: input.created ?? 1700000001000, - ...(input.completed === false ? {} : { completed: (input.created ?? 1700000001000) + 1_000 }), - }, - parentID: input.parentID ?? userID, - modelID: model.modelID, - providerID: model.providerID, - mode: "build", - agent: "build", - path: { cwd: directory, root: directory }, - cost: 0.01, - tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, - variant: "max", - ...(input.error ? { error: input.error } : {}), - }, - parts: parts.map((part) => ({ ...part, sessionID, messageID: id })), - } satisfies Extract - decodeMessage(message, decodeOptions) - return message -} - -export function userText( - text: string, - input: Partial, { type: "text" }>, "type" | "text">> = {}, -): Extract, { type: "text" }> { - return { id: "prt_user_text", type: "text", text, ...input } -} - -export function textPart(id: string, text: string): Extract, { type: "text" }> { - return { id, type: "text", text } -} - -export function reasoningPart(id: string, text: string): Extract, { type: "reasoning" }> { - return { id, type: "reasoning", text, time: { start: 1700000001000 } } -} - -export function toolPart( - id: string, - tool: string, - state: "pending", - input: Record, - options?: ToolOptions<"pending">, -): Omit -export function toolPart( - id: string, - tool: string, - state: "running", - input: Record, - options?: ToolOptions<"running">, -): Omit -export function toolPart( - id: string, - tool: string, - state: "completed", - input: Record, - options?: ToolOptions<"completed">, -): Omit -export function toolPart( - id: string, - tool: string, - state: "error", - input: Record, - options?: ToolOptions<"error">, -): Omit -export function toolPart( - id: string, - tool: string, - state: ToolStatus, - input: Record, - options: ToolOptions = {}, -): Omit { - const base = { id, type: "tool" as const, callID: `call_${id}`, tool } - if (state === "pending") return { ...base, state: { status: state, input, raw: "" } } - if (state === "running") - return { - ...base, - state: { - status: state, - input, - title: options.title, - metadata: options.metadata ?? {}, - time: { start: 1700000001000 }, - }, - } - if (state === "error") - return { - ...base, - state: { - status: state, - input, - error: options.error ?? "Tool failed", - metadata: options.metadata ?? {}, - time: { start: 1700000001000, end: 1700000002000 }, - }, - } - return { - ...base, - state: { - status: state, - input, - output: options.output ?? "Completed", - title: options.title ?? tool, - metadata: options.metadata ?? {}, - time: { start: 1700000001000, end: 1700000002000 }, - }, - } -} - -export function shell( - id: string, - state: ToolStatus, - output = "", - command = `echo ${id}`, -): Omit { - if (state === "pending") return toolPart(id, "bash", state, { command }) - if (state === "running") - return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } }) - if (state === "error") - return toolPart(id, "bash", state, { command }, { error: output || undefined, metadata: { command, output } }) - return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } }) -} - -export function completedAssistantInfo(info: AssistantMessage): AssistantMessage { - return { ...info, time: { ...info.time, completed: 1700000003000 } } -} - -export function project() { - return { - id: projectID, - worktree: directory, - vcs: "git", - name: "timeline-stability", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - } -} - -export function session(input: Partial = {}): Session { - return { - id: sessionID, - slug: "timeline-stability", - projectID, - directory, - title, - version: "dev", - time: { created: 1700000000000, updated: 1700000000000 }, - ...input, - } -} - -function eventSchema< - const Type extends TimelinePayload["type"], - const Properties extends Schema.Codec, ->(type: Type, properties: Properties) { - return Schema.Struct({ - directory: Schema.String, - project: Schema.optional(Schema.String), - workspace: Schema.optional(Schema.String), - payload: Schema.Struct({ id: Event.ID, type: Schema.Literal(type), properties }), - }) -} - -function provider() { - return { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "claude-opus-4-6" }, - } -} diff --git a/packages/app/e2e/performance/timeline-stability/interaction.spec.ts b/packages/app/e2e/performance/timeline-stability/interaction.spec.ts deleted file mode 100644 index 8cdf4fa8cf..0000000000 --- a/packages/app/e2e/performance/timeline-stability/interaction.spec.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - defineVisualRegions, - reportVisualStability, - startVisualProbe, - stopVisualProbe, - visualPlan, -} from "../../utils/visual-stability" -import { assistantMessage, setupTimeline, shell, textPart, toolPart, userMessage, waitForVisualSettle } from "./fixture" - -test("expands and collapses a long completed shell without overlap", async ({ page }, testInfo) => { - const shellID = "prt_interaction_01_shell" - const followingID = "prt_interaction_02_following" - await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([shell(shellID, "completed", lines(50)), textPart(followingID, "Following shell expansion")]), - ], - settings: { shellToolPartsExpanded: false }, - cpuRate: 4, - seedHistory: true, - }) - const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`) - await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`]) - const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - const plan = visualPlan(regions, [ - { type: "required", regions: ["shell", "following"] }, - { type: "unique", regions: ["shell", "following"] }, - { type: "stable", regions: ["shell", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - { type: "preserve-bottom-anchor" }, - { type: "flow", regions: ["shell", "following"] }, - ]) - await startVisualProbe(page, regions) - await trigger.click() - await expect(trigger).toHaveAttribute("aria-expanded", "true") - await page.waitForTimeout(500) - const expanded = await stopVisualProbe(page) - await reportVisualStability(testInfo, "shell-expand", expanded, plan) - - await startVisualProbe(page, regions) - await trigger.click() - await expect(trigger).toHaveAttribute("aria-expanded", "false") - await page.waitForTimeout(500) - const collapsed = await stopVisualProbe(page) - await reportVisualStability(testInfo, "shell-collapse", collapsed, plan) -}) - -test("expands and collapses a completed context group without overlap", async ({ page }, testInfo) => { - const ids = [ - "prt_interaction_01_read", - "prt_interaction_02_glob", - "prt_interaction_03_grep", - "prt_interaction_04_list", - ] - const group = `[data-timeline-part-ids="${ids.join(",")}"]` - const followingID = "prt_interaction_context_following" - await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }), - toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }), - toolPart(ids[2]!, "grep", "completed", { path: ".", pattern: "stable" }), - toolPart(ids[3]!, "list", "completed", { path: "src" }), - textPart(followingID, "Following context expansion"), - ]), - ], - cpuRate: 4, - seedHistory: true, - }) - const trigger = page.locator(`${group} [data-slot="collapsible-trigger"]`) - await waitForVisualSettle(page, [group, `[data-timeline-part-id="${followingID}"]`]) - for (const [name, expanded] of [ - ["context-expand", true], - ["context-collapse", false], - ["context-reexpand", true], - ] as const) { - const regions = defineVisualRegions({ - context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' }, - following: { - selector: `[data-timeline-part-id="${followingID}"]`, - closest: '[data-timeline-row="AssistantPart"]', - }, - }) - await startVisualProbe(page, regions) - await trigger.click() - await expect(trigger).toHaveAttribute("aria-expanded", String(expanded)) - await page.waitForTimeout(500) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - name, - trace, - visualPlan(regions, [ - { type: "required", regions: ["context", "following"] }, - { type: "unique", regions: ["context", "following"] }, - { type: "stable", regions: ["context", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - { type: "preserve-bottom-anchor" }, - { type: "flow", regions: ["context", "following"] }, - ]), - ) - } -}) - -test("expands and collapses an edit diff without moving twice", async ({ page }, testInfo) => { - const editID = "prt_interaction_edit" - const followingID = "prt_interaction_edit_following" - await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - toolPart( - editID, - "edit", - "completed", - { filePath: "src/edit.ts" }, - { - metadata: { - filediff: { - file: "src/edit.ts", - additions: 40, - deletions: 40, - before: source(40, false), - after: source(40, true), - }, - }, - }, - ), - textPart(followingID, "Following edit expansion"), - ]), - ], - settings: { editToolPartsExpanded: false }, - cpuRate: 4, - seedHistory: true, - }) - const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first() - await waitForVisualSettle(page, [`[data-timeline-part-id="${editID}"]`, `[data-timeline-part-id="${followingID}"]`]) - const regions = defineVisualRegions({ - edit: { selector: `[data-timeline-part-id="${editID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - await startVisualProbe(page, regions) - await trigger.click() - await expect(trigger).toHaveAttribute("aria-expanded", "true") - await page.waitForTimeout(900) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "edit-expand", - trace, - visualPlan(regions, [ - { type: "required", regions: ["edit", "following"] }, - { type: "unique", regions: ["edit", "following"] }, - { type: "stable", regions: ["edit", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 }, - { type: "label-stability", regions: "all" }, - { type: "preserve-bottom-anchor" }, - { type: "flow", regions: ["edit", "following"] }, - ]), - ) -}) - -test("shows all and expands historical diff summary without overlap", async ({ page }, testInfo) => { - const firstUser = userMessage(undefined, { - summary: { - diffs: Array.from({ length: 12 }, (_, index) => ({ - file: `src/diff-${index}.ts`, - additions: 1, - deletions: 1, - patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`, - })), - }, - }) - const nextUserID = "msg_2000_diff_interaction_user" - await setupTimeline(page, { - messages: [ - firstUser, - assistantMessage(), - userMessage(undefined, { id: nextUserID, created: 1700000010000 }), - assistantMessage([], { - id: "msg_2001_diff_interaction_assistant", - parentID: nextUserID, - created: 1700000011000, - }), - ], - cpuRate: 4, - }) - const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - await scroller.evaluate((element) => (element.scrollTop = 0)) - const diff = page.locator('[data-timeline-row="DiffSummary"]') - const following = page.locator(`[data-message-id="${nextUserID}"]`).first() - await expect(diff).toBeVisible() - const regions = defineVisualRegions({ - diff: { selector: '[data-timeline-row="DiffSummary"]' }, - following: { selector: `[data-message-id="${nextUserID}"]` }, - }) - await startVisualProbe(page, regions) - await page.getByText(/show all/i).click() - await page.waitForTimeout(500) - await diff.locator('[data-slot="session-turn-diff-trigger"]').first().click() - await page.waitForTimeout(900) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "diff-summary-expand", - trace, - visualPlan(regions, [ - { type: "required", regions: ["diff", "following"] }, - { type: "unique", regions: ["diff", "following"] }, - { type: "stable", regions: ["diff", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 }, - { type: "label-stability", regions: "all" }, - { type: "flow", regions: ["diff", "following"] }, - ]), - ) -}) - -function lines(count: number) { - return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") -} - -function source(count: number, changed: boolean) { - return Array.from( - { length: count }, - (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`, - ).join("") -} diff --git a/packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts b/packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts deleted file mode 100644 index 40688429a3..0000000000 --- a/packages/app/e2e/performance/timeline-stability/lifecycle.spec.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - defineVisualRegions, - mapVisualRegions, - reportVisualStability, - startVisualProbe, - stopVisualProbe, - visualPlan, -} from "../../utils/visual-stability" -import { - assistantMessage, - completedAssistantInfo, - messageUpdated, - partDelta, - partUpdated, - reasoningPart, - setupTimeline, - shell, - status, - textPart, - userMessage, - waitForVisualSettle, -} from "./fixture" - -test.describe("timeline visual lifecycle stability", () => { - test("streams empty, short, and long parallel shells to staggered completion", async ({ page }, testInfo) => { - test.setTimeout(180_000) - const ids = ["prt_parallel_01_empty", "prt_parallel_02_short", "prt_parallel_03_long"] as const - const initial = ids.map((id) => shell(id, "running")) - const followingID = "prt_parallel_04_following" - const assistant = assistantMessage([...initial, textPart(followingID, "Following all parallel shells.")], { - completed: false, - }) - const timeline = await setupTimeline(page, { - messages: [userMessage(), assistant], - settings: { shellToolPartsExpanded: true, showReasoningSummaries: true }, - cpuRate: 4, - eventRetry: 24, - seedHistory: true, - }) - await timeline.send(status("busy"), 150) - for (const id of ids) await timeline.waitForPart(id) - const scroller = page.locator(".scroll-view__viewport", { - has: page.locator('[data-timeline-row="AssistantPart"]'), - }) - await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight)) - const regions = defineVisualRegions({ - prt_shell_empty: shellRegion(ids[0]), - prt_shell_short: shellRegion(ids[1]), - prt_shell_long: shellRegion(ids[2]), - following: shellRegion(followingID), - }) - await waitForVisualSettle(page, [`[data-timeline-part-id="${followingID}"]`]) - await startVisualProbe(page, regions) - await timeline.sendAll([ - { event: partUpdated(shell(ids[0]!, "completed", "")), delay: 180 }, - { event: partUpdated(shell(ids[2]!, "running", lines(10))), delay: 70 }, - { event: partUpdated(shell(ids[1]!, "running", lines(2))), delay: 110 }, - { event: partUpdated(shell(ids[2]!, "running", lines(25))), delay: 80 }, - { event: partUpdated(shell(ids[1]!, "completed", lines(2))), delay: 260 }, - { event: partUpdated(shell(ids[2]!, "running", lines(50))), delay: 100 }, - { event: partUpdated(shell(ids[2]!, "completed", lines(50))), delay: 450 }, - { event: messageUpdated(completedAssistantInfo(assistant.info)), delay: 100 }, - { event: status("idle"), delay: 700 }, - ]) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "parallel-shells", - trace, - visualPlan( - regions, - [ - { type: "required", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] }, - { type: "unique", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long"] }, - { type: "stable", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 4 }, - { type: "label-stability", regions: "all" }, - { type: "preserve-bottom-anchor" }, - { type: "flow", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] }, - ], - { perMarker: true }, - ), - ) - await expect(page.locator(`[data-timeline-part-id="${ids[2]}"] [data-slot="bash-pre"]`)).toContainText("line 50") - - const short = page.locator(`[data-timeline-part-id="${ids[1]}"]`) - await short.locator('[data-slot="collapsible-trigger"]').click() - await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false") - await timeline.send(partUpdated(textPart("prt_late_sibling", "A later sibling rerender.")), 250) - await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false") - }) - - test("replaces thinking with streamed reasoning and text without a blank visible turn", async ({ - page, - }, testInfo) => { - const reasoningID = "prt_reasoning_visible" - const textID = "prt_streamed_text" - const assistant = assistantMessage([], { completed: false }) - const timeline = await setupTimeline(page, { - messages: [userMessage(), assistant], - settings: { showReasoningSummaries: true }, - cpuRate: 4, - }) - await timeline.send(status("busy"), 120) - await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() - - const regions = defineVisualRegions({ - thinking: { selector: '[data-timeline-row="Thinking"]' }, - reasoning: { - selector: `[data-timeline-part-id="${reasoningID}"]`, - closest: '[data-timeline-row="AssistantPart"]', - }, - text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - await startVisualProbe(page, regions) - await timeline.send(partUpdated(reasoningPart(reasoningID, "")), 100) - await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0) - await timeline.send(partUpdated(reasoningPart(reasoningID, "## Planning\n\nChecking the visible timeline.")), 160) - await timeline.waitForPart(reasoningID) - await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) - await timeline.send(partUpdated(textPart(textID, "Starting")), 100) - await timeline.send(partDelta(textID, " **stable"), 90) - await timeline.send(partDelta(textID, " output** with `code` and [a link"), 130) - await timeline.send(partDelta(textID, "](https://example.com)."), 220) - await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 120) - await timeline.send(status("idle"), 500) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "reasoning-text-handoff", - trace, - visualPlan(regions, [ - { type: "required", regions: ["reasoning", "text"] }, - { type: "continuous-any", regions: ["thinking", "reasoning", "text"] }, - { type: "unique", regions: ["reasoning", "text"] }, - { type: "stable", regions: ["reasoning", "text"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxReversals: 4 }, - { type: "label-stability", regions: "all" }, - { type: "flow", regions: ["reasoning", "text"] }, - ]), - ) - await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("stable output") - }) -}) - -function lines(count: number) { - return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") -} - -function shellRegion(id: string) { - return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' } -} diff --git a/packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts b/packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts deleted file mode 100644 index 7891eba5bd..0000000000 --- a/packages/app/e2e/performance/timeline-stability/oracle-browser.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - analyzeVisualObservations, - defineVisualRegions, - startVisualProbe, - stopVisualProbe, - visualPlan, -} from "../../utils/visual-stability" -import { assistantMessage, setupTimeline, textPart, userMessage } from "./fixture" - -test("detects blanking caused by ancestor opacity", async ({ page }) => { - const partID = "prt_oracle_ancestor_opacity" - await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] }) - const row = page.locator(`[data-timeline-part-id="${partID}"]`).first() - const regions = defineVisualRegions({ - content: { selector: `[data-timeline-part-id="${partID}"]` }, - }) - await startVisualProbe(page, regions) - await row.evaluate((element) => { - element.parentElement!.style.opacity = "0" - }) - await page.waitForTimeout(50) - await row.evaluate((element) => { - element.parentElement!.style.opacity = "1" - }) - await page.waitForTimeout(50) - const trace = await stopVisualProbe(page) - const issues = analyzeVisualObservations( - trace.samples, - visualPlan(regions, [ - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all" }, - { type: "label-stability", regions: "all" }, - ]), - ) - - expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true) -}) - -test("detects root opacity when probing descendant opacity", async ({ page }) => { - const partID = "prt_oracle_descendant_opacity" - await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] }) - const row = page.locator(`[data-timeline-part-id="${partID}"]`).first() - await row.evaluate((element) => { - element.innerHTML = 'Visible content' - }) - const regions = defineVisualRegions({ - content: { - selector: `[data-timeline-part-id="${partID}"]`, - opacitySelectors: ['[data-probe-opacity="true"]'], - }, - }) - await startVisualProbe(page, regions) - await row.evaluate((element) => { - ;(element as HTMLElement).style.opacity = "0" - }) - await page.waitForTimeout(50) - await row.evaluate((element) => { - ;(element as HTMLElement).style.opacity = "1" - }) - await page.waitForTimeout(50) - const trace = await stopVisualProbe(page) - const issues = analyzeVisualObservations( - trace.samples, - visualPlan(regions, [ - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all" }, - { type: "label-stability", regions: "all" }, - ]), - ) - - expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true) -}) diff --git a/packages/app/e2e/performance/timeline-stability/playwright.config.ts b/packages/app/e2e/performance/timeline-stability/playwright.config.ts deleted file mode 100644 index cfa4d92613..0000000000 --- a/packages/app/e2e/performance/timeline-stability/playwright.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import config from "../playwright.config" - -export default { - ...config, - testDir: ".", - testMatch: "**/*.spec.ts", - outputDir: "../../test-results/timeline-stability", - reporter: [["html", { outputFolder: "../../playwright-report/timeline-stability", open: "never" }], ["line"]], - retries: 0, - workers: 1, - use: { - ...config.use, - trace: "retain-on-failure", - screenshot: "only-on-failure", - video: "retain-on-failure", - }, -} diff --git a/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts b/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts deleted file mode 100644 index e67545104d..0000000000 --- a/packages/app/e2e/performance/timeline-stability/scroll-interaction.spec.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - defineVisualRegions, - reportVisualStability, - startVisualProbe, - stopVisualProbe, - visualPlan, -} from "../../utils/visual-stability" -import { - assistantMessage, - partUpdated, - setupTimeline, - shell, - textPart, - userMessage, - type TimelineMessage, -} from "./fixture" - -test("does not reverse visible rows when the user wheels during shell remeasurement", async ({ page }, testInfo) => { - const shellID = "prt_wheel_01_shell" - const followingID = "prt_wheel_02_following" - const timeline = await setupTimeline(page, { - messages: [ - ...history(12), - userMessage(), - assistantMessage([shell(shellID, "running"), textPart(followingID, "Following wheel interaction")], { - completed: false, - }), - ], - settings: { shellToolPartsExpanded: true }, - cpuRate: 4, - reducedMotion: true, - seedHistory: true, - }) - const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - await startVisualProbe(page, regions) - await timeline.send(partUpdated(shell(shellID, "running", lines(30))), 80) - await scroller.evaluate((element) => - element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -180 })), - ) - await scroller.evaluate((element) => (element.scrollTop -= 180)) - await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 250) - const trace = await stopVisualProbe(page) - await reportVisualStability(testInfo, "wheel-during-resize", trace, rowPairPlan(regions, 1)) -}) - -test("keeps moving upward while drag-selecting above the timeline", async ({ page }) => { - await setupTimeline(page, { - messages: history(80), - viewport: { width: 1400, height: 700 }, - reducedMotion: true, - }) - const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - const text = page.getByText("History 79.", { exact: false }) - await expect(text).toBeVisible() - await scroller.evaluate((element) => { - element.dataset.selectionLength = "0" - document.addEventListener("selectionchange", () => { - element.dataset.selectionLength = String( - Math.max(Number(element.dataset.selectionLength), window.getSelection()?.toString().length ?? 0), - ) - }) - }) - const textBox = await text.boundingBox() - const scrollBox = await scroller.boundingBox() - expect(textBox).not.toBeNull() - expect(scrollBox).not.toBeNull() - if (!textBox || !scrollBox) return - - await page.mouse.move(textBox.x + textBox.width - 10, textBox.y + textBox.height / 2) - await page.mouse.down() - await page.mouse.move(textBox.x + 20, scrollBox.y - 120, { steps: 30 }) - - await expect.poll(() => scroller.evaluate((element) => Number(element.dataset.selectionLength))).toBeGreaterThan(0) - await expect - .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) - .toBeGreaterThan(500) - await page.mouse.up() -}) - -test("does not pull a keyboard-scrolled user during shell remeasurement", async ({ page }, testInfo) => { - const shellID = "prt_keyboard_01_shell" - const followingID = "prt_keyboard_02_following" - const timeline = await setupTimeline(page, { - messages: [ - ...history(12), - userMessage(), - assistantMessage([shell(shellID, "running"), textPart(followingID, "Following keyboard interaction")], { - completed: false, - }), - ], - settings: { shellToolPartsExpanded: true }, - cpuRate: 4, - }) - const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - await scroller.focus() - for (let index = 0; index < 3; index++) { - await scroller.press("PageUp") - await page.waitForTimeout(250) - } - await expect - .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop), { - timeout: 20_000, - }) - .toBeGreaterThan(80) - await page.waitForFunction(() => { - const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => - element.querySelector("[data-timeline-row]"), - ) - if (!root) return false - return new Promise((resolve) => { - const top = root.scrollTop - requestAnimationFrame(() => requestAnimationFrame(() => resolve(Math.abs(root.scrollTop - top) <= 0.5))) - }) - }) - const anchor = await scroller.evaluate((element) => { - const view = element.getBoundingClientRect() - return [...element.querySelectorAll("[data-timeline-key]")].find((row) => { - const rect = row.getBoundingClientRect() - return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40 - })?.dataset.timelineKey - }) - expect(anchor).toBeTruthy() - const regions = defineVisualRegions({ - anchor: { selector: `[data-timeline-key="${anchor}"]` }, - }) - await startVisualProbe(page, regions) - await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 400) - const trace = await stopVisualProbe(page) - await reportVisualStability(testInfo, "keyboard-during-resize", trace, anchorPlan(regions)) -}) - -test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => { - const shellID = "prt_descendant_keyboard_01_shell" - const timeline = await setupTimeline(page, { - messages: [...history(12), userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])], - settings: { shellToolPartsExpanded: false }, - cpuRate: 4, - reducedMotion: true, - }) - const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - const row = page.locator(`[data-timeline-part-id="${shellID}"]`).first() - const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`) - await row.evaluate((element) => element.setAttribute("tabindex", "0")) - await row.focus() - for (let index = 0; index < 3; index++) { - await row.press("PageUp") - await page.waitForTimeout(250) - } - await expect - .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) - .toBeGreaterThan(5) - const anchor = await scroller.evaluate((element) => { - const view = element.getBoundingClientRect() - return [...element.querySelectorAll("[data-timeline-key]")].find((row) => { - const rect = row.getBoundingClientRect() - return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40 - })?.dataset.timelineKey - }) - expect(anchor).toBeTruthy() - const regions = defineVisualRegions({ - anchor: { selector: `[data-timeline-key="${anchor}"]` }, - }) - await startVisualProbe(page, regions) - await trigger.click() - await page.waitForTimeout(300) - const trace = await stopVisualProbe(page) - await reportVisualStability(testInfo, "descendant-keyboard-resize", trace, anchorPlan(regions)) -}) - -test("does not claim keyboard scrolling owned by a nested scrollable", async ({ page }) => { - const shellID = "prt_nested_keyboard_shell" - await setupTimeline(page, { - messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(50))])], - settings: { shellToolPartsExpanded: true }, - cpuRate: 4, - reducedMotion: true, - seedHistory: true, - }) - const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - const nested = page.locator(`[data-timeline-part-id="${shellID}"] [data-scrollable]`) - await nested.evaluate((element) => (element.scrollTop = element.scrollHeight)) - await nested.focus() - await page.waitForFunction(() => { - const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => - element.querySelector("[data-timeline-row]"), - ) - if (!root) return false - return new Promise((resolve) => { - const top = root.scrollTop - requestAnimationFrame(() => requestAnimationFrame(() => resolve(Math.abs(root.scrollTop - top) <= 0.5))) - }) - }) - const before = await scroller.evaluate((element) => element.scrollTop) - const nestedBefore = await nested.evaluate((element) => element.scrollTop) - await nested.press("PageUp") - await page.waitForTimeout(300) - expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before) - expect(await nested.evaluate((element) => element.scrollTop)).toBeLessThan(nestedBefore) - - await nested.evaluate((element) => (element.scrollTop = 0)) - await scroller.evaluate((element) => (element.scrollTop = Math.min(300, element.scrollHeight - element.clientHeight))) - const boundaryBefore = await scroller.evaluate((element) => element.scrollTop) - expect(boundaryBefore).toBeGreaterThan(0) - await nested.press("PageUp") - await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(boundaryBefore) - - const nonOverflowing = page.locator(`[data-timeline-part-id="${shellID}"]`).first() - await nonOverflowing.evaluate((element) => { - element.setAttribute("data-scrollable", "") - element.setAttribute("tabindex", "0") - }) - await nonOverflowing.focus() - const nonOverflowBefore = await scroller.evaluate((element) => element.scrollTop) - await nonOverflowing.press("PageUp") - await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(nonOverflowBefore) -}) - -test("jump to latest lands on stable final rows after offscreen growth", async ({ page }, testInfo) => { - const shellID = "prt_jump_01_shell" - const followingID = "prt_jump_02_following" - const timeline = await setupTimeline(page, { - messages: [ - ...history(20), - userMessage(), - assistantMessage([shell(shellID, "running"), textPart(followingID, "Latest visible row")], { completed: false }), - ], - settings: { shellToolPartsExpanded: true }, - cpuRate: 4, - }) - const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - await scroller.evaluate( - (element) => (element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 600)), - ) - await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 300) - const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - await startVisualProbe(page, regions) - await page.getByRole("button", { name: /Jump to latest/i }).click() - await expect(page.locator(`[data-timeline-part-id="${followingID}"]`)).toBeVisible() - await page.waitForTimeout(600) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "jump-latest", - trace, - visualPlan(regions, [ - { type: "required", regions: ["shell", "following"] }, - { type: "unique", regions: ["shell", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 1 }, - { type: "label-stability", regions: "all" }, - { type: "acquire-bottom-anchor" }, - { type: "flow", regions: ["shell", "following"] }, - ]), - ) -}) - -test("handles a single row taller than the viewport", async ({ page }, testInfo) => { - const shellID = "prt_tall_01_shell" - const followingID = "prt_tall_02_following" - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([shell(shellID, "running"), textPart(followingID, "After tall row")], { completed: false }), - ], - settings: { shellToolPartsExpanded: true }, - viewport: { width: 900, height: 360 }, - cpuRate: 4, - seedHistory: true, - }) - const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - await startVisualProbe(page, regions) - await timeline.send(partUpdated(shell(shellID, "completed", lines(100))), 700) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "taller-than-viewport", - trace, - visualPlan(regions, [ - { type: "required", regions: ["shell", "following"] }, - { type: "unique", regions: ["shell", "following"] }, - { type: "stable", regions: ["shell", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - { type: "preserve-bottom-anchor" }, - { type: "flow", regions: ["shell", "following"] }, - ]), - ) -}) - -function history(count: number): TimelineMessage[] { - return Array.from({ length: count }, (_, index) => { - const prefix = `msg_${String(index).padStart(4, "0")}_scroll` - const userID = `${prefix}_a_user` - return [ - userMessage(undefined, { id: userID, created: 1690000000000 + index * 10_000 }), - assistantMessage( - [textPart(`prt_${String(index).padStart(4, "0")}_scroll`, `History ${index}. ${"content ".repeat(30)}`)], - { - id: `${prefix}_b_assistant`, - parentID: userID, - created: 1690000001000 + index * 10_000, - }, - ), - ] - }).flat() -} - -function lines(count: number) { - return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") -} - -function rowPairPlan( - regions: Record<"shell" | "following", { selector: string; closest?: string }>, - maxPositionReversals: number, -) { - return visualPlan(regions, [ - { type: "required", regions: ["shell", "following"] }, - { type: "unique", regions: ["shell", "following"] }, - { type: "stable", regions: ["shell", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals }, - { type: "label-stability", regions: "all" }, - { type: "flow", regions: ["shell", "following"] }, - ]) -} - -function anchorPlan(regions: Record<"anchor", { selector: string; closest?: string }>) { - return visualPlan(regions, [ - { type: "required", regions: ["anchor"] }, - { type: "unique", regions: ["anchor"] }, - { type: "stable", regions: ["anchor"] }, - { type: "fixed", regions: ["anchor"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - ]) -} diff --git a/packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts deleted file mode 100644 index bab1ca23b4..0000000000 --- a/packages/app/e2e/performance/timeline-stability/shell-matrix.spec.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { test } from "@playwright/test" -import { - defineVisualRegions, - reportVisualStability, - startVisualProbe, - stopVisualProbe, - visualPlan, -} from "../../utils/visual-stability" -import { - assistantMessage, - partUpdated, - setupTimeline, - shell, - textPart, - userMessage, - waitForVisualSettle, -} from "./fixture" - -const profiles = [ - { - name: "empty running to completed", - updates: [{ state: "completed" as const, output: "", delay: 350 }], - }, - { - name: "50 lines arriving incrementally", - updates: [ - { state: "running" as const, output: lines(1), delay: 100 }, - { state: "running" as const, output: lines(10), delay: 160 }, - { state: "running" as const, output: lines(25), delay: 90 }, - { state: "running" as const, output: lines(50), delay: 220 }, - { state: "completed" as const, output: lines(50), delay: 500 }, - ], - }, - { - name: "wide ANSI and CRLF output", - updates: [ - { - state: "running" as const, - output: Array.from({ length: 20 }, (_, index) => `\u001b[32mline ${index}\u001b[0m ${"wide-".repeat(30)}`).join( - "\r\n", - ), - delay: 240, - }, - { - state: "completed" as const, - output: Array.from({ length: 20 }, (_, index) => `line ${index} ${"wide-".repeat(30)}`).join("\n"), - delay: 500, - }, - ], - }, -] as const - -for (const profile of profiles) { - test(`keeps rows stable for shell ${profile.name}`, async ({ page }, testInfo) => { - const shellID = `prt_matrix_${profiles.indexOf(profile)}_01_shell` - const followingID = `prt_matrix_${profiles.indexOf(profile)}_02_following` - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([shell(shellID, "running"), textPart(followingID, "Following shell row")], { - completed: false, - }), - ], - settings: { shellToolPartsExpanded: true }, - cpuRate: 4, - seedHistory: true, - }) - const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight)) - await waitForVisualSettle(page, [ - `[data-timeline-part-id="${shellID}"]`, - `[data-timeline-part-id="${followingID}"]`, - ]) - const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { - selector: `[data-timeline-part-id="${followingID}"]`, - closest: '[data-timeline-row="AssistantPart"]', - }, - }) - await startVisualProbe(page, regions) - for (const update of profile.updates) { - await timeline.send(partUpdated(shell(shellID, update.state, update.output)), update.delay) - } - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - `shell-${profiles.indexOf(profile)}`, - trace, - visualPlan( - regions, - [ - { type: "required", regions: ["shell", "following"] }, - { type: "unique", regions: ["shell", "following"] }, - { type: "stable", regions: ["shell", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 }, - { type: "label-stability", regions: "all" }, - { type: "preserve-bottom-anchor" }, - { type: "flow", regions: ["shell", "following"] }, - ], - { perMarker: true }, - ), - ) - }) -} - -test("keeps following row stable when a collapsed shell receives 50 lines", async ({ page }, testInfo) => { - const shellID = "prt_matrix_collapsed_01_shell" - const followingID = "prt_matrix_collapsed_02_following" - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([shell(shellID, "running"), textPart(followingID, "Following collapsed shell")], { - completed: false, - }), - ], - settings: { shellToolPartsExpanded: false }, - cpuRate: 4, - seedHistory: true, - }) - await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`]) - const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - await startVisualProbe(page, regions) - await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 240) - await timeline.send(partUpdated(shell(shellID, "completed", lines(50))), 500) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "collapsed-shell", - trace, - visualPlan( - regions, - [ - { type: "required", regions: ["shell", "following"] }, - { type: "unique", regions: ["shell", "following"] }, - { type: "stable", regions: ["shell", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: ["following"], maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - { type: "flow", regions: ["shell", "following"] }, - ], - { perMarker: true }, - ), - ) -}) - -test("keeps rows stable when a running shell becomes an error", async ({ page }, testInfo) => { - const shellID = "prt_matrix_error_01_shell" - const followingID = "prt_matrix_error_02_following" - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([shell(shellID, "running", lines(10)), textPart(followingID, "Following failed shell")], { - completed: false, - }), - ], - settings: { shellToolPartsExpanded: true }, - cpuRate: 4, - seedHistory: true, - }) - await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`]) - const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - await startVisualProbe(page, regions) - await timeline.send( - partUpdated({ - ...shell(shellID, "error"), - state: { - status: "error", - input: { command: `echo ${shellID}` }, - error: "Command failed after output", - metadata: {}, - time: { start: 1700000001000, end: 1700000002000 }, - }, - }), - 500, - ) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "shell-error", - trace, - visualPlan( - regions, - [ - { type: "required", regions: ["shell", "following"] }, - { type: "unique", regions: ["shell", "following"] }, - { type: "stable", regions: ["shell", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: ["following"], maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - { type: "preserve-bottom-anchor" }, - { type: "flow", regions: ["shell", "following"] }, - ], - { perMarker: true }, - ), - ) -}) - -test("keeps rows stable when later text arrives before shell output", async ({ page }, testInfo) => { - const shellID = "prt_late_text_01_shell" - const followingID = "prt_late_text_02_following" - const timeline = await setupTimeline(page, { - messages: [userMessage(), assistantMessage([shell(shellID, "running")], { completed: false })], - settings: { shellToolPartsExpanded: true }, - cpuRate: 4, - seedHistory: true, - }) - await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`]) - const regions = defineVisualRegions({ - shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { - selector: `[data-timeline-part-id="${followingID}"]`, - closest: '[data-timeline-row="AssistantPart"]', - }, - }) - await startVisualProbe(page, regions) - await timeline.send(partUpdated(textPart(followingID, "Later assistant content arrived before shell output.")), 240) - await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 300) - await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 600) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "late-text-before-shell-output", - trace, - visualPlan( - regions, - [ - { type: "required", regions: ["shell", "following"] }, - { type: "unique", regions: ["shell", "following"] }, - { type: "stable", regions: ["shell"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - { type: "preserve-bottom-anchor" }, - { type: "flow", regions: ["shell", "following"] }, - ], - { perMarker: true }, - ), - ) -}) - -function lines(count: number) { - return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") -} diff --git a/packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts b/packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts deleted file mode 100644 index 03690ec921..0000000000 --- a/packages/app/e2e/performance/timeline-stability/tool-mutation.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - defineVisualRegions, - reportVisualStability, - startVisualProbe, - stopVisualProbe, - visualPlan, -} from "../../utils/visual-stability" -import { - assistantMessage, - partUpdated, - session, - sessionID, - setupTimeline, - textPart, - toolPart, - userMessage, -} from "./fixture" - -test("adds a task child-session link without replacing the task row", async ({ page }, testInfo) => { - const taskID = "prt_task_link" - const childID = "ses_task_child" - const input = { description: "Inspect child", subagent_type: "explore" } - const timeline = await setupTimeline(page, { - messages: [userMessage(), assistantMessage([toolPart(taskID, "task", "running", input)], { completed: false })], - sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect child" })], - cpuRate: 4, - }) - const regions = defineVisualRegions({ - task: { selector: `[data-timeline-part-id="${taskID}"] [data-slot="collapsible-trigger"]` }, - }) - await startVisualProbe(page, regions) - await timeline.send( - partUpdated(toolPart(taskID, "task", "completed", input, { metadata: { sessionId: childID } })), - 500, - ) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "task-link", - trace, - visualPlan(regions, [ - { type: "required", regions: ["task"] }, - { type: "unique", regions: ["task"] }, - { type: "stable", regions: ["task"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - ]), - ) - await expect( - page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }), - ).toBeVisible() -}) - -test("changes generic tool arguments without replacing the row", async ({ page }, testInfo) => { - const toolID = "prt_generic_mutation" - const followingID = "prt_generic_mutation_following" - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage( - [ - toolPart(toolID, "mcp_probe", "running", { target: "one", count: 1 }), - textPart(followingID, "Following generic tool"), - ], - { completed: false }, - ), - ], - cpuRate: 4, - }) - const regions = defineVisualRegions({ - tool: { selector: `[data-timeline-part-id="${toolID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - await startVisualProbe(page, regions) - await timeline.send( - partUpdated(toolPart(toolID, "mcp_probe", "running", { target: "two", count: 2, mode: "deep" })), - 200, - ) - await timeline.send( - partUpdated(toolPart(toolID, "mcp_probe", "completed", { target: "two", count: 2, mode: "deep" })), - 400, - ) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "generic-mutation", - trace, - visualPlan( - regions, - [ - { type: "required", regions: ["tool", "following"] }, - { type: "unique", regions: ["tool", "following"] }, - { type: "stable", regions: ["tool", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - { type: "flow", regions: ["tool", "following"] }, - ], - { perMarker: true }, - ), - ) -}) diff --git a/packages/app/e2e/performance/timeline-stability/tools.spec.ts b/packages/app/e2e/performance/timeline-stability/tools.spec.ts deleted file mode 100644 index d28fdaa65f..0000000000 --- a/packages/app/e2e/performance/timeline-stability/tools.spec.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - defineVisualRegions, - reportVisualStability, - startVisualProbe, - stopVisualProbe, - visualPlan, -} from "../../utils/visual-stability" -import { - assistantMessage, - directory, - partUpdated, - session, - sessionID, - setupTimeline, - status, - textPart, - toolPart, - userMessage, -} from "./fixture" - -test.describe("timeline tool state stability", () => { - test("moves lightweight tools through pending, running, and completed without replacing rows", async ({ - page, - }, testInfo) => { - const ids = ["webfetch", "websearch", "task", "skill", "custom"] as const - const inputs = { - webfetch: { url: "https://example.com/docs" }, - websearch: { query: "timeline stability" }, - task: { description: "Inspect timeline", subagent_type: "explore" }, - skill: { name: "stability" }, - custom: { target: "timeline", depth: 2 }, - } - const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" } - const questionID = "prt_state_question" - const todoID = "prt_state_todo" - const initial = [ - ...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])), - toolPart(questionID, "question", "pending", questionInput()), - toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }), - textPart("prt_state_following", "Following lightweight tools"), - ] - const childID = "ses_timeline_child" - const timeline = await setupTimeline(page, { - messages: [userMessage(), assistantMessage(initial, { completed: false })], - sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect timeline" })], - cpuRate: 4, - }) - await timeline.send(status("busy"), 120) - for (const id of ids) await timeline.waitForPart(`prt_state_${id}`) - await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) - await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0) - - const regionIDs = [ - "prt_state_webfetch", - "prt_state_websearch", - "prt_state_task", - "prt_state_skill", - "prt_state_custom", - ] as const - const regions = defineVisualRegions({ - prt_state_webfetch: toolRegion(regionIDs[0]), - prt_state_websearch: toolRegion(regionIDs[1]), - prt_state_task: toolRegion(regionIDs[2]), - prt_state_skill: toolRegion(regionIDs[3]), - prt_state_custom: toolRegion(regionIDs[4]), - }) - await startVisualProbe(page, regions) - for (const [index, id] of ids.entries()) { - await timeline.send( - partUpdated(toolPart(`prt_state_${id}`, names[id], "running", inputs[id])), - [80, 240, 100, 360, 140][index], - ) - } - for (const [index, id] of ["skill", "webfetch", "custom", "task", "websearch"].entries()) { - const key = id as (typeof ids)[number] - const metadata = key === "task" ? { sessionId: childID } : key === "websearch" ? { provider: "exa" } : {} - const output = key === "websearch" ? "Result https://example.com/result" : "Completed" - await timeline.send( - partUpdated(toolPart(`prt_state_${key}`, names[key], "completed", inputs[key], { metadata, output })), - [110, 70, 280, 130, 420][index], - ) - } - await timeline.send( - partUpdated( - toolPart(questionID, "question", "completed", questionInput(), { metadata: { answers: [["Keep it stable"]] } }), - ), - 350, - ) - await timeline.waitForPart(questionID) - await timeline.send(status("idle"), 500) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "lightweight-tools", - trace, - visualPlan(regions, [ - { type: "required", regions: regionIDs }, - { type: "unique", regions: regionIDs }, - { type: "stable", regions: regionIDs }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxReversals: 4 }, - { type: "label-stability", regions: "all" }, - ]), - ) - await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable") - await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0) - await expect( - page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }), - ).toBeVisible() - await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible() - }) - - test("keeps an expanded mixed context group stable through staggered completion and error", async ({ - page, - }, testInfo) => { - const ids = ["prt_ctx_01_read", "prt_ctx_02_glob", "prt_ctx_03_grep", "prt_ctx_04_list"] - const tools = ["read", "glob", "grep", "list"] - const inputs = [ - { filePath: "src/a.ts", offset: 0, limit: 120 }, - { path: directory, pattern: "**/*.ts" }, - { path: directory, pattern: "stability", include: "*.ts" }, - { path: "src" }, - ] - const context = ids.map((id, index) => toolPart(id, tools[index]!, "pending", inputs[index]!)) - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([...context, textPart("prt_ctx_following", "Following context")], { completed: false }), - ], - cpuRate: 4, - }) - await timeline.send(status("busy"), 100) - const groupSelector = `[data-timeline-part-ids="${ids.join(",")}"]` - const group = page.locator(groupSelector) - await expect(group).toBeVisible() - await group.locator('[data-slot="collapsible-trigger"]').click() - await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true") - - const regions = defineVisualRegions({ - status: { - selector: `${groupSelector} [data-component="tool-status-title"]`, - opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'], - }, - context: { selector: groupSelector, closest: '[data-timeline-row="AssistantPart"]' }, - following: { - selector: '[data-timeline-part-id="prt_ctx_following"]', - closest: '[data-timeline-row="AssistantPart"]', - }, - }) - await startVisualProbe(page, regions) - for (const [index, delay] of [90, 260, 70, 380].entries()) { - await timeline.send(partUpdated(toolPart(ids[index]!, tools[index]!, "running", inputs[index]!)), delay) - } - await timeline.send(partUpdated(toolPart(ids[1]!, tools[1]!, "completed", inputs[1]!)), 130) - await timeline.send(partUpdated(toolPart(ids[3]!, tools[3]!, "completed", inputs[3]!)), 210) - await timeline.send( - partUpdated(toolPart(ids[0]!, tools[0]!, "error", inputs[0]!, { error: "Read interrupted" })), - 110, - ) - await timeline.send(partUpdated(toolPart(ids[2]!, tools[2]!, "completed", inputs[2]!)), 250) - await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored") - await timeline.send(status("idle"), 700) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "mixed-context", - trace, - visualPlan(regions, [ - { type: "required", regions: ["context", "following"] }, - { type: "unique", regions: ["context"] }, - { type: "stable", regions: ["context"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxReversals: 4 }, - { type: "label-stability", regions: "all" }, - { type: "flow", regions: ["context", "following"] }, - ]), - ) - await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored") - await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true") - await group.locator('[data-slot="collapsible-trigger"]').click() - await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false") - await timeline.send(partUpdated(textPart("prt_ctx_late_sibling", "Later sibling content")), 200) - await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false") - await group.locator('[data-slot="collapsible-trigger"]').click() - await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true") - }) -}) - -function questionInput() { - return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] } -} - -function toolRegion(id: string) { - return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' } -} diff --git a/packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts deleted file mode 100644 index e999e8c50a..0000000000 --- a/packages/app/e2e/performance/timeline-stability/transition-matrix.spec.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - defineVisualRegions, - reportVisualStability, - startVisualProbe, - stopVisualProbe, - visualPlan, -} from "../../utils/visual-stability" -import { - assistantID, - assistantMessage, - completedAssistantInfo, - event, - messageUpdated, - partDelta, - partUpdated, - setupTimeline, - shell, - status, - textPart, - toolPart, - userMessage, -} from "./fixture" - -test("keeps unchanged siblings stable while a middle part is inserted and removed", async ({ page }, testInfo) => { - const firstID = "prt_mutation_01_first" - const middleID = "prt_mutation_02_middle" - const lastID = "prt_mutation_03_last" - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([textPart(firstID, "First stable row"), textPart(lastID, "Last stable row")], { - completed: false, - }), - ], - cpuRate: 4, - }) - const regions = defineVisualRegions({ - first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - await startVisualProbe(page, regions) - await timeline.send(partUpdated(textPart(middleID, "Inserted middle row. ".repeat(12))), 350) - await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toBeVisible() - await timeline.send( - event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: middleID }), - 500, - ) - await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toHaveCount(0) - const trace = await stopVisualProbe(page) - await reportVisualStability(testInfo, "middle-insert-remove", trace, stablePairPlan(regions, 1)) -}) - -test("streams text through growth, canonical replacement, and completion", async ({ page }, testInfo) => { - const textID = "prt_text_reconcile" - const followingID = "prt_text_reconcile_following" - const assistant = assistantMessage([textPart(textID, "Starting"), textPart(followingID, "Following text row")], { - completed: false, - }) - const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 }) - const regions = defineVisualRegions({ - text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - await startVisualProbe(page, regions) - await timeline.send(partDelta(textID, " streamed content"), 100) - await timeline.send(partDelta(textID, "\n\n- item one\n- item two\n- item three"), 180) - await timeline.send(partUpdated(textPart(textID, "Canonical replacement with a shorter final paragraph.")), 200) - await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 500) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "text-reconcile", - trace, - visualPlan(regions, [ - { type: "required", regions: ["text", "following"] }, - { type: "unique", regions: ["text", "following"] }, - { type: "stable", regions: ["text", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 }, - { type: "label-stability", regions: "all" }, - { type: "preserve-bottom-anchor" }, - { type: "flow", regions: ["text", "following"] }, - ]), - ) -}) - -test("inserts a completed question between stable rows", async ({ page }, testInfo) => { - const firstID = "prt_question_01_first" - const questionID = "prt_question_02_hidden" - const lastID = "prt_question_03_last" - const input = { questions: [{ header: "Choice", question: "Keep stable?", options: [] }] } - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage( - [ - textPart(firstID, "Before question"), - toolPart(questionID, "question", "running", input), - textPart(lastID, "After question"), - ], - { completed: false }, - ), - ], - cpuRate: 4, - }) - await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) - const regions = defineVisualRegions({ - first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' }, - }) - await startVisualProbe(page, regions) - await timeline.send( - partUpdated(toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Yes"]] } })), - 600, - ) - await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toBeVisible() - const trace = await stopVisualProbe(page) - await reportVisualStability(testInfo, "question-insert", trace, stablePairPlan(regions, 0)) -}) - -test("replaces thinking with an assistant error without a blank turn", async ({ page }, testInfo) => { - const assistant = assistantMessage([], { completed: false }) - const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 }) - await timeline.send(status("busy"), 150) - await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() - const regions = defineVisualRegions({ - thinking: { selector: '[data-timeline-row="Thinking"]' }, - error: { selector: '[data-timeline-row="Error"]' }, - }) - await startVisualProbe(page, regions) - await timeline.send( - messageUpdated({ - ...assistant.info, - error: { name: "APIError", data: { message: "Provider failed visibly", isRetryable: false } }, - }), - 500, - ) - await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) - await expect(page.locator('[data-timeline-row="Error"]')).toContainText("Provider failed visibly") - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "thinking-error", - trace, - visualPlan(regions, [ - { type: "required", regions: ["thinking", "error"] }, - { type: "continuous-any", regions: ["thinking", "error"] }, - { type: "unique", regions: ["thinking", "error"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all" }, - { type: "label-stability", regions: "all" }, - ]), - ) -}) - -test("updates retry attempts and long provider messages without remounting the retry row", async ({ - page, -}, testInfo) => { - const timeline = await setupTimeline(page, { - messages: [userMessage(), assistantMessage([], { completed: false })], - cpuRate: 4, - }) - await timeline.send(status("retry", 1), 120) - await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible() - const regions = defineVisualRegions({ - retry: { selector: '[data-timeline-row="Retry"]' }, - }) - await startVisualProbe(page, regions) - await timeline.send( - event("session.status", { - sessionID: "ses_timeline_stability", - status: { - type: "retry", - attempt: 2, - message: "A very long provider retry message ".repeat(8), - next: Date.now() + 10_000, - }, - }), - 300, - ) - await timeline.send(status("retry", 3), 300) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "retry-evolution", - trace, - visualPlan( - regions, - [ - { type: "required", regions: ["retry"] }, - { type: "unique", regions: ["retry"] }, - { type: "stable", regions: ["retry"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - ], - { perMarker: true }, - ), - ) -}) - -test("reducer-hardening: removes a historical turn one message at a time without moving a visible lower anchor twice", async ({ - page, -}, testInfo) => { - const removeUserID = "msg_0500_remove_user" - const removeAssistantID = "msg_0501_remove_assistant" - const anchorUserID = "msg_2000_anchor_user" - const timeline = await setupTimeline(page, { - messages: [ - userMessage(undefined, { id: removeUserID, created: 1690000000000 }), - assistantMessage([textPart("prt_remove_text", "Removed historical content. ".repeat(15))], { - id: removeAssistantID, - parentID: removeUserID, - created: 1690000001000, - }), - userMessage(undefined, { id: anchorUserID, created: 1700000000000 }), - assistantMessage([textPart("prt_anchor_text", "Visible anchor response")], { - id: "msg_2001_anchor_assistant", - parentID: anchorUserID, - created: 1700000001000, - }), - ], - cpuRate: 4, - }) - const regions = defineVisualRegions({ - anchor: { selector: `[data-timeline-row="UserMessage"][data-message-id="${anchorUserID}"]` }, - }) - await startVisualProbe(page, regions) - await timeline.send( - event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeAssistantID }), - 200, - ) - await timeline.send(event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeUserID }), 500) - const trace = await stopVisualProbe(page) - await reportVisualStability( - testInfo, - "historical-turn-remove", - trace, - visualPlan( - regions, - [ - { type: "required", regions: ["anchor"] }, - { type: "unique", regions: ["anchor"] }, - { type: "stable", regions: ["anchor"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals: 0 }, - { type: "label-stability", regions: "all" }, - ], - { perMarker: true }, - ), - ) -}) - -function stablePairPlan( - regions: Record<"first" | "last", { selector: string; closest?: string }>, - maxPositionReversals: number, -) { - return visualPlan(regions, [ - { type: "required", regions: ["first", "last"] }, - { type: "unique", regions: ["first", "last"] }, - { type: "stable", regions: ["first", "last"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all", maxPositionReversals }, - { type: "label-stability", regions: "all" }, - ]) -} diff --git a/packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts b/packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts deleted file mode 100644 index 81de0a055f..0000000000 --- a/packages/app/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts +++ /dev/null @@ -1,312 +0,0 @@ -import type { Page } from "@playwright/test" -import { benchmark, expect } from "../benchmark" -import { setupTimelineBenchmark } from "./session-timeline-benchmark.fixture" - -const changedLinesPerFile = 100 -const linesPerSide = changedLinesPerFile / 2 -const fileCounts = [1, 10, 100, 1_000, 10_000] -const filesPerDirectory = 100 -const readyFrames = 3 -const completionTimeoutMs = Number(process.env.REVIEW_PANE_COMPLETION_TIMEOUT_MS ?? 900_000) - -type ReviewPaneScalingSample = { - observedAtMs: number - logicalRows: number - treeRows: number - fileRows: number - diffLines: number - header: string - ready: boolean -} - -type ReviewPaneScalingProbe = { - startedAt?: number - firstTreeRowMs?: number - logicalTreeReadyMs?: number - firstDiffRenderMs?: number - stableReadyMs?: number - samples: ReviewPaneScalingSample[] - frameTimesMs: number[] - longTasks: { startTime: number; duration: number }[] - stop: () => void -} - -benchmark.describe("performance: review pane scaling", () => { - for (const fileCount of fileCounts) { - const changedLines = fileCount * changedLinesPerFile - - benchmark( - `${changedLines} changed lines across ${fileCount} ${fileCount === 1 ? "file" : "files"}`, - async ({ page, report }) => { - benchmark.setTimeout(1_200_000) - await page.emulateMedia({ reducedMotion: "reduce" }) - - const patchByteLimit = Number(process.env.REVIEW_PANE_PATCH_BYTE_LIMIT ?? Number.POSITIVE_INFINITY) - if (Number.isNaN(patchByteLimit) || patchByteLimit < 0) - throw new Error(`Invalid REVIEW_PANE_PATCH_BYTE_LIMIT: ${process.env.REVIEW_PANE_PATCH_BYTE_LIMIT}`) - const responseBody = JSON.stringify(createScalingDiffs(fileCount, patchByteLimit)) - await setupTimelineBenchmark(page, { - historyTurns: 0, - eventBatch: 1, - newLayoutDesigns: true, - }) - await page.route("**/vcs/diff**", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: responseBody, - }), - ) - - const expectedRows = fileCount + 2 + Math.ceil(fileCount / filesPerDirectory) - const metrics = await measureReviewPaneLoad(page, { - expectedFile: reviewFile(0), - expectedRows, - }) - const search = await measureBroadReviewSearch(page, fileCount) - - expect(metrics.logicalRows).toBe(expectedRows) - expect(metrics.fileRows).toBeGreaterThan(0) - expect(metrics.treeRows).toBeGreaterThan(0) - expect(metrics.diffLines).toBeGreaterThan(0) - expect(search.logicalRows).toBe(fileCount) - expect(search.renderedRows).toBeGreaterThan(0) - report( - { ...metrics, search }, - { - fileCount, - changedLinesPerFile, - changedLines, - additions: changedLines / 2, - deletions: changedLines / 2, - patchLines: changedLines, - patchByteLimit: Number.isFinite(patchByteLimit) ? patchByteLimit : null, - payloadBytes: new TextEncoder().encode(responseBody).byteLength, - expectedRows, - }, - ) - }, - ) - } -}) - -async function measureBroadReviewSearch(page: Page, expectedRows: number) { - const filter = page.getByRole("searchbox", { name: "Filter files" }) - await filter.evaluate((element) => { - element.addEventListener( - "input", - () => { - ;(window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt = performance.now() - }, - { once: true, capture: true }, - ) - }) - await filter.fill("file-") - - return page.evaluate((expectedRows) => { - const startedAt = (window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt! - return new Promise<{ stableMs: number; logicalRows: number; renderedRows: number }>((resolve) => { - let previous = -1 - let streak = 0 - const sample = () => { - const tree = document.querySelector('#review-panel [data-component="file-tree-v2"]') - const rows = [...document.querySelectorAll('#review-panel [data-slot="file-tree-v2-row"]')] - const logicalRows = Number(tree?.dataset.totalRows ?? rows.length) - const ready = - logicalRows === expectedRows && rows.length > 0 && rows.every((row) => row.textContent?.includes("file-")) - streak = ready && rows.length === previous ? streak + 1 : ready ? 1 : 0 - previous = rows.length - if (streak >= 3) { - resolve({ stableMs: performance.now() - startedAt, logicalRows, renderedRows: rows.length }) - return - } - requestAnimationFrame(sample) - } - requestAnimationFrame(sample) - }) - }, expectedRows) -} - -function createScalingDiffs(fileCount: number, patchByteLimit: number) { - const changes = Array.from({ length: linesPerSide }, (_, index) => { - const line = String(index).padStart(3, "0") - return `-export const value_${line} = "before"\n+export const value_${line} = "after"` - }).join("\n") - let patchBytes = 0 - let capped = false - - return Array.from({ length: fileCount }, (_, index) => { - const file = reviewFile(index) - const fullPatch = [ - `diff --git a/${file} b/${file}`, - `--- a/${file}`, - `+++ b/${file}`, - `@@ -1,${linesPerSide} +1,${linesPerSide} @@`, - changes, - ].join("\n") - if (index === 0 && fullPatch.length > patchByteLimit) - throw new Error(`REVIEW_PANE_PATCH_BYTE_LIMIT must include the active patch (${fullPatch.length} bytes)`) - const patch = !capped && patchBytes + fullPatch.length <= patchByteLimit ? fullPatch : emptyReviewPatch(file) - if (patch === fullPatch) patchBytes += fullPatch.length - else capped = true - return { - file, - patch, - additions: linesPerSide, - deletions: linesPerSide, - status: "modified" as const, - } - }) -} - -function emptyReviewPatch(file: string) { - return [`diff --git a/${file} b/${file}`, `--- a/${file}`, `+++ b/${file}`].join("\n") -} - -function reviewFile(index: number) { - return `src/review/d${String(Math.floor(index / filesPerDirectory)).padStart(5, "0")}/file-${String(index).padStart(5, "0")}.ts` -} - -async function measureReviewPaneLoad(page: Page, input: { expectedFile: string; expectedRows: number }) { - const toggle = page.getByRole("button", { name: "Toggle review" }) - await expect(toggle).toBeVisible() - await toggle.evaluate((element) => element.setAttribute("data-review-pane-scaling-toggle", "")) - await installReviewPaneScalingProbe(page, input) - await toggle.click() - await page.waitForFunction( - () => - (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe - ?.stableReadyMs !== undefined, - undefined, - { timeout: completionTimeoutMs }, - ) - - return page.evaluate(() => { - const probe = (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe! - probe.stop() - const startedAt = probe.startedAt! - const final = probe.samples.at(-1)! - const resources = performance - .getEntriesByType("resource") - .filter((entry) => entry.name.includes("/vcs/diff")) as PerformanceResourceTiming[] - const resource = resources.at(-1) - const longTasks = probe.longTasks.filter( - (entry) => entry.startTime >= startedAt && entry.startTime <= startedAt + probe.stableReadyMs!, - ) - const frameGaps = probe.frameTimesMs.map((time, index) => time - (probe.frameTimesMs[index - 1] ?? 0)) - - return { - firstTreeRowMs: probe.firstTreeRowMs ?? null, - logicalTreeReadyMs: probe.logicalTreeReadyMs ?? null, - firstDiffRenderMs: probe.firstDiffRenderMs ?? null, - stableReadyMs: probe.stableReadyMs ?? null, - responseStartMs: resource ? resource.responseStart - startedAt : null, - responseEndMs: resource ? resource.responseEnd - startedAt : null, - responseToStableMs: resource ? probe.stableReadyMs! - (resource.responseEnd - startedAt) : null, - treeRows: final.treeRows, - logicalRows: final.logicalRows, - fileRows: final.fileRows, - diffLines: final.diffLines, - samples: probe.samples.length, - maxFrameGapMs: Math.max(0, ...frameGaps), - longTaskCount: longTasks.length, - longTaskTotalMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0), - maxLongTaskMs: Math.max(0, ...longTasks.map((entry) => entry.duration)), - } - }) -} - -async function installReviewPaneScalingProbe(page: Page, input: { expectedFile: string; expectedRows: number }) { - await page.evaluate( - ({ expectedFile, expectedRows, stableFrames }) => { - let running = true - let readyStreak = 0 - const basename = expectedFile.split("/").at(-1)! - const longTaskObserver = PerformanceObserver.supportedEntryTypes.includes("longtask") - ? new PerformanceObserver((list) => { - probe.longTasks.push( - ...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })), - ) - }) - : undefined - const probe: ReviewPaneScalingProbe = { - samples: [], - frameTimesMs: [], - longTasks: [], - stop: () => { - running = false - longTaskObserver?.disconnect() - }, - } - - const sample = (time: number) => { - if (!running || probe.startedAt === undefined) return - const panel = document.querySelector("#review-panel") - const tree = panel?.querySelector('[data-component="file-tree-v2"]') - const rows = panel?.querySelectorAll('[data-slot="file-tree-v2-row"]') ?? [] - const fileRows = panel?.querySelectorAll('button[data-slot="file-tree-v2-row"]') ?? [] - const header = - panel?.querySelector('[data-slot="session-review-v2-file-header"]')?.textContent?.trim() ?? "" - const viewers = panel - ? [...panel.querySelectorAll('[data-component="file"][data-mode="diff"]')] - : [] - const diffLines = viewers.reduce( - (sum, viewer) => - sum + (viewer.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0), - 0, - ) - const observedAtMs = time - probe.startedAt - const logicalRows = Number(tree?.dataset.totalRows ?? rows.length) - const ready = - logicalRows === expectedRows && - fileRows.length > 0 && - header.includes(basename) && - viewers.length === 1 && - diffLines > 0 - const previous = probe.samples.at(-1) - const stable = - ready && - previous?.ready === true && - previous.logicalRows === logicalRows && - previous.treeRows === rows.length && - previous.fileRows === fileRows.length && - previous.diffLines === diffLines && - previous.header === header - - probe.frameTimesMs.push(observedAtMs) - probe.samples.push({ - observedAtMs, - logicalRows, - treeRows: rows.length, - fileRows: fileRows.length, - diffLines, - header, - ready, - }) - if (probe.firstTreeRowMs === undefined && rows.length > 0) probe.firstTreeRowMs = observedAtMs - if (probe.logicalTreeReadyMs === undefined && logicalRows === expectedRows) - probe.logicalTreeReadyMs = observedAtMs - if (probe.firstDiffRenderMs === undefined && diffLines > 0) probe.firstDiffRenderMs = observedAtMs - readyStreak = !ready ? 0 : stable ? readyStreak + 1 : 1 - if (readyStreak === stableFrames) probe.stableReadyMs = observedAtMs - if (probe.stableReadyMs === undefined) requestAnimationFrame(sample) - } - - longTaskObserver?.observe({ type: "longtask", buffered: true }) - document.addEventListener( - "click", - (event) => { - const toggle = event.target instanceof Element ? event.target.closest("button") : undefined - if (!toggle?.hasAttribute("data-review-pane-scaling-toggle")) return - probe.startedAt = performance.now() - performance.mark("opencode.review-pane-scaling.click") - requestAnimationFrame(sample) - }, - { capture: true, once: true }, - ) - ;(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe = probe - }, - { ...input, stableFrames: readyFrames }, - ) -} diff --git a/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts b/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts deleted file mode 100644 index 838af17c93..0000000000 --- a/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { Page } from "@playwright/test" -import { expectSessionTitle } from "../../utils/waits" -import { mockOpenCodeServer } from "../../utils/mock-server" -import { benchmark, expect, withBenchmarkPage } from "../benchmark" -import { fixture } from "./session-timeline-stress.fixture" -import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers" -import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe" - -type ParentHydrationBenchmarkMode = "natural" | "candidate" - -const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural" -if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`) -const userID = "msg_parent_hydration_user" -const user = { - ...fixture.messages[fixture.targetID][0]!, - info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } }, - parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({ - ...part, - id: `prt_parent_hydration_user_${index}`, - messageID: userID, - })), -} -const assistantSeed = fixture.messages[fixture.targetID][3]! -const assistants = Array.from({ length: 14 }, (_, index) => { - const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}` - return { - ...assistantSeed, - info: { - ...assistantSeed.info, - id: messageID, - parentID: userID, - time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 }, - }, - parts: assistantSeed.parts.map((part, partIndex) => ({ - ...part, - id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`, - messageID, - })), - } -}) -const messages = [user, ...assistants] -const target = fixture.sessions.find((session) => session.id === fixture.targetID)! -const lastID = userID -const lastAssistant = assistants.at(-1)! -const lastPart = lastAssistant.parts.at(-1)! -const lastPartID = - lastPart.type === "tool" - ? lastPart.id - : `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}` - -benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => { - benchmark.setTimeout(180_000) - const results = [] as Awaited>[] - for (let run = 0; run < 5; run++) { - results.push( - await withBenchmarkPage( - browser, - `session-parent-hydration-${mode}-${run}`, - (page) => trial(page, mode), - testInfo, - ), - ) - } - const timing = results.map((result) => result.metrics.firstCorrectObservedMs!).sort((a, b) => a - b) - report( - { - results: results.map((result) => ({ ...result.metrics, historyGateCount: result.historyGateCount })), - summary: { - firstCorrectObservedMs: { min: timing[0], median: timing[2], max: timing.at(-1) }, - blankSamples: results.map((result) => result.metrics.blankSamples), - requestCounts: { - list: results.map((result) => result.requestCounts.list), - parent: results.map((result) => result.requestCounts.parent), - }, - historyGateCount: results.map((result) => result.historyGateCount), - }, - }, - { mode }, - ) -}) - -async function trial(page: Page, mode: ParentHydrationBenchmarkMode) { - const requests: { type: "list" | "parent"; before?: string }[] = [] - const history = mode === "candidate" ? Promise.withResolvers() : undefined - let historyGates = 0 - await mockOpenCodeServer(page, { - sessions: fixture.sessions.filter((session) => session.id === fixture.sourceID), - provider: fixture.provider, - directory: fixture.directory, - project: fixture.project, - messageDelay: 50, - onMessages: (request) => { - if (request.sessionID === fixture.targetID && request.phase === "start") - requests.push({ type: "list", before: request.before }) - }, - beforeMessagesResponse: (request) => { - if (mode !== "candidate" || request.sessionID !== fixture.targetID || !request.before) return Promise.resolve() - historyGates++ - return history!.promise - }, - onMessage: (request) => { - if (request.sessionID === fixture.targetID && request.messageID === userID) requests.push({ type: "parent" }) - }, - message: (sessionID, messageID) => { - if (sessionID !== fixture.targetID || messageID !== userID) return - return user - }, - pageMessages: (sessionID, limit, before) => { - const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID] - const end = before ? items.findIndex((message) => message.info.id === before) : items.length - const start = Math.max(0, end - limit) - return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined } - }, - }) - await page.route(`**/session/${fixture.targetID}`, (route) => { - const current = new URL(route.request().url()).pathname.startsWith("/api/") - return route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify( - current - ? { - data: { - ...target, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - location: { directory: target.directory }, - }, - } - : target, - ), - }) - }) - await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] }) - await page.goto(stressSessionHref(fixture.sourceID)) - await expectSessionTitle(page, fixture.expected.sourceTitle) - await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!) - - const href = stressSessionHref(fixture.targetID) - await page.evaluate( - ({ href, title }) => { - const link = document.createElement("a") - link.id = "parent-hydration-target" - link.href = href - link.textContent = title - document.body.append(link) - }, - { href, title: target.title }, - ) - const metrics = await measureSessionSwitch(page, { - destinationIDs: messages.map((message) => message.info.id), - sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id), - lastID, - requiredPartID: lastPartID, - requireBottomAnchor: false, - href, - switch: async () => { - await page.locator("#parent-hydration-target").click() - await expectSessionTitle(page, target.title) - }, - }).finally(() => history?.resolve()) - expect(metrics.firstCorrectObservedMs).not.toBeNull() - const requestCounts = { - list: requests.filter((request) => request.type === "list").length, - parent: requests.filter((request) => request.type === "parent").length, - } - if (mode === "candidate") { - expect(requestCounts.parent).toBe(0) - expect(historyGates).toBe(0) - } - return { metrics, requestCounts, historyGateCount: historyGates } -} diff --git a/packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts b/packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts index 48d4d3deb0..2e80d70381 100644 --- a/packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts +++ b/packages/app/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts @@ -2,13 +2,7 @@ import type { Page } from "@playwright/test" import { expectSessionTitle } from "../../utils/waits" import { benchmark, expect, withBenchmarkPage } from "../benchmark" import { fixture } from "./session-timeline-stress.fixture" -import { - createReviewDiffs, - installStressSessionTabs, - installTimelineSettings, - mockStressTimeline, - stressSessionHref, -} from "./timeline-test-helpers" +import { installStressSessionTabs, mockStressTimeline, stressSessionHref } from "./timeline-test-helpers" import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe" type Result = Awaited> @@ -26,41 +20,8 @@ benchmark("benchmarks cold and hot session tab switching", async ({ browser, rep report({ results, summary: summarize(results) }) }) -benchmark( - "benchmarks v2 session tab switching with and without the review pane", - async ({ browser, report }, testInfo) => { - benchmark.setTimeout(360_000) - const runs = Number(process.env.SESSION_TAB_SWITCH_RUNS ?? 5) - const results = { - closed: { cold: [] as Result[], hot: [] as Result[] }, - open: { cold: [] as Result[], hot: [] as Result[] }, - } - for (const reviewPane of ["closed", "open"] as const) { - for (const mode of ["cold", "hot"] as const) { - for (let run = 0; run < runs; run++) { - results[reviewPane][mode].push( - await withBenchmarkPage( - browser, - `session-tab-switch-v2-${reviewPane}-${mode}-${run}`, - (page) => trial(page, mode, { newLayoutDesigns: true, reviewPane }), - testInfo, - ), - ) - } - } - } - report({ results, summary: summarizeReviewPane(results) }, { runs, reviewDiffs: createReviewDiffs().length }) - }, -) - -async function trial( - page: Page, - mode: "cold" | "hot", - options?: { newLayoutDesigns?: boolean; reviewPane?: "closed" | "open" }, -) { - const reviewDiffs = options?.newLayoutDesigns ? createReviewDiffs() : undefined - await mockStressTimeline(page, { vcsDiff: reviewDiffs }) - if (options?.newLayoutDesigns) await installTimelineSettings(page) +async function trial(page: Page, mode: "cold" | "hot") { + await mockStressTimeline(page) await installStressSessionTabs(page) if (mode === "hot") { await page.goto(stressSessionHref(fixture.targetID)) @@ -72,10 +33,6 @@ async function trial( await expectSessionTitle(page, fixture.expected.sourceTitle) } await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!) - if (options?.reviewPane === "open") { - await openReviewPane(page) - await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!) - } const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.info.id) const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.info.id) @@ -113,15 +70,6 @@ function summarize(results: Record<"cold" | "hot", Result[]>) { ) } -function summarizeReviewPane(results: Record<"closed" | "open", Record<"cold" | "hot", Result[]>>) { - return Object.fromEntries( - Object.entries(results).map(([reviewPane, values]) => [ - reviewPane, - summarize(values as Record<"cold" | "hot", Result[]>), - ]), - ) -} - async function switchSession(page: Page, sessionID: string, title: string) { const href = stressSessionHref(sessionID) const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() @@ -129,16 +77,3 @@ async function switchSession(page: Page, sessionID: string, title: string) { await tab.click() await expectSessionTitle(page, title) } - -async function openReviewPane(page: Page) { - await page.getByRole("button", { name: "Toggle review" }).click() - const panel = page.locator("#review-panel") - await expect(panel).toBeVisible() - // Text-based readiness works across review implementations; the legacy list mounts - // diff viewers lazily while V2 mounts the active preview eagerly. - await page.waitForFunction(() => { - const panel = document.querySelector("#review-panel") - const text = panel?.textContent ?? "" - return text.includes("generated-000.ts") && text.includes("+3") - }) -} diff --git a/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts b/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts index 5298b9b0b7..e315c2ad43 100644 --- a/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts +++ b/packages/app/e2e/performance/timeline/session-tab-switch-metrics.ts @@ -4,15 +4,7 @@ export type SessionSwitchSample = { source: string[] hasVisibleRows: boolean last: boolean - requiredPartVisible?: boolean - bottomAnchorRequired?: boolean bottomErrorPx?: number - review?: { - fileHost: boolean - fileHostReplaced: boolean - header: string - replacedLevels: string[] - } } export function classifySessionSwitch(samples: SessionSwitchSample[]) { @@ -31,10 +23,6 @@ export function classifySessionSwitch(samples: SessionSwitchSample[]) { (sample) => sample.hasVisibleRows && sample.destination.length === 0 && sample.source.length === 0, ).length, sourceSamples: samples.filter((sample) => sample.source.length > 0).length, - reviewFileHostMissingSamples: samples.filter((sample) => sample.review && !sample.review.fileHost).length, - reviewFileHostReplacedSamples: samples.filter((sample) => sample.review?.fileHostReplaced).length, - reviewHeaders: [...new Set(samples.flatMap((sample) => (sample.review ? [sample.review.header] : [])))], - reviewReplacedLevels: [...new Set(samples.flatMap((sample) => sample.review?.replacedLevels ?? []))], } } @@ -43,8 +31,7 @@ export function isCorrectDestination(sample: SessionSwitchSample) { sample.destination.length > 0 && sample.source.length === 0 && sample.last && - sample.requiredPartVisible !== false && - (sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1) + Math.abs(sample.bottomErrorPx ?? Infinity) <= 1 ) } diff --git a/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts b/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts index 955da80367..14f9d2d003 100644 --- a/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts +++ b/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts @@ -8,56 +8,19 @@ type SessionSwitchProbe = { async function installSessionSwitchProbe( page: Page, - input: { - destinationIDs: string[] - sourceIDs: string[] - lastID: string - requiredPartID?: string - requireBottomAnchor?: boolean - href: string - }, + input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string }, ) { - await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => { + await page.evaluate(({ destinationIDs, sourceIDs, lastID, href }) => { const destination = new Set(destinationIDs) const source = new Set(sourceIDs) const samples: SessionSwitchSample[] = [] let started: number | undefined let running = true - const reviewLevels: Record = { - panel: "#review-panel", - tabs: '#review-panel [data-component="tabs"]', - body: '#review-panel [data-slot="session-review-v2-body"]', - review: '#review-panel [data-component="session-review-v2"]', - preview: '#review-panel [data-slot="session-review-v2-preview"]', - scroll: '#review-panel [data-slot="session-review-v2-diff-scroll"]', - file: '#review-panel [data-component="file"][data-mode="diff"]', - } - const initialReviewNodes: Record = {} const sample = () => { if (!running || started === undefined) return setTimeout(() => { if (!running || started === undefined) return const observedAtMs = performance.now() - started - const reviewPanel = document.querySelector("#review-panel") - const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]') - const initialReviewFile = initialReviewNodes.file - const replacedLevels = Object.entries(reviewLevels).flatMap(([name, selector]) => { - const initial = initialReviewNodes[name] - if (!initial) return [] - const current = document.querySelector(selector) - return current && current !== initial ? [name] : [] - }) - const review = reviewPanel - ? { - fileHost: !!reviewFile, - fileHostReplaced: !!initialReviewFile && !!reviewFile && reviewFile !== initialReviewFile, - header: - reviewPanel - .querySelector('[data-slot="session-review-v2-file-header"]') - ?.textContent?.trim() ?? "", - replacedLevels, - } - : undefined const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => element.querySelector("[data-timeline-row]"), ) @@ -73,13 +36,6 @@ async function installSessionSwitchProbe( const rect = element.getBoundingClientRect() return rect.bottom > view.top && rect.top < view.bottom }) - const requiredPartVisible = requiredPartID - ? [...root.querySelectorAll("[data-timeline-part-id]")].some((element) => { - if (element.dataset.timelinePartId !== requiredPartID) return false - const rect = element.getBoundingClientRect() - return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom - }) - : undefined const spacer = root.querySelector('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect() samples.push({ observedAtMs, @@ -87,22 +43,10 @@ async function installSessionSwitchProbe( source: visible.filter((id) => source.has(id)), hasVisibleRows, last: visible.includes(lastID), - requiredPartVisible, - bottomAnchorRequired: requireBottomAnchor !== false, bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined, - review, }) } else { - samples.push({ - observedAtMs, - destination: [], - source: [], - hasVisibleRows: false, - last: false, - requiredPartVisible: requiredPartID ? false : undefined, - bottomAnchorRequired: requireBottomAnchor !== false, - review, - }) + samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false }) } requestAnimationFrame(sample) }, 0) @@ -113,9 +57,6 @@ async function installSessionSwitchProbe( const link = event.target instanceof Element ? event.target.closest("a") : undefined if (link?.getAttribute("href") !== href) return started = performance.now() - for (const [name, selector] of Object.entries(reviewLevels)) { - initialReviewNodes[name] = document.querySelector(selector) - } requestAnimationFrame(sample) }, { capture: true, once: true }, @@ -142,8 +83,7 @@ async function waitForStableSessionSwitch(page: Page) { sample.destination.length > 0 && sample.source.length === 0 && sample.last && - sample.requiredPartVisible !== false && - (sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1), + Math.abs(sample.bottomErrorPx ?? Infinity) <= 1, ) ) }) @@ -161,27 +101,13 @@ async function collectSessionSwitchResult(page: Page) { export async function measureSessionSwitch( page: Page, - input: { - destinationIDs: string[] - sourceIDs: string[] - lastID: string - requiredPartID?: string - requireBottomAnchor?: boolean - href: string - switch: () => Promise - }, + input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise }, ) { const { switch: run, ...probe } = input await installSessionSwitchProbe(page, probe) - try { - await run() - await waitForStableSessionSwitch(page) - return await collectSessionSwitchResult(page) - } finally { - await page.evaluate(() => { - ;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop() - }) - } + await run() + await waitForStableSessionSwitch(page) + return collectSessionSwitchResult(page) } export async function waitForStableTimeline(page: Page, lastID: string) { diff --git a/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts index a86a55cff2..4dad1df37b 100644 --- a/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts +++ b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts @@ -93,53 +93,36 @@ const assistantMessage = { parts: [editPart], } -export async function setupTimelineBenchmark( - page: Page, - options: { - historyTurns: number - eventBatch: number - newLayoutDesigns?: boolean - vcsDiff?: unknown[] - turnDiffs?: unknown[] - }, -) { +export async function setupTimelineBenchmark(page: Page, options: { historyTurns: number; eventBatch: number }) { const events: EventPayload[] = [] let eventBatch = options.eventBatch - const currentUserMessage = options.turnDiffs - ? { ...userMessage, info: { ...userMessage.info, summary: { diffs: options.turnDiffs } } } - : userMessage await mockOpenCodeServer(page, { directory, project: project(), provider: provider(), sessions: [session()], - vcsDiff: options.vcsDiff, pageMessages: () => ({ items: [ ...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(), - currentUserMessage, + userMessage, assistantMessage, ], }), events: () => events.splice(0, eventBatch), eventRetry: 16, }) - await page.addInitScript( - (input) => { - localStorage.setItem( - "settings.v3", - JSON.stringify({ - general: { - newLayoutDesigns: input.newLayoutDesigns, - editToolPartsExpanded: true, - shellToolPartsExpanded: true, - showReasoningSummaries: true, - }, - }), - ) - }, - { newLayoutDesigns: options.newLayoutDesigns ?? false }, - ) + await page.addInitScript(() => { + localStorage.setItem( + "settings.v3", + JSON.stringify({ + general: { + editToolPartsExpanded: true, + shellToolPartsExpanded: true, + showReasoningSummaries: true, + }, + }), + ) + }) await page.setViewportSize({ width: 1366, height: 768 }) const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first() diff --git a/packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts b/packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts index 1a52bc8aa3..64d79283f0 100644 --- a/packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts +++ b/packages/app/e2e/performance/timeline/session-timeline-benchmark.spec.ts @@ -1,4 +1,3 @@ -import type { Page } from "@playwright/test" import { benchmark, benchmarkDiagnostics, expect } from "../benchmark" import { buildInitialStreamEvent, @@ -7,300 +6,80 @@ import { textPartID, } from "./session-timeline-benchmark.fixture" import { startTimelineProfile } from "./session-timeline-profile" -import { createReviewDiffs } from "./timeline-test-helpers" import { collectTimelineStreamMetrics, installTimelineStreamProbe, startTimelineStreamProbe, } from "./session-timeline-stream-probe" -type TimelineStreamOptions = { - newLayoutDesigns?: boolean - reviewDiffs?: boolean - reviewPane?: boolean -} - -type ReviewPaneSample = { - observedAtMs: number - panelVisible: boolean - header: string - diffViewers: number - diffLines: number - codeBlocks: number - ready: boolean -} - -type ReviewPaneProbe = { - samples: ReviewPaneSample[] - start: () => void - stop: () => void -} - -const reviewReadyStreak = 3 - benchmark.describe("performance: session timeline streaming", () => { benchmark("streams assistant text without remounting or oscillating", async ({ page, report }) => { - benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000) - const result = await runTimelineStreamBenchmark(page, {}) - report(result.metrics, result.context) - }) - - benchmark("streams assistant text in v2 with review pane closed", async ({ page, report }) => { - benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000) - const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true }) - report(result.metrics, result.context) - }) - - benchmark("streams assistant text in v2 with review diffs and pane closed", async ({ page, report }) => { - benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000) - const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true, reviewDiffs: true }) - report(result.metrics, result.context) - }) - - benchmark("streams assistant text in v2 with review pane open", async ({ page, report }) => { - benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000) - const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true, reviewPane: true }) - report(result.metrics, result.context) - }) -}) - -benchmark.describe("performance: review pane", () => { - benchmark("loads v2 review diffs and switches active files", async ({ page, report }) => { - benchmark.setTimeout(240_000) - const historyTurns = Number(process.env.REVIEW_PANE_HISTORY_TURNS ?? 72) - const diffs = createReviewDiffs() + benchmark.setTimeout(480_000) + const cpuThrottle = Number(process.env.TIMELINE_CPU_THROTTLE ?? 30) + const deltaCount = Number(process.env.TIMELINE_DELTA_COUNT ?? 160) + const historyTurns = Number(process.env.TIMELINE_HISTORY_TURNS ?? 320) + const eventBatch = Number(process.env.TIMELINE_EVENT_BATCH ?? 1) + const minimal = process.env.TIMELINE_MINIMAL === "1" + const profileCPU = process.env.TIMELINE_CPU_PROFILE === "1" + const profileVisual = !minimal && profileCPU && process.env.TIMELINE_VISUAL_PROFILE !== "0" const fixture = await setupTimelineBenchmark(page, { historyTurns, - eventBatch: 1, - newLayoutDesigns: true, - vcsDiff: diffs, + eventBatch, }) - fixture.transport.enqueue(buildInitialStreamEvent(1)) + fixture.transport.enqueue(buildInitialStreamEvent(deltaCount)) + const contentStart = performance.now() await expect(fixture.text).toBeVisible() await expect(fixture.text).toContainText("Implementation plan") + const initialContentObservedMs = performance.now() - contentStart await fixture.scrollToBottom() await fixture.waitForStableGeometry() - const open = await measureReviewPaneLoad(page, diffs[0]!.file) - const switches = [] - for (const diff of diffs.slice(1, 4)) switches.push(await measureReviewNextFile(page, diff.file)) + const profile = await startTimelineProfile(page, { cpuThrottle, profileCPU }) + await installTimelineStreamProbe(page, { textPartID, finalIndex: deltaCount, profileVisual, minimal }) + const deltas = buildStreamDeltaEvents(deltaCount) + await startTimelineStreamProbe(page) + fixture.transport.enqueue(deltas) + + await page.waitForFunction( + (finalIndex) => + ( + window as Window & { + __timelineStreamBenchmark?: { applied: { index: number }[] } + } + ).__timelineStreamBenchmark?.applied.some((value) => value.index === finalIndex), + deltaCount, + { timeout: 420_000 }, + ) + await expect(fixture.text).toContainText("benchmark-complete") + await expect(fixture.text).toContainText("Streaming") + await fixture.waitForStableGeometry() + const metrics = await collectTimelineStreamMetrics(page, { + textPartID, + finalIndex: deltaCount, + navigations: benchmarkDiagnostics(page).navigations, + }) + const delivered = deltas.length - fixture.transport.pendingCount() + await profile.stop() report( { - open, - switches, + endToEndInitialContentObservedMs: initialContentObservedMs, + ...metrics, + deliveredDeltas: delivered, + pendingDeltas: fixture.transport.pendingCount(), }, { + cpuThrottle, + profileCPU, + profileVisual, + minimal, + queuedDeltas: deltas.length, historyTurns, - reviewDiffs: diffs.length, + eventBatch, }, ) + + await profile.reset() }) }) - -async function runTimelineStreamBenchmark(page: Page, options: TimelineStreamOptions) { - const completionTimeoutMs = Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) - const cpuThrottle = Number(process.env.TIMELINE_CPU_THROTTLE ?? 30) - const deltaCount = Number(process.env.TIMELINE_DELTA_COUNT ?? 160) - const historyTurns = Number(process.env.TIMELINE_HISTORY_TURNS ?? 320) - const eventBatch = Number(process.env.TIMELINE_EVENT_BATCH ?? 1) - const minimal = process.env.TIMELINE_MINIMAL === "1" - const profileCPU = process.env.TIMELINE_CPU_PROFILE === "1" - const profileVisual = !minimal && profileCPU && process.env.TIMELINE_VISUAL_PROFILE !== "0" - const diffs = options.reviewDiffs || options.reviewPane ? createReviewDiffs() : undefined - const fixture = await setupTimelineBenchmark(page, { - historyTurns, - eventBatch, - newLayoutDesigns: options.newLayoutDesigns, - // Turn diffs exercise timeline data cost; the pane-open scenario serves the same - // diffs through the default git mode so it works across review implementations. - turnDiffs: options.reviewDiffs ? diffs : undefined, - vcsDiff: options.reviewPane ? diffs : undefined, - }) - - fixture.transport.enqueue(buildInitialStreamEvent(deltaCount)) - const contentStart = performance.now() - await expect(fixture.text).toBeVisible() - await expect(fixture.text).toContainText("Implementation plan") - const initialContentObservedMs = performance.now() - contentStart - await fixture.scrollToBottom() - await fixture.waitForStableGeometry() - - const reviewPane = options.reviewPane && diffs ? await measureReviewPaneLoad(page, diffs[0]!.file) : undefined - if (reviewPane) await fixture.waitForStableGeometry() - - const profile = await startTimelineProfile(page, { cpuThrottle, profileCPU }) - await installTimelineStreamProbe(page, { textPartID, finalIndex: deltaCount, profileVisual, minimal }) - const deltas = buildStreamDeltaEvents(deltaCount) - await startTimelineStreamProbe(page) - fixture.transport.enqueue(deltas) - - await page.waitForFunction( - (finalIndex) => - ( - window as Window & { - __timelineStreamBenchmark?: { applied: { index: number }[] } - } - ).__timelineStreamBenchmark?.applied.some((value) => value.index === finalIndex), - deltaCount, - { timeout: completionTimeoutMs }, - ) - await expect(fixture.text).toContainText("benchmark-complete") - await expect(fixture.text).toContainText("Streaming") - await fixture.waitForStableGeometry() - const metrics = await collectTimelineStreamMetrics(page, { - textPartID, - finalIndex: deltaCount, - navigations: benchmarkDiagnostics(page).navigations, - }) - const delivered = deltas.length - fixture.transport.pendingCount() - await profile.stop() - - const result = { - metrics: { - endToEndInitialContentObservedMs: initialContentObservedMs, - ...metrics, - deliveredDeltas: delivered, - pendingDeltas: fixture.transport.pendingCount(), - reviewPane: reviewPane ?? null, - }, - context: { - cpuThrottle, - profileCPU, - profileVisual, - minimal, - queuedDeltas: deltas.length, - historyTurns, - eventBatch, - newLayoutDesigns: options.newLayoutDesigns === true, - reviewPane: options.reviewPane === true ? "open" : "closed", - reviewDiffs: diffs?.length ?? 0, - }, - } - - await profile.reset() - return result -} - -async function measureReviewPaneLoad(page: Page, file: string) { - // Default git mode reads the mocked /vcs/diff data, so opening the pane is enough - // and the flow works across review pane implementations. - await installReviewPaneProbe(page, { file }) - await startReviewPaneProbe(page) - await page.getByRole("button", { name: "Toggle review" }).click() - await expect(page.locator("#review-panel")).toBeVisible() - return collectReviewPaneProbe(page) -} - -async function measureReviewNextFile(page: Page, file: string) { - await installReviewPaneProbe(page, { file }) - await startReviewPaneProbe(page) - await page.getByRole("button", { name: "Next file" }).click() - return collectReviewPaneProbe(page) -} - -async function installReviewPaneProbe(page: Page, input: { file: string }) { - await page.evaluate((input) => { - const samples: ReviewPaneSample[] = [] - const basename = input.file.split(/[\\/]/).at(-1) ?? input.file - let started: number | undefined - let running = true - - const paneState = () => { - const panel = document.querySelector("#review-panel") - const review = panel?.querySelector('[data-component="session-review-v2"]') - const rect = (review ?? panel)?.getBoundingClientRect() - const text = panel?.textContent ?? "" - const previewHeader = panel?.querySelector( - '[data-slot="session-review-v2-file-header"]', - )?.textContent - const header = previewHeader ?? text - const viewers = panel ? [...panel.querySelectorAll('[data-component="file"][data-mode="diff"]')] : [] - const codeBlocks = panel?.querySelectorAll("code").length ?? 0 - const diffLines = viewers.reduce( - (sum, viewer) => - sum + - (viewer.shadowRoot?.querySelectorAll("[data-line]").length ?? viewer.querySelectorAll("[data-line]").length), - 0, - ) - const panelVisible = - !!panel && panel.getAttribute("aria-hidden") !== "true" && !!rect && rect.width > 0 && rect.height > 0 - return { - panelVisible, - header: header.slice(0, 500), - diffViewers: viewers.length, - diffLines, - codeBlocks, - ready: - panelVisible && - header.includes(basename) && - (viewers.length > 0 || text.includes("+3") || diffLines > 0 || codeBlocks > 0), - } - } - - const sample = () => { - if (!running || started === undefined) return - requestAnimationFrame(() => { - setTimeout(() => { - if (!running || started === undefined) return - samples.push({ observedAtMs: performance.now() - started, ...paneState() }) - if (performance.now() - started < 10_000) sample() - }, 0) - }) - } - - ;(window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe = { - samples, - start: () => { - started = performance.now() - performance.mark("opencode.review-pane.click") - sample() - }, - stop: () => { - running = false - }, - } - }, input) -} - -async function startReviewPaneProbe(page: Page) { - await page.evaluate(() => { - ;(window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe!.start() - }) -} - -async function collectReviewPaneProbe(page: Page) { - await page.waitForFunction((streak) => { - const samples = (window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe?.samples - if (!samples) return false - return samples.some((_, index) => { - const stable = samples.slice(index, index + streak) - return stable.length === streak && stable.every((sample) => sample.ready) - }) - }, reviewReadyStreak) - - const samples = await page.evaluate(() => { - const probe = (window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe! - probe.stop() - return probe.samples - }) - return { summary: summarizeReviewPaneSamples(samples), samples } -} - -function summarizeReviewPaneSamples(samples: ReviewPaneSample[]) { - const firstReady = samples.find((sample) => sample.ready) - const stableIndex = samples.findIndex((_, index) => { - const stable = samples.slice(index, index + reviewReadyStreak) - return stable.length === reviewReadyStreak && stable.every((sample) => sample.ready) - }) - return { - samples: samples.length, - firstReadyObservedMs: firstReady?.observedAtMs ?? null, - stableReadyObservedMs: stableIndex === -1 ? null : samples[stableIndex + reviewReadyStreak - 1]!.observedAtMs, - notReadySamples: samples.filter((sample) => !sample.ready).length, - maxDiffViewers: Math.max(0, ...samples.map((sample) => sample.diffViewers)), - maxDiffLines: Math.max(0, ...samples.map((sample) => sample.diffLines)), - maxCodeBlocks: Math.max(0, ...samples.map((sample) => sample.codeBlocks)), - } -} diff --git a/packages/app/e2e/performance/timeline/timeline-test-helpers.ts b/packages/app/e2e/performance/timeline/timeline-test-helpers.ts index 5028373c55..6360b4a793 100644 --- a/packages/app/e2e/performance/timeline/timeline-test-helpers.ts +++ b/packages/app/e2e/performance/timeline/timeline-test-helpers.ts @@ -21,10 +21,7 @@ export async function installTimelineSettings(page: Page) { export function mockStressTimeline( page: Page, - input?: { - onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void - vcsDiff?: unknown[] - }, + input?: { onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void }, ) { return mockOpenCodeServer(page, { sessions: fixture.sessions, @@ -33,7 +30,6 @@ export function mockStressTimeline( project: fixture.project, pageMessages, onMessages: input?.onMessages, - vcsDiff: input?.vcsDiff, }) } @@ -82,53 +78,3 @@ export function stressDraftHref(draftID: string) { function stressServer() { return `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` } - -export function createReviewDiffs() { - return Array.from({ length: Number(process.env.REVIEW_PANE_DIFF_COUNT ?? 72) }, (_, index) => { - const lines = index % 3 === 0 ? 300 : index % 3 === 1 ? 120 : 38 - const file = `src/review/generated-${String(index).padStart(3, "0")}.ts` - const before = reviewSource(index, lines) - const after = before - .replace(`value_${index}_4`, `updated_${index}_4`) - .replace( - `value_${index}_${Math.max(8, Math.floor(lines / 2))}`, - `updated_${index}_${Math.max(8, Math.floor(lines / 2))}`, - ) - .replace(`value_${index}_${lines - 4}`, `updated_${index}_${lines - 4}`) - return { - file, - patch: reviewPatch(file, before, after), - additions: 3, - deletions: 3, - status: "modified" as const, - } - }) -} - -function reviewSource(seed: number, lines: number) { - return Array.from( - { length: lines }, - (_, index) => `export const value_${seed}_${index} = "${reviewWords(seed + index, index % 5 === 0 ? 180 : 42)}"`, - ).join("\n") -} - -function reviewPatch(file: string, before: string, after: string) { - const beforeLines = before.split("\n") - const afterLines = after.split("\n") - return [ - `diff --git a/${file} b/${file}`, - `--- a/${file}`, - `+++ b/${file}`, - `@@ -1,${beforeLines.length} +1,${afterLines.length} @@`, - ...beforeLines.flatMap((line, index) => { - const next = afterLines[index]! - if (line === next) return [` ${line}`] - return [`-${line}`, `+${next}`] - }), - ].join("\n") -} - -function reviewWords(seed: number, length: number) { - const words = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet"] - return Array.from({ length: Math.ceil(length / 7) }, (_, index) => words[(seed + index * 3) % words.length]).join(" ") -} diff --git a/packages/app/e2e/performance/unit/mock-server.test.ts b/packages/app/e2e/performance/unit/mock-server.test.ts deleted file mode 100644 index 83308c0a86..0000000000 --- a/packages/app/e2e/performance/unit/mock-server.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { expect, test } from "bun:test" -import type { Page, Route } from "@playwright/test" -import { mockOpenCodeServer } from "../../utils/mock-server" - -test("applies message latency after a list response gate is released", async () => { - const events: string[] = [] - const gate = Promise.withResolvers() - let handler: ((route: Route) => Promise) | undefined - const page = { - route: (_url: string, callback: (route: Route) => Promise) => { - handler = callback - return Promise.resolve() - }, - } as unknown as Page - await mockOpenCodeServer(page, { - provider: {}, - directory: "C:/OpenCode", - project: {}, - sessions: [{ id: "session" }], - messageDelay: 25, - beforeMessagesResponse: () => { - events.push("before") - return gate.promise - }, - onMessages: (request) => events.push(request.phase), - pageMessages: () => { - events.push("page") - return { items: [] } - }, - }) - - const response = handler!({ - request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }), - fulfill: () => { - events.push("fulfill") - return Promise.resolve() - }, - } as unknown as Route) - expect(events).toEqual(["start", "before"]) - - const released = performance.now() - gate.resolve() - await response - expect(performance.now() - released).toBeGreaterThanOrEqual(20) - expect(events).toEqual(["start", "before", "page", "end", "fulfill"]) -}) diff --git a/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts b/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts index 4b824d9dfb..dd771b7d57 100644 --- a/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts +++ b/packages/app/e2e/performance/unit/session-tab-switch-metrics.test.ts @@ -52,35 +52,3 @@ test("reports missing correctness without throwing", () => { expect(result.firstCorrectObservedMs).toBeNull() expect(result.stableObservedMs).toBeNull() }) - -test("requires an explicitly tracked part to be visible", () => { - const result = classifySessionSwitch([ - { - observedAtMs: 16, - destination: ["destination"], - source: [], - hasVisibleRows: true, - last: true, - requiredPartVisible: false, - bottomErrorPx: 0, - }, - ]) - - expect(result.firstCorrectObservedMs).toBeNull() -}) - -test("can measure content correctness without requiring a bottom anchor", () => { - const result = classifySessionSwitch([ - { - observedAtMs: 16, - destination: ["destination"], - source: [], - hasVisibleRows: true, - last: true, - requiredPartVisible: true, - bottomAnchorRequired: false, - }, - ]) - - expect(result.firstCorrectObservedMs).toBe(16) -}) diff --git a/packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts b/packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts deleted file mode 100644 index d5d7f09a69..0000000000 --- a/packages/app/e2e/performance/unit/session-tab-switch-probe.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { expect, test } from "bun:test" -import type { Page } from "@playwright/test" -import { measureSessionSwitch } from "../timeline/session-tab-switch-probe" - -function testPage(waitFailure?: Error) { - const stops: unknown[] = [] - const page = { - evaluate: async (_callback: unknown, input?: unknown) => { - if (input) return - stops.push(undefined) - }, - waitForFunction: async () => { - if (waitFailure) throw waitFailure - }, - } as unknown as Page - return { page, stops } -} - -function input(run: () => Promise) { - return { - destinationIDs: ["destination"], - sourceIDs: ["source"], - lastID: "destination", - href: "/session/destination", - switch: run, - } -} - -test("stops sampling when the session switch fails", async () => { - const failure = new Error("switch failed") - const context = testPage() - - await expect( - measureSessionSwitch( - context.page, - input(async () => Promise.reject(failure)), - ), - ).rejects.toBe(failure) - - expect(context.stops).toHaveLength(1) -}) - -test("stops sampling when the stable wait fails", async () => { - const failure = new Error("stable wait failed") - const context = testPage(failure) - - await expect( - measureSessionSwitch( - context.page, - input(async () => {}), - ), - ).rejects.toBe(failure) - - expect(context.stops).toHaveLength(1) -}) diff --git a/packages/app/e2e/performance/unit/visual-stability.test.ts b/packages/app/e2e/performance/unit/visual-stability.test.ts deleted file mode 100644 index 2fc3be3166..0000000000 --- a/packages/app/e2e/performance/unit/visual-stability.test.ts +++ /dev/null @@ -1,392 +0,0 @@ -import { expect, test } from "bun:test" -import { - analyzeVisualStability, - analyzeVisualStabilityByMarker, - type VisualStabilityTrace, -} from "../../utils/visual-stability" -import { analyzeVisualObservations } from "../../utils/visual-stability/analyzer" -import { legacyVisualPlan, visualPlan, type VisualInvariant } from "../../utils/visual-stability/invariant" -import { defineVisualRegions, mapVisualRegions } from "../../utils/visual-stability/regions" - -function trace(samples: VisualStabilityTrace["samples"]): VisualStabilityTrace { - return { markers: [], samples } -} - -test("accepts continuous visible motion", () => { - expect( - analyzeVisualStability( - trace([ - frame(0, region({ width: 80, bottom: 40 }), region({ top: 40, bottom: 60 })), - frame(16, region({ width: 75, bottom: 45 }), region({ top: 45, bottom: 65 })), - frame(32, region({ width: 70, bottom: 50 }), region({ top: 50, bottom: 70 })), - ]), - { flow: ["changing", "following"] }, - ), - ).toEqual([]) -}) - -test("reports repeated geometry reversals", () => { - const issues = analyzeVisualStability( - trace([ - frame(0, region({ width: 80 })), - frame(16, region({ width: 60 })), - frame(32, region({ width: 78 })), - frame(48, region({ width: 62 })), - ]), - ) - - expect(issues.some((issue) => issue.includes("changing width reversed 2 times"))).toBe(true) -}) - -test("reports visible blanking, label reversal, and overlap", () => { - const issues = analyzeVisualStability( - trace([ - frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })), - frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })), - frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })), - ]), - { flow: ["changing", "following"] }, - ) - - expect(issues.some((issue) => issue.includes("opacity fell to 0.2"))).toBe(true) - expect(issues.some((issue) => issue.includes("label reverted"))).toBe(true) - expect(issues.some((issue) => issue.includes("overlapped following by 1px"))).toBe(true) -}) - -test("reports duplicate regions and unexpected remounts", () => { - const issues = analyzeVisualStability( - trace([frame(0, region({ node: 1 })), frame(16, region({ node: 2, count: 2 })), frame(32, region({ node: 2 }))]), - { stable: ["changing"], unique: ["changing"] }, - ) - - expect(issues.some((issue) => issue.includes("changing appeared 2 times"))).toBe(true) - expect(issues.some((issue) => issue.includes("changing remounted"))).toBe(true) -}) - -test("reports bottom anchor loss but permits movement while scrolled away", () => { - const anchored = analyzeVisualStability( - trace([ - { at: 0, regions: { changing: region() }, viewport: viewport(0) }, - { at: 16, regions: { changing: region() }, viewport: viewport(24) }, - ]), - { preserveBottomAnchor: true }, - ) - const away = analyzeVisualStability( - trace([ - { at: 0, regions: { changing: region() }, viewport: viewport(80) }, - { at: 16, regions: { changing: region() }, viewport: viewport(104) }, - ]), - { preserveBottomAnchor: true }, - ) - - expect(anchored.some((issue) => issue.includes("bottom anchor moved to 24px"))).toBe(true) - expect(away).toEqual([]) -}) - -test("reports up down up movement while preserving a bottom anchor", () => { - const issues = analyzeVisualStability( - trace([ - { at: 0, regions: { changing: region({ top: 200, bottom: 240 }) }, viewport: viewport(0) }, - { at: 16, regions: { changing: region({ top: 180, bottom: 220 }) }, viewport: viewport(0) }, - { at: 32, regions: { changing: region({ top: 196, bottom: 236 }) }, viewport: viewport(0) }, - { at: 48, regions: { changing: region({ top: 176, bottom: 216 }) }, viewport: viewport(0) }, - ]), - { preserveBottomAnchor: true, maxPositionReversals: 0 }, - ) - - expect(issues.some((issue) => issue.includes("changing top reversed 2 times"))).toBe(true) - expect(issues.some((issue) => issue.includes("changing bottom reversed 2 times"))).toBe(true) -}) - -test("accepts monotonic upward movement while preserving a bottom anchor", () => { - expect( - analyzeVisualStability( - trace([ - { at: 0, regions: { changing: region({ top: 200, bottom: 240 }) }, viewport: viewport(0) }, - { at: 16, regions: { changing: region({ top: 190, bottom: 230 }) }, viewport: viewport(0) }, - { at: 32, regions: { changing: region({ top: 180, bottom: 220 }) }, viewport: viewport(0) }, - ]), - { preserveBottomAnchor: true, maxPositionReversals: 0 }, - ), - ).toEqual([]) -}) - -test("ignores overlap entirely outside the clipped timeline viewport", () => { - expect( - analyzeVisualStability( - trace([ - { - at: 0, - regions: { - changing: region({ top: -200, bottom: -100 }), - following: region({ top: -150, bottom: -50 }), - }, - viewport: viewport(0), - }, - ]), - { flow: ["changing", "following"] }, - ), - ).toEqual([]) -}) - -test("reports visible anchor movement while allowing virtual scrollbar movement", () => { - const issues = analyzeVisualStability( - trace([ - { at: 0, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: { ...viewport(100), scrollTop: 40 } }, - { at: 16, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: { ...viewport(120), scrollTop: 60 } }, - ]), - { fixed: ["anchor"] }, - ) - - expect(issues).toEqual([]) - - const moved = analyzeVisualStability( - trace([ - { at: 0, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: viewport(100) }, - { at: 16, regions: { anchor: region({ top: 112, bottom: 132 }) }, viewport: viewport(100) }, - ]), - { fixed: ["anchor"] }, - ) - expect(moved.some((issue) => issue.includes("anchor moved 12px in the viewport"))).toBe(true) -}) - -test("analyzes each marked event independently", () => { - const input: VisualStabilityTrace = { - markers: [ - { at: 10, label: "grow" }, - { at: 40, label: "shrink" }, - ], - samples: [ - frame(0, region({ top: 100 })), - frame(16, region({ top: 90 })), - frame(32, region({ top: 80 })), - frame(48, region({ top: 90 })), - frame(64, region({ top: 100 })), - ], - } - - expect(analyzeVisualStability(input, { maxPositionReversals: 0 })).toContain("changing top reversed 1 times") - expect( - analyzeVisualStabilityByMarker(input, { - maxPositionReversals: 0, - motion: ["changing"], - aggregateMotion: false, - }), - ).toEqual([]) -}) - -test("reports cross-event motion reversals by default", () => { - const input: VisualStabilityTrace = { - markers: [ - { at: 10, label: "up" }, - { at: 40, label: "down" }, - ], - samples: [ - frame(0, region({ top: 100 })), - frame(16, region({ top: 90 })), - frame(32, region({ top: 80 })), - frame(48, region({ top: 90 })), - frame(64, region({ top: 100 })), - ], - } - - expect(analyzeVisualStabilityByMarker(input, { maxPositionReversals: 0 })).toContain("changing top reversed 1 times") -}) - -test("reports regions rendered in the wrong flow order", () => { - const issues = analyzeVisualStability( - trace([frame(0, region({ top: 100, bottom: 120 }), region({ top: 60, bottom: 80 }))]), - { flow: ["changing", "following"] }, - ) - - expect(issues.some((issue) => issue.includes("changing rendered after following"))).toBe(true) -}) - -test("uses painted bounds instead of clipped layout overflow", () => { - expect( - analyzeVisualStability( - trace([ - frame( - 0, - region({ top: 100, bottom: 140, height: 40, layoutTop: 100, layoutBottom: 300 }), - region({ top: 140, bottom: 180 }), - ), - ]), - { flow: ["changing", "following"] }, - ), - ).toEqual([]) -}) - -test("does not report disappearance when a present row moves outside the viewport", () => { - expect( - analyzeVisualStability( - trace([ - frame(0, region({ visible: true })), - frame(16, region({ visible: false, inViewport: false, top: -100, bottom: -80 })), - frame(32, region({ visible: true })), - ]), - ), - ).toEqual([]) -}) - -test("reports an in-viewport transparent frame between visible frames", () => { - const issues = analyzeVisualStability( - trace([ - frame(0, region()), - frame(16, region({ visible: false, opacity: 0, inViewport: true })), - frame(32, region()), - ]), - ) - - expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true) -}) - -test("reports an in-viewport display-none frame between visible frames", () => { - const issues = analyzeVisualStability( - trace([ - frame(0, region()), - frame(16, region({ visible: false, width: 0, height: 0, inViewport: true, cssHidden: true })), - frame(32, region()), - ]), - ) - - expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true) -}) - -test("can limit motion analysis to unaffected regions", () => { - expect( - analyzeVisualStability( - trace([ - frame(0, region({ height: 20 }), region()), - frame(16, region({ height: 40 }), region()), - frame(32, region({ height: 30 }), region()), - ]), - { motion: ["following"] }, - ), - ).toEqual([]) -}) - -test("reports a blank frame across replacement surfaces", () => { - const issues = analyzeVisualStability( - { - markers: [], - samples: [ - { at: 0, regions: { thinking: region(), error: region({ present: false, visible: false }) } }, - { - at: 16, - regions: { - thinking: region({ present: false, visible: false }), - error: region({ present: false, visible: false }), - }, - }, - { at: 32, regions: { thinking: region({ present: false, visible: false }), error: region() } }, - ], - }, - { continuousAny: [["thinking", "error"]] }, - ) - - expect(issues.some((issue) => issue.includes("thinking | error blanked"))).toBe(true) -}) - -test("reports failure to acquire the bottom anchor", () => { - const issues = analyzeVisualStability( - trace([ - { at: 0, regions: { changing: region() }, viewport: viewport(600) }, - { at: 16, regions: { changing: region() }, viewport: viewport(120) }, - ]), - { acquireBottomAnchor: true }, - ) - - expect(issues.some((issue) => issue.includes("did not acquire bottom anchor"))).toBe(true) -}) - -test("reports a required region that never renders", () => { - const issues = analyzeVisualStability( - trace([ - { - at: 0, - regions: { changing: region({ present: false, visible: false }) }, - }, - ]), - { required: ["changing"] }, - ) - - expect(issues).toContain("changing never rendered") -}) - -test("preserves typed region names while mapping definitions", () => { - const regions = defineVisualRegions({ - changing: { selector: "[data-changing]" }, - following: { selector: "[data-following]", closest: "[data-row]" }, - }) - const selectors = mapVisualRegions(regions, (region) => region.selector) - - expect(selectors).toEqual({ changing: "[data-changing]", following: "[data-following]" }) - const name: keyof typeof selectors = "changing" - expect(name).toBe("changing") -}) - -test("evaluates the typed invariant algebra over explicit observations", () => { - const regions = defineVisualRegions({ - changing: { selector: "[data-changing]" }, - following: { selector: "[data-following]" }, - }) - const invariants = [ - { type: "required", regions: ["changing"] }, - { type: "flow", regions: ["changing", "following"] }, - ] satisfies VisualInvariant[] - // @ts-expect-error Plans reject names that are not in the region definition. - const invalid = { type: "required", regions: ["missing"] } satisfies VisualInvariant - const plan = visualPlan(regions, invariants, { perMarker: true }) - - expect(invalid.regions).toEqual(["missing"]) - expect(plan.perMarker).toBe(true) - expect(analyzeVisualObservations([frame(0, region({ bottom: 50 }), region({ top: 49, bottom: 69 }))], plan)).toEqual([ - "changing overlapped following by 1px at 0ms", - ]) -}) - -test("legacy plan adapter preserves analyzer messages and order", () => { - const input = trace([ - frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })), - frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })), - frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })), - ]) - const options = { flow: ["changing", "following"], stable: ["changing"] } - - expect(analyzeVisualObservations(input.samples, legacyVisualPlan(options))).toEqual( - analyzeVisualStability(input, options), - ) -}) - -function frame( - at: number, - changing: VisualStabilityTrace["samples"][number]["regions"][string], - following?: VisualStabilityTrace["samples"][number]["regions"][string], -) { - return { at, regions: { changing, ...(following ? { following } : {}) } } -} - -function region(input: Partial = {}) { - return { - present: true, - visible: true, - inViewport: true, - top: 0, - bottom: 20, - width: 100, - height: 20, - opacity: 1, - count: 1, - node: 1, - label: "", - text: "", - layoutTop: input.top ?? 0, - layoutBottom: input.bottom ?? 20, - ...input, - } -} - -function viewport(distanceFromBottom: number) { - return { top: 0, bottom: 400, scrollTop: 100, scrollHeight: 500, clientHeight: 400, distanceFromBottom } -} diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts index f09a2c7b63..159b5a5067 100644 --- a/packages/app/e2e/regression/cross-server-tab-close.spec.ts +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -1,6 +1,5 @@ import { expect, test, type Page, type Route } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" -import { currentSession } from "../utils/mock-server" const serverA = "http://127.0.0.1:4096" const serverB = "http://127.0.0.1:4097" @@ -34,7 +33,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn await tabA.locator('[data-slot="tab-close"] button').click() await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) - await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true) + await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true) await expect(page.getByText(sessionB.title).first()).toBeVisible() const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`)) expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true) @@ -85,21 +84,17 @@ async function mockServers(page: Page, requests: string[]) { const current = url.origin === serverA ? sessionA : sessionB const directory = url.searchParams.get("directory") if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") - return sse(route) - if (url.pathname === "/global/health") return json(route, {}, 404) - if (url.pathname === "/api/health") return json(route, { pid: 1 }) - if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) - if (url.pathname === "/api/session/active") return json(route, { data: {} }) - if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) - if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) + if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) + if (url.pathname === "/global/health") return json(route, { healthy: true }) + if (url.pathname === "/session") return json(route, [current]) if (url.pathname === `/session/${current.id}`) return json(route, current) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (url.pathname === `/session/${current.id}/message`) return json(route, []) if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) return json(route, []) - if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) + if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname)) + return json(route, {}) if (url.pathname === "/provider") return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) @@ -121,20 +116,7 @@ async function mockServers(page: Page, requests: string[]) { directory: current.directory, home: current.directory, }) - if (url.pathname === "/api/path") - return json(route, { - state: current.directory, - config: current.directory, - worktree: current.directory, - directory: current.directory, - home: current.directory, - }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) - if (url.pathname === "/api/vcs") - return json(route, { - location: { directory: current.directory }, - data: { branch: "main", defaultBranch: "main" }, - }) return json(route, {}) }) } diff --git a/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts b/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts deleted file mode 100644 index 136232c211..0000000000 --- a/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectSessionTitle } from "../utils/waits" - -const directory = "C:/OpenCode/FileBrowserSidebar" -const projectID = "proj_file_browser_sidebar" -const sessionID = "ses_file_browser_sidebar" -const title = "File browser sidebar" -const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` -const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`) -// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable. -const PROBE = "original" - -test.use({ viewport: { width: 1440, height: 900 } }) - -// The file-browser sidebar must stay mounted across preview/pinned file-tab -// switches. Remounting resets scroll and filter state. -test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => { - await setup(page) - - await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) - await expectSessionTitle(page, title) - - const panel = page.locator("#review-panel") - await panel.getByRole("button", { name: "Open file" }).click() - await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") - - const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]') - await expect(sidebar).toBeVisible() - await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible() - - await panel.getByRole("button", { name: "file-00.ts" }).click() - await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "") - await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible() - - const viewport = panel.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') - await viewport.hover() - await page.mouse.wheel(0, 100_000) - await expect - .poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) - .toBeLessThanOrEqual(1) - const scrolled = await viewport.evaluate((element) => element.scrollTop) - expect(scrolled).toBeGreaterThan(0) - await writeProbe(page) - - await panel.getByRole("button", { name: "file-79.ts" }).click() - await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "") - await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible() - expect(await readProbe(page)).toBe(PROBE) - await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled) - - await panel.getByRole("button", { name: "file-78.ts" }).dblclick() - await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "") - await panel.getByRole("button", { name: "file-79.ts" }).click() - await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "") - await panel.getByRole("tab", { name: "file-78.ts" }).click() - await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "") - expect(await readProbe(page)).toBe(PROBE) - await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled) -}) - -type Probed = HTMLElement & { __e2eProbe?: string } - -async function writeProbe(page: Page) { - await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => { - ;(el as Probed).__e2eProbe = probe - }, PROBE) -} - -async function readProbe(page: Page) { - return page - .locator('#review-panel [data-component="session-review-v2-sidebar-root"]') - .evaluate((el) => (el as Probed).__e2eProbe) -} - -async function setup(page: Page) { - await mockOpenCodeServer(page, { - directory, - project: { - id: projectID, - worktree: directory, - vcs: "git", - name: "file-browser-sidebar", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "test" }, - }, - sessions: [ - { - id: sessionID, - slug: sessionID, - projectID, - directory, - title, - version: "dev", - time: { created: 1700000000000, updated: 1700000000000 }, - }, - ], - vcsDiff: [], - fileList: (path) => { - if (path) return [] - return files.map((name) => ({ - name, - path: name, - absolute: `${directory}/${name}`, - type: "file" as const, - ignored: false, - })) - }, - fileContent: (path) => ({ type: "text", content: `contents:${path}` }), - pageMessages: () => ({ items: [] }), - }) - - await page.addInitScript( - ({ directory, server, sessionID }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem( - "opencode.global.dat:server", - JSON.stringify({ - projects: { local: [{ worktree: directory, expanded: true }] }, - lastProject: { local: directory }, - }), - ) - localStorage.setItem( - "opencode.global.dat:layout", - JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), - ) - localStorage.setItem( - "opencode.global.dat:review-panel-v2", - JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }), - ) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify([{ type: "session", server, sessionId: sessionID }]), - ) - }, - { directory, server, sessionID }, - ) -} diff --git a/packages/app/e2e/regression/legacy-new-session.spec.ts b/packages/app/e2e/regression/legacy-new-session.spec.ts deleted file mode 100644 index 45cd64adcc..0000000000 --- a/packages/app/e2e/regression/legacy-new-session.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { expect, test } from "@playwright/test" -import { base64Encode } from "@opencode-ai/core/util/encode" -import { mockOpenCodeServer } from "../utils/mock-server" - -const draftID = "draft_legacy_new_session" -const directory = "C:/OpenCode/LegacyNewSession" -const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` - -test("redirects a draft to the legacy new-session route", async ({ page }) => { - await mockOpenCodeServer(page, { - directory, - project: { - id: "proj_legacy_new_session", - worktree: directory, - vcs: "git", - name: "legacy-new-session", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { all: [], connected: [], default: {} }, - sessions: [], - pageMessages: () => ({ items: [] }), - }) - await page.addInitScript( - ({ directory, draftID, server }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } })) - localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" })) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify([{ type: "draft", draftID, server, directory }]), - ) - }, - { directory, draftID, server }, - ) - - await page.goto(`/new-session?draftId=${draftID}`) - - await expect(page).toHaveURL(`/${base64Encode(directory)}/session`) - await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible() - await expect(page.locator('[data-component="prompt-input"]')).toBeVisible() -}) diff --git a/packages/app/e2e/regression/new-session-panel-corner.spec.ts b/packages/app/e2e/regression/new-session-panel-corner.spec.ts deleted file mode 100644 index a17b8c0817..0000000000 --- a/packages/app/e2e/regression/new-session-panel-corner.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { expect, test } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectAppVisible } from "../utils/waits" - -const draftID = "draft_new_session_panel_corner" -const directory = "C:/OpenCode/NewSessionPanelCorner" -const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` - -test.use({ - viewport: { width: 935, height: 522 }, - deviceScaleFactor: 1, -}) - -test("matches the rounded panel corners to the dark new-session background", async ({ page }, testInfo) => { - await mockOpenCodeServer(page, { - directory, - project: { - id: "proj_new_session_panel_corner", - worktree: directory, - vcs: "git", - name: "new-session-panel-corner", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { all: [], connected: [], default: {} }, - sessions: [], - pageMessages: () => ({ items: [] }), - }) - await page.addInitScript( - ({ directory, draftID, server }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem("opencode-theme-id", "oc-2") - localStorage.setItem("opencode-color-scheme", "dark") - localStorage.setItem( - "opencode.global.dat:server", - JSON.stringify({ - projects: { local: [{ worktree: directory, expanded: true }] }, - lastProject: { local: directory }, - }), - ) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify([{ type: "draft", draftID, server, directory }]), - ) - }, - { directory, draftID, server }, - ) - - await page.goto(`/new-session?draftId=${draftID}`) - await expectAppVisible(page.locator('[data-component="prompt-input"]')) - await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark") - const panel = page.locator('main div[class*="rounded-[10px]"][class*="overflow-hidden"]') - await expect(panel).toHaveCount(1) - const box = await panel.boundingBox() - if (!box) throw new Error("New-session panel bounds are unavailable") - - const screenshot = await page.screenshot({ path: testInfo.outputPath("new-session-dark.png") }) - const corners = await page.evaluate( - async ({ source, points }) => { - const image = new Image() - image.src = source - await image.decode() - const canvas = document.createElement("canvas") - canvas.width = image.naturalWidth - canvas.height = image.naturalHeight - const context = canvas.getContext("2d") - if (!context) throw new Error("2D canvas is unavailable") - context.drawImage(image, 0, 0) - return points.map((point) => Array.from(context.getImageData(point.x, point.y, 1, 1).data)) - }, - { - source: `data:image/png;base64,${screenshot.toString("base64")}`, - points: [ - { x: Math.floor(box.x), y: Math.floor(box.y) }, - { x: Math.ceil(box.x + box.width) - 1, y: Math.floor(box.y) }, - { x: Math.floor(box.x), y: Math.ceil(box.y + box.height) - 1 }, - { x: Math.ceil(box.x + box.width) - 1, y: Math.ceil(box.y + box.height) - 1 }, - ], - }, - ) - - expect(corners.every(([red, green, blue, alpha]) => red <= 8 && green <= 8 && blue <= 8 && alpha === 255)).toBe(true) -}) diff --git a/packages/app/e2e/regression/open-file-expand-folder.spec.ts b/packages/app/e2e/regression/open-file-expand-folder.spec.ts deleted file mode 100644 index 37739d2fbb..0000000000 --- a/packages/app/e2e/regression/open-file-expand-folder.spec.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectSessionTitle } from "../utils/waits" - -const directory = "C:/OpenCode/OpenFileExpand" -const projectID = "proj_open_file_expand" -const sessionID = "ses_open_file_expand" -const title = "Open file expand" -const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` - -test.use({ viewport: { width: 1440, height: 900 } }) - -test("expands a folder whose path has a trailing Windows separator", async ({ page }) => { - await mockOpenCodeServer(page, { - directory, - project: { - id: projectID, - worktree: directory, - vcs: "git", - name: "open-file-expand", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "test" }, - }, - sessions: [ - { - id: sessionID, - slug: sessionID, - projectID, - directory, - title, - version: "dev", - time: { created: 1700000000000, updated: 1700000000000 }, - }, - ], - vcsDiff: [], - fileList: (path) => { - if (path === "frontend\\" || path === "frontend") { - return [ - { - name: "app.ts", - path: "frontend\\app.ts", - absolute: `${directory}/frontend/app.ts`, - type: "file" as const, - ignored: false, - }, - ] - } - if (path) return [] - return [ - { - name: "frontend", - path: "frontend\\", - absolute: `${directory}/frontend`, - type: "directory" as const, - ignored: false, - }, - { - name: "README.md", - path: "README.md", - absolute: `${directory}/README.md`, - type: "file" as const, - ignored: false, - }, - ] - }, - fileContent: (path) => ({ type: "text", content: `contents:${path}` }), - pageMessages: () => ({ items: [] }), - }) - - await page.addInitScript( - ({ directory, server, sessionID }) => { - localStorage.setItem( - "settings.v3", - JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }), - ) - localStorage.setItem( - "opencode.global.dat:server", - JSON.stringify({ - projects: { local: [{ worktree: directory, expanded: true }] }, - lastProject: { local: directory }, - }), - ) - localStorage.setItem( - "opencode.global.dat:layout", - JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), - ) - localStorage.setItem( - "opencode.global.dat:review-panel-v2", - JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }), - ) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify([{ type: "session", server, sessionId: sessionID }]), - ) - }, - { directory, server, sessionID }, - ) - - await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) - await expectSessionTitle(page, title) - - const panel = page.locator("#review-panel") - await panel.getByRole("button", { name: "Open file" }).click() - await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") - - const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]') - await expect(sidebar).toBeVisible() - - const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]') - await expect(frontendRow).toBeVisible() - await expect(frontendRow).toHaveAttribute("aria-expanded", "false") - await frontendRow.click() - await expect(frontendRow).toHaveAttribute("aria-expanded", "true") - - const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]') - await expect(appRow).toBeVisible() - await appRow.click() - await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "") - await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible() -}) diff --git a/packages/app/e2e/regression/project-picker-recent-search.spec.ts b/packages/app/e2e/regression/project-picker-recent-search.spec.ts deleted file mode 100644 index 2cdb0b4a03..0000000000 --- a/packages/app/e2e/regression/project-picker-recent-search.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { expect, test } from "@playwright/test" -import type { Page } from "@playwright/test" -import { fixture, pageMessages } from "../smoke/session-timeline.fixture" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectAppVisible } from "../utils/waits" - -const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"] -const worktrees = NAMES.map((name) => `/opencode-demo/${name}`) - -// The sixth project sits outside the five-item recent cap, so it is only reachable if the -// dialog hands every recent project to the list filter instead of a pre-truncated slice. -const OUTSIDE_CAP = "foxtrot-docs" - -// Dialog rows carry data-directory-path; the sidebar project list does not, so this -// scopes assertions to the picker instead of matching the sidebar entry of the same name. -const rows = (page: Page) => page.locator("[data-directory-path]") -const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`) - -async function openProjectDialog(page: Page) { - await mockOpenCodeServer(page, { - sessions: fixture.sessions, - provider: fixture.provider, - directory: fixture.directory, - project: fixture.project, - pageMessages, - fileList: () => [], - findFiles: () => [], - }) - await page.addInitScript((dirs) => { - localStorage.setItem( - "opencode.global.dat:server", - JSON.stringify({ - projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) }, - lastProject: {}, - }), - ) - }, worktrees) - await page.goto("/") - const add = page.getByRole("button", { name: "Add project" }).first() - await expectAppVisible(add) - await add.click() - await expect(rows(page)).toHaveCount(5) - return page.getByRole("textbox").last() -} - -test("searches every recent project, not just the five most recent", async ({ page }) => { - const search = await openProjectDialog(page) - await expect(row(page, OUTSIDE_CAP)).toHaveCount(0) - - await search.fill("foxtrot") - - await expect(row(page, OUTSIDE_CAP)).toHaveCount(1) -}) - -test("still caps the idle recent list at five projects", async ({ page }) => { - await openProjectDialog(page) - - await expect(row(page, NAMES[4])).toHaveCount(1) - await expect(row(page, OUTSIDE_CAP)).toHaveCount(0) -}) diff --git a/packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts b/packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts deleted file mode 100644 index 50dcc8820b..0000000000 --- a/packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { expect, test } from "@playwright/test" -import { base64Encode } from "@opencode-ai/core/util/encode" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectAppVisible } from "../utils/waits" - -const directory = "C:/OpenCode/PromptInputV2Editing" -const projectID = "proj_prompt_input_v2_editing" -const sessionID = "ses_prompt_input_v2_editing" - -test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => { - await mockOpenCodeServer(page, { - directory, - project: { - id: projectID, - worktree: directory, - vcs: "git", - name: "prompt-input-v2-editing", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { all: [], connected: [], default: {} }, - sessions: [ - { - id: sessionID, - slug: "prompt-input-v2-editing", - projectID, - directory, - title: "Prompt input V2 editing", - version: "dev", - time: { created: 1700000000000, updated: 1700000000000 }, - }, - ], - pageMessages: () => ({ items: [] }), - }) - await page.addInitScript(() => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - }) - - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - const composer = page.locator('[data-component="prompt-input-v2"]') - const input = composer.locator('[data-component="prompt-input"]') - await expectAppVisible(composer) - - await input.fill("keep me") - await composer.getByRole("button", { name: "Add images and files" }).click() - await page.getByRole("menuitem", { name: "Commands" }).click() - await page.locator('[data-suggestion-id="model.choose"]').click() - - await expect(input).toHaveText("keep me") -}) diff --git a/packages/app/e2e/regression/prompt-thinking-level.spec.ts b/packages/app/e2e/regression/prompt-thinking-level.spec.ts index 9315c347c0..4219699f28 100644 --- a/packages/app/e2e/regression/prompt-thinking-level.spec.ts +++ b/packages/app/e2e/regression/prompt-thinking-level.spec.ts @@ -54,15 +54,18 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => { }) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - const composer = page.locator('[data-component="prompt-input-v2"]') + const composer = page.locator('[data-component="session-composer"]') const input = composer.locator('[data-component="prompt-input"]') - const control = composer.getByRole("button", { name: "Choose model variant" }) + const control = composer.locator('[data-component="prompt-variant-control"]') await expectAppVisible(composer) await idleComposer(page) + await expect(control).toBeHidden() + + await composer.hover() await expect(control).toBeVisible() - await control.click() + await control.locator('[data-action="prompt-model-variant"]').click() const high = page.getByRole("menuitemradio", { name: "high" }) await expect(high).toBeVisible() await page.mouse.move(0, 0) diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts deleted file mode 100644 index 35a0aa44cd..0000000000 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page, type Route } from "@playwright/test" -import { installSseTransport } from "../utils/sse-transport" -import { currentSession } from "../utils/mock-server" - -const serverA = "http://127.0.0.1:4096" -const serverB = "http://127.0.0.1:4097" -const directoryA = "C:/server-a" -const directoryB = "/home/server-b" -const sessionA = session("ses_server_a", directoryA, "Server A session") -const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id } -const sessionB = session("ses_server_b", directoryB, "Server B session") - -test("session settings use the remote server context", async ({ page }) => { - const permissionRequests: string[] = [] - await mockServers(page, permissionRequests) - await configureServers(page) - - await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) - await expect(page.getByText(sessionB.title).first()).toBeVisible() - await page.keyboard.press("Control+,") - - const dialog = page.locator(".settings-v2-dialog") - const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]') - const input = autoAccept.getByRole("switch") - await expect(autoAccept).toBeVisible() - await expect(input).toBeEnabled() - permissionRequests.length = 0 - await autoAccept.locator('[data-slot="switch-control"]').click() - await expect(input).toBeChecked() - await expect - .poll(() => - permissionRequests.some((request) => { - const url = new URL(request) - return url.origin === serverB && url.searchParams.get("directory") === directoryB - }), - ) - .toBe(true) - expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true) - - await dialog.getByRole("tab", { name: "Models" }).click() - await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled() - await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0) -}) - -test("auto-accept responds for an unfocused server session", async ({ page }) => { - const permissionRequests: string[] = [] - const permissionResponses: PermissionResponse[] = [] - const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { - server: serverA, - retry: 20, - }) - await mockServers(page, permissionRequests, permissionResponses) - await configureServers(page, [ - { type: "session", server: serverA, sessionId: sessionA.id }, - { type: "session", server: serverB, sessionId: sessionB.id }, - ]) - - const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` - await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`) - await expect(page.getByText(sessionA.title).first()).toBeVisible() - await page.keyboard.press("Control+,") - const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]') - await autoAccept.locator('[data-slot="switch-control"]').click() - await expect(autoAccept.getByRole("switch")).toBeChecked() - await expect - .poll(() => - permissionRequests.some((request) => { - const url = new URL(request) - return url.origin === serverA && url.searchParams.get("directory") === directoryA - }), - ) - .toBe(true) - await page.keyboard.press("Escape") - - await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click() - await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) - await expect(page.getByText(sessionB.title).first()).toBeVisible() - await transport.waitForConnection() - - await transport.send({ - directory: directoryA, - payload: { - id: "event-permission-background-a", - type: "permission.asked", - properties: { - id: "permission-background-a", - sessionID: sessionA.id, - permission: "bash", - patterns: ["git status"], - metadata: {}, - always: [], - }, - }, - }) - - await expect - .poll(() => permissionResponses) - .toEqual([ - { - origin: serverA, - directory: directoryA, - sessionID: sessionA.id, - permissionID: "permission-background-a", - body: { response: "once" }, - }, - ]) - - await transport.send({ - directory: directoryA, - payload: { - id: "event-permission-background-a-child", - type: "permission.asked", - properties: { - id: "permission-background-a-child", - sessionID: childSessionA.id, - permission: "bash", - patterns: ["git diff"], - metadata: {}, - always: [], - }, - }, - }) - - await expect - .poll(() => permissionResponses) - .toEqual([ - { - origin: serverA, - directory: directoryA, - sessionID: sessionA.id, - permissionID: "permission-background-a", - body: { response: "once" }, - }, - { - origin: serverA, - directory: directoryA, - sessionID: childSessionA.id, - permissionID: "permission-background-a-child", - body: { response: "once" }, - }, - ]) -}) - -type PermissionResponse = { - origin: string - directory?: string - sessionID: string - permissionID: string - body: unknown -} - -async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) { - await page.addInitScript( - ({ serverB, tabs }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) - localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs)) - }, - { serverB, tabs }, - ) -} - -async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) { - await page.route("**/*", async (route) => { - const url = new URL(route.request().url()) - if (url.origin !== serverA && url.origin !== serverB) return route.fallback() - const remote = url.origin === serverB - const directory = remote ? directoryB : directoryA - const sessions = remote ? [sessionB] : [sessionA, childSessionA] - const requestDirectory = url.searchParams.get("directory") - const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/) - if (route.request().method() === "POST" && response) { - permissionResponses.push({ - origin: url.origin, - directory: requestDirectory ?? undefined, - sessionID: response[1]!, - permissionID: response[2]!, - body: route.request().postDataJSON(), - }) - return json(route, true) - } - if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") - return sse(route) - if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent") - return json(route, { data: [] }) - if (url.pathname === "/api/model/default") return json(route, { data: null }) - if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname)) - return json(route, { location: { directory }, data: [] }) - if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] }) - if (url.pathname === "/api/mcp/resource") - return json(route, { location: { directory }, data: { resources: [], templates: [] } }) - if (url.pathname === "/api/project") { - return json(route, [ - { - id: remote ? sessionB.projectID : "project-server-a", - worktree: directory, - vcs: "git", - time: { created: 1, updated: 1 }, - sandboxes: [], - }, - ]) - } - if (url.pathname === "/api/project/current") - return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory }) - if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) - if (url.pathname === "/api/session/active") return json(route, { data: {} }) - const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`) - if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) - if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`)) - return json(route, { data: [], cursor: {} }) - const current = sessions.find((session) => url.pathname === `/session/${session.id}`) - if (current) return json(route, current) - if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) - if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, []) - if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) - if (url.pathname === "/permission") { - permissionRequests.push(url.toString()) - return json(route, []) - } - if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname)) - return json(route, []) - if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) - if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a")) - if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) - if (url.pathname === "/project" || url.pathname === "/project/current") { - const project = { - id: remote ? sessionB.projectID : "project-server-a", - worktree: directory, - vcs: "git", - time: { created: 1, updated: 1 }, - sandboxes: [], - } - return json(route, url.pathname === "/project" ? [project] : project) - } - if (url.pathname === "/path") - return json(route, { - state: directory, - config: directory, - worktree: directory, - directory, - home: directory, - }) - if (url.pathname === "/api/path") - return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory }) - if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) - if (url.pathname === "/api/vcs") - return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } }) - if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] }) - return json(route, {}) - }) -} - -function session(id: string, directory: string, title: string) { - return { - id, - slug: id, - projectID: `project-${id}`, - directory, - title, - version: "dev", - time: { created: 1, updated: 1 }, - } -} - -function provider(id: string) { - const name = id === "server-b" ? "Server B" : "Server A" - return { - all: [ - { - id, - name: `${name} Provider`, - models: { - [id]: { - id, - name: `${name} Model`, - family: id, - release_date: "2026-01-01", - limit: { context: 200_000 }, - }, - }, - }, - ], - connected: [id], - default: { providerID: id, modelID: id }, - } -} - -function json(route: Route, body: unknown, status = 200) { - return route.fulfill({ - status, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify(body), - }) -} - -function sse(route: Route) { - return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) -} diff --git a/packages/app/e2e/regression/remote-tab-busy.spec.ts b/packages/app/e2e/regression/remote-tab-busy.spec.ts deleted file mode 100644 index 2d9b1e2349..0000000000 --- a/packages/app/e2e/regression/remote-tab-busy.spec.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { expect, test, type Page, type Route } from "@playwright/test" -import { base64Encode } from "@opencode-ai/core/util/encode" -import { currentSession } from "../utils/mock-server" - -const serverA = "http://127.0.0.1:4096" -const serverB = "http://127.0.0.1:4097" -const sessionA = session("ses_server_a", "C:/server-a", "Server A session") -const sessionB = session("ses_server_b", "/home/server-b", "Server B session") - -test("tab busy indicator reflects the tab server's own session status", async ({ page }) => { - await mockServers(page) - await page.addInitScript( - ({ serverA, serverB, sessionA, sessionB }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify([ - { type: "session", server: serverA, sessionId: sessionA }, - { type: "session", server: serverB, sessionId: sessionB }, - ]), - ) - }, - { serverA, serverB, sessionA: sessionA.id, sessionB: sessionB.id }, - ) - - const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}` - const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` - await page.goto(hrefA) - await expect(page.getByText(sessionA.title).first()).toBeVisible() - - // Session B is busy on server B while server A stays the active server, so the - // busy indicator must come from the tab server's status, not the active server's. - const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`) - await expect(tabB.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible() - - const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`) - await expect(tabA.locator("[data-titlebar-tab-title]")).toHaveText(sessionA.title) - await expect(tabA.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0) -}) - -function session(id: string, directory: string, title: string) { - return { - id, - slug: id, - projectID: `project-${id}`, - directory, - title, - version: "dev", - time: { created: 1, updated: 1 }, - } -} - -async function mockServers(page: Page) { - await page.route("**/*", async (route) => { - const url = new URL(route.request().url()) - if (url.origin !== serverA && url.origin !== serverB) return route.fallback() - const current = url.origin === serverA ? sessionA : sessionB - const directory = url.searchParams.get("directory") - if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") - return sse(route, url.pathname === "/api/event") - if (url.pathname === "/global/health") return json(route, {}, 404) - if (url.pathname === "/api/health") return json(route, { pid: 1 }) - if (url.pathname === "/api/session/active") - return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} }) - if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) - if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) - if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) - if (url.pathname === `/session/${current.id}`) return json(route, current) - if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) - if (url.pathname === `/session/${current.id}/message`) return json(route, []) - if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) - if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) - return json(route, []) - if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) - if (url.pathname === "/provider") - return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) - if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) - if (url.pathname === "/project" || url.pathname === "/project/current") { - const project = { - id: current.projectID, - worktree: current.directory, - vcs: "git", - time: { created: 1, updated: 1 }, - sandboxes: [], - } - return json(route, url.pathname === "/project" ? [project] : project) - } - if (url.pathname === "/path") - return json(route, { - state: current.directory, - config: current.directory, - worktree: current.directory, - directory: current.directory, - home: current.directory, - }) - if (url.pathname === "/api/path") - return json(route, { - state: current.directory, - config: current.directory, - worktree: current.directory, - directory: current.directory, - home: current.directory, - }) - if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) - if (url.pathname === "/api/vcs") - return json(route, { - location: { directory: current.directory }, - data: { branch: "main", defaultBranch: "main" }, - }) - return json(route, {}) - }) -} - -function json(route: Route, body: unknown, status = 200) { - return route.fulfill({ - status, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify(body), - }) -} - -function sse(route: Route, current: boolean) { - return route.fulfill({ - status: 200, - contentType: "text/event-stream", - body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n", - }) -} diff --git a/packages/app/e2e/regression/review-image-flash.spec.ts b/packages/app/e2e/regression/review-image-flash.spec.ts deleted file mode 100644 index dd200384d4..0000000000 --- a/packages/app/e2e/regression/review-image-flash.spec.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { expect, test, type Page } from "@playwright/test" -import { base64Encode } from "@opencode-ai/core/util/encode" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectAppVisible, expectSessionTitle } from "../utils/waits" - -const directory = "C:/OpenCode/ReviewImageFlashRegression" -const sessionID = "ses_review_image_flash_regression" -const title = "Review image flash regression" -const imageFile = "assets/preview.png" - -test("clicking an image file in the v2 review pane does not blank the panel", async ({ page }) => { - await openReview(page) - await installReviewFlashProbe(page) - - await page.getByRole("button", { name: /preview\.png/ }).click() - await waitForReviewFlashProbe(page, 400) - const trace = await collectReviewFlashProbe(page) - const bad = trace.samples.filter((sample) => sample.blank || sample.blackCenter) - - expect(trace.samples.length).toBeGreaterThan(0) - expect( - bad, - JSON.stringify({ bad: bad.slice(0, 8), first: trace.samples.slice(0, 8), last: trace.samples.slice(-4) }, null, 2), - ).toEqual([]) -}) - -async function openReview(page: Page) { - await page.setViewportSize({ width: 960, height: 900 }) - await page.addInitScript(() => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - }) - await mockOpenCodeServer(page, { - directory, - project: { - id: "proj_review_image_flash_regression", - worktree: directory, - vcs: "git", - name: "review-image-flash-regression", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { all: [], connected: [], default: {} }, - sessions: [ - { - id: sessionID, - slug: "review-image-flash-regression", - projectID: "proj_review_image_flash_regression", - directory, - title, - version: "dev", - time: { created: 1700000000000, updated: 1700000000000 }, - }, - ], - vcsDiff: [ - { - file: "src/example.ts", - additions: 1, - deletions: 1, - status: "modified", - patch: - "diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n", - }, - { - file: imageFile, - patch: "", - additions: 1, - deletions: 0, - status: "added", - }, - ], - fileContent: async (path) => { - if (path !== imageFile) return undefined - await new Promise((resolve) => setTimeout(resolve, 250)) - return { - type: "binary", - content: "iVBORw0KGgo=", - encoding: "base64", - mimeType: "image/png", - } - }, - fileList: (path) => { - if (!path) { - return [ - { name: "assets", path: "assets", absolute: `${directory}/assets`, type: "directory", ignored: false }, - { name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false }, - ] - } - if (path === "assets") { - return [ - { - name: "preview.png", - path: imageFile, - absolute: `${directory}/${imageFile}`, - type: "file", - ignored: false, - }, - ] - } - if (path === "src") { - return [ - { - name: "example.ts", - path: "src/example.ts", - absolute: `${directory}/src/example.ts`, - type: "file", - ignored: false, - }, - ] - } - return [] - }, - pageMessages: () => ({ - items: [ - { - info: { - id: "msg_review_image_flash_regression", - sessionID, - role: "user", - time: { created: 1700000000000 }, - summary: { diffs: [] }, - agent: "build", - model: { providerID: "opencode", modelID: "test" }, - }, - parts: [ - { - id: "prt_review_image_flash_regression", - sessionID, - messageID: "msg_review_image_flash_regression", - type: "text", - text: "Review this change.", - }, - ], - }, - ], - }), - }) - - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - await expectSessionTitle(page, title) - await page.getByRole("button", { name: "Toggle review" }).click() - await expectAppVisible(page.locator('#review-panel [data-component="session-review-v2"]')) - await expectAppVisible(page.getByRole("button", { name: /preview\.png/ })) -} - -async function installReviewFlashProbe(page: Page) { - await page.evaluate(() => { - const samples: Array<{ - observedAtMs: number - blank: boolean - blackCenter: boolean - text: string - background: string - }> = [] - const startedAt = performance.now() - const sample = () => { - const panel = document.querySelector('#review-panel [data-component="session-review-v2"]') - const rect = panel?.getBoundingClientRect() - const center = rect - ? document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2) - : undefined - const background = center instanceof Element ? getComputedStyle(center).backgroundColor : "" - samples.push({ - observedAtMs: performance.now() - startedAt, - blank: !panel || panel.textContent?.trim().length === 0, - blackCenter: background === "rgb(0, 0, 0)", - text: panel?.textContent?.trim().slice(0, 80) ?? "", - background, - }) - if (performance.now() - startedAt < 500) requestAnimationFrame(sample) - } - document.addEventListener( - "click", - (event) => { - const target = event.target instanceof Element ? event.target : undefined - if (!target?.closest('[data-slot="file-tree-v2-row"]')) return - requestAnimationFrame(sample) - }, - { capture: true, once: true }, - ) - ;(window as Window & { __reviewImageFlash?: { samples: typeof samples; startedAt: number } }).__reviewImageFlash = { - samples, - startedAt, - } - }) -} - -async function waitForReviewFlashProbe(page: Page, durationMs: number) { - await page.waitForFunction((durationMs) => { - const state = (window as Window & { __reviewImageFlash?: { samples: unknown[]; startedAt: number } }) - .__reviewImageFlash - return !!state && state.samples.length > 0 && performance.now() - state.startedAt >= durationMs - }, durationMs) -} - -async function collectReviewFlashProbe(page: Page) { - return page.evaluate(() => { - return (window as Window & { __reviewImageFlash?: { samples: unknown[]; startedAt: number } }).__reviewImageFlash! - }) as Promise<{ - startedAt: number - samples: Array<{ observedAtMs: number; blank: boolean; blackCenter: boolean; text: string; background: string }> - }> -} diff --git a/packages/app/e2e/regression/review-line-comment.spec.ts b/packages/app/e2e/regression/review-line-comment.spec.ts index 7850f7820a..042f926c53 100644 --- a/packages/app/e2e/regression/review-line-comment.spec.ts +++ b/packages/app/e2e/regression/review-line-comment.spec.ts @@ -84,7 +84,6 @@ test("stages a submitted line comment in the prompt context", async ({ page }) = async function openReview(page: Page) { await page.setViewportSize({ width: 700, height: 900 }) await mockOpenCodeServer(page, { - protocol: "v2", directory, project: { id: "proj_review_line_comment_regression", @@ -144,9 +143,9 @@ async function openReview(page: Page) { await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, title) - const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff") + const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff") await page.getByRole("tab", { name: "Changes" }).click() - expect((await (await diffResponse).json()).data).toHaveLength(1) + expect(await (await diffResponse).json()).toHaveLength(1) const review = page.locator('[data-component="session-review"]') await expectAppVisible(review) diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts deleted file mode 100644 index 04e6d2cced..0000000000 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectSessionTitle } from "../utils/waits" - -const directory = "C:/OpenCode/ReviewOpenFile" -const projectID = "proj_review_open_file" -const sessionID = "ses_review_open_file" -const title = "Review open file" -const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` - -test.use({ viewport: { width: 1440, height: 900 } }) - -test("opens and searches project files inline", async ({ page }) => { - const searches: { query: string; dirs?: string; limit?: number }[] = [] - await mockOpenCodeServer(page, { - directory, - project: { - id: projectID, - worktree: directory, - vcs: "git", - name: "open-file-project", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "test" }, - }, - sessions: [ - { - id: sessionID, - slug: sessionID, - projectID, - directory, - title, - version: "dev", - time: { created: 1700000000000, updated: 1700000000000 }, - }, - ], - vcsDiff: [fileDiff("src/changed.ts")], - fileList: (path) => { - if (path) return [] - return [ - fileNode("README.md"), - { name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false }, - ] - }, - fileContent: (path) => ({ type: "text", content: `contents:${path}` }), - findFiles: (input) => { - searches.push(input) - return input.query === "nested" ? ["src/nested.ts"] : [] - }, - pageMessages: () => ({ items: [] }), - }) - await page.addInitScript( - ({ directory, server, sessionID }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem( - "opencode.global.dat:server", - JSON.stringify({ - projects: { local: [{ worktree: directory, expanded: true }] }, - lastProject: { local: directory }, - }), - ) - localStorage.setItem( - "opencode.global.dat:layout", - JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), - ) - localStorage.setItem( - "opencode.global.dat:review-panel-v2", - JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }), - ) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify([{ type: "session", server, sessionId: sessionID }]), - ) - }, - { directory, server, sessionID }, - ) - - await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) - await expectSessionTitle(page, title) - - const panel = page.locator("#review-panel") - const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]') - const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" }) - const contextButton = page.getByRole("button", { name: "View context usage" }) - await contextButton.click() - await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") - await panel.getByRole("button", { name: "Open file" }).click() - await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") - await expect(sidebarToggle).toBeDisabled() - await expect(sidebar).toBeVisible() - await contextButton.click() - await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") - await expect(sidebar).toBeHidden() - await panel.getByRole("button", { name: "Open file" }).click() - const filter = panel.getByRole("combobox", { name: "Filter files" }) - await expect(filter).toBeFocused() - await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") - await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible() - - await panel.getByRole("button", { name: "README.md" }).click() - await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "") - await expect(sidebarToggle).toBeEnabled() - await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible() - await expect(sidebar).toHaveCount(0) - - await panel.getByRole("button", { name: "Open file" }).click() - await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0) - await expect(sidebar).toBeVisible() - await filter.fill("nested") - const result = panel.getByRole("option", { name: /nested\.ts/ }) - await expect(result).toBeVisible() - const resultID = await result.getAttribute("id") - expect(resultID).toBeTruthy() - await expect(filter).toHaveAttribute("aria-activedescendant", resultID!) - await filter.press("Enter") - await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") - await expect(sidebarToggle).toBeEnabled() - await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible() - expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 }) - - await panel.getByRole("button", { name: "Open file" }).click() - await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) - await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") - await expect(sidebarToggle).toBeDisabled() - await panel.locator("#session-side-panel-review-tab").click() - await expect(sidebarToggle).toBeEnabled() - await panel.getByRole("tab", { name: "Open file" }).click() - await page.keyboard.press("Control+w") - await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0) - await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") -}) - -function fileNode(path: string) { - return { - name: path, - path, - absolute: `${directory}/${path}`, - type: "file", - ignored: false, - } -} - -function fileDiff(file: string) { - return { - file, - before: "before\n", - after: "after\n", - additions: 1, - deletions: 1, - status: "modified", - } -} diff --git a/packages/app/e2e/regression/review-state-persistence.spec.ts b/packages/app/e2e/regression/review-state-persistence.spec.ts deleted file mode 100644 index 0d6756201e..0000000000 --- a/packages/app/e2e/regression/review-state-persistence.spec.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectSessionTitle } from "../utils/waits" - -const directory = "C:/OpenCode/ReviewStatePersistence" -const projectID = "proj_review_state_persistence" -const sessionA = "ses_review_state_a" -const sessionB = "ses_review_state_b" -const titleA = "Alpha review state" -const titleB = "Beta review state" -const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` - -test.use({ viewport: { width: 1440, height: 900 } }) - -test("restores review mode and selected file per session", async ({ page }) => { - await setup(page) - await page.goto(sessionHref(sessionA)) - await expectSessionTitle(page, titleA) - await page.getByRole("button", { name: "Toggle review" }).click() - - await selectMode(page, "Git changes", "Branch changes") - await selectFile(page, "beta.ts") - - await switchSession(page, titleB) - await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() - await selectFile(page, "gamma.ts") - - await switchSession(page, titleA) - await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible() - await expectSelectedFile(page, "beta.ts") - await selectMode(page, "Branch changes", "Git changes") - await expectSelectedFile(page, "alpha.ts") - await selectMode(page, "Git changes", "Branch changes") - await expectSelectedFile(page, "beta.ts") - - await page.reload() - await expectSessionTitle(page, titleA) - await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible() - await expectSelectedFile(page, "beta.ts") - - await switchSession(page, titleB) - await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() - await expectSelectedFile(page, "gamma.ts") -}) - -async function selectMode(page: Page, current: string, next: string) { - await page.getByRole("button", { name: current }).click() - await page.getByRole("option", { name: next }).dispatchEvent("click") -} - -async function selectFile(page: Page, file: string) { - await page.getByRole("button", { name: file }).click() - await expectSelectedFile(page, file) -} - -async function expectSelectedFile(page: Page, file: string) { - await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file) -} - -async function switchSession(page: Page, title: string) { - await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click() - await expectSessionTitle(page, title) -} - -async function setup(page: Page) { - await mockOpenCodeServer(page, { - protocol: "v1", - directory, - project: { - id: projectID, - worktree: directory, - vcs: "git", - name: "review-state-persistence", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "test" }, - }, - sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], - pageMessages: () => ({ items: [] }), - }) - await page.route(/\/vcs(?:\?.*)?$/, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ branch: "feature", default_branch: "dev" }), - }), - ) - await page.route("**/vcs/diff**", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify( - new URL(route.request().url()).searchParams.get("mode") === "branch" - ? [diff("src/alpha.ts"), diff("src/beta.ts")] - : [diff("src/alpha.ts"), diff("src/gamma.ts")], - ), - }), - ) - await page.addInitScript( - ({ directory, server, sessions }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem( - "opencode.global.dat:server", - JSON.stringify({ - projects: { local: [{ worktree: directory, expanded: true }] }, - lastProject: { local: directory }, - }), - ) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))), - ) - }, - { directory, server, sessions: [sessionA, sessionB] }, - ) -} - -function session(id: string, title: string, created: number) { - return { - id, - slug: id, - projectID, - directory, - title, - version: "dev", - time: { created, updated: created }, - } -} - -function diff(file: string) { - return { - file, - additions: 1, - deletions: 1, - status: "modified", - patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`, - } -} - -function sessionHref(sessionID: string) { - return `/server/${base64Encode(server)}/session/${sessionID}` -} diff --git a/packages/app/e2e/regression/review-tab-switch.spec.ts b/packages/app/e2e/regression/review-tab-switch.spec.ts deleted file mode 100644 index 8634166e51..0000000000 --- a/packages/app/e2e/regression/review-tab-switch.spec.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectAppVisible, expectSessionTitle } from "../utils/waits" - -const directory = "C:/OpenCode/ReviewTabSwitch" -const projectID = "proj_review_tab_switch" -const sessionA = "ses_review_tab_a" -const sessionB = "ses_review_tab_b" -const titleA = "Alpha session" -const titleB = "Beta session" -const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` -const diffs = Array.from({ length: 2_740 }, (_, index) => - fileDiff(`src/generated-${String(index).padStart(4, "0")}.ts`), -) -// Marks the review pane DOM node so a remount (fresh node) is detectable. -const PROBE = "original" - -test.use({ viewport: { width: 1440, height: 900 } }) - -// The v2 review pane's diff data is workspace-scoped: switching between session -// tabs in the same workspace must update its parameters reactively instead of -// tearing the pane down and remounting it (which flickers). -test("keeps the v2 review pane mounted when switching session tabs in a workspace", async ({ page }) => { - await setup(page) - - await page.goto(sessionHref(sessionA)) - await expectSessionTitle(page, titleA) - - await page.getByRole("button", { name: "Toggle review" }).click() - const reviewTab = page.locator("#session-side-panel-review-tab") - const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel") - await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel") - await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel") - const review = page.locator('#review-panel [data-component="session-review-v2"]') - await expectAppVisible(review) - await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" })) - await writeProbe(page) - - await switchTab(page, titleB) - await expectSessionTitle(page, titleB) - await expectAppVisible(review) - await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" })) - expect(await readProbe(page)).toBe(PROBE) - - await switchTab(page, titleA) - await expectSessionTitle(page, titleA) - await expectAppVisible(review) - await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" })) - expect(await readProbe(page)).toBe(PROBE) - - const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') - await viewport.hover() - await page.mouse.wheel(0, 100_000) - await expect - .poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) - .toBeLessThanOrEqual(1) - await expect(page.getByRole("button", { name: "generated-2739.ts" })).toBeVisible() -}) - -type Probed = HTMLElement & { __e2eProbe?: string } - -async function switchTab(page: Page, title: string) { - await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click() -} - -async function writeProbe(page: Page) { - await page.locator('#review-panel [data-component="session-review-v2"]').evaluate((el, probe) => { - ;(el as Probed).__e2eProbe = probe - }, PROBE) -} - -async function readProbe(page: Page) { - return page.locator('#review-panel [data-component="session-review-v2"]').evaluate((el) => (el as Probed).__e2eProbe) -} - -async function setup(page: Page) { - await mockOpenCodeServer(page, { - directory, - project: { - id: projectID, - worktree: directory, - vcs: "git", - name: "review-tab-switch", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "test" }, - }, - sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], - vcsDiff: diffs, - pageMessages: () => ({ items: [] }), - }) - - await page.addInitScript( - ({ directory, server, sessions }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem( - "opencode.global.dat:server", - JSON.stringify({ - projects: { local: [{ worktree: directory, expanded: true }] }, - lastProject: { local: directory }, - }), - ) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))), - ) - }, - { directory, server, sessions: [sessionA, sessionB] }, - ) -} - -function session(id: string, title: string, created: number) { - return { - id, - slug: id, - projectID, - directory, - title, - version: "dev", - time: { created, updated: created }, - } -} - -function sessionHref(sessionID: string) { - return `/server/${base64Encode(server)}/session/${sessionID}` -} - -function fileDiff(file: string) { - return { - file, - additions: 1, - deletions: 1, - status: "modified", - patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`, - } -} diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts deleted file mode 100644 index 79b564820e..0000000000 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { expect, test, type Page } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectSessionTitle } from "../utils/waits" - -const directory = "C:/OpenCode/ReviewTerminalStacked" -const projectID = "proj_review_terminal_stacked" -const sessionID = "ses_review_terminal_stacked" -const title = "Review terminal stacked" -const branchDiffs = [ - fileDiff(".github/actions/setup-bun/action.yml", 7), - ...Array.from({ length: 2_739 }, (_, index) => - fileDiff( - `src/branch/d${String(Math.floor(index / 100)).padStart(5, "0")}/generated-${String(index).padStart(4, "0")}.ts`, - 100, - false, - ), - ), -] - -test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => { - test.setTimeout(120_000) - const events: Array<{ directory: string; payload: Record }> = [] - const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } } - let detailVersion = 1 - let detailFailures = 1 - await page.setViewportSize({ width: 1400, height: 900 }) - await mockOpenCodeServer(page, { - protocol: "v1", - directory, - project: { - id: projectID, - worktree: directory, - vcs: "git", - name: "review-terminal-stacked", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "test" }, - }, - sessions: [ - { - id: sessionID, - slug: "review-terminal-stacked", - projectID, - directory, - title, - version: "dev", - time: { created: 1700000000000, updated: 1700000000000 }, - }, - ], - sessionStatus: () => sessionStatus, - pageMessages: () => ({ items: [] }), - events: () => events.splice(0, 1), - eventRetry: 16, - }) - await page.route(/\/vcs(?:\?.*)?$/, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - branch: "review-pane-performance", - default_branch: "dev", - }), - }), - ) - await page.route("**/vcs/diff**", (route) => { - const url = new URL(route.request().url()) - const scope = url.searchParams.get("directory")?.replaceAll("\\", "/") - const detail = scope?.endsWith("/src/branch/d00027") - if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" }) - return route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify( - url.searchParams.get("mode") === "branch" - ? detail - ? branchDiffs - .filter((diff) => diff.file.startsWith("src/branch/d00027/")) - .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion)) - : branchDiffs - : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)), - ), - }) - }) - await page.route("**/pty*", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - location: { directory, project: { id: projectID, directory } }, - data: { - id: "pty_review_terminal", - title: "Terminal 1", - command: "cmd.exe", - args: [], - cwd: directory, - status: "running", - pid: 1, - }, - }), - }), - ) - await page.route("**/pty/pty_review_terminal*", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - location: { directory, project: { id: projectID, directory } }, - data: { - id: "pty_review_terminal", - title: "Terminal 1", - command: "cmd.exe", - args: [], - cwd: directory, - status: "running", - pid: 1, - }, - }), - }), - ) - await page.route("**/pty/pty_review_terminal/connect-token*", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - location: { directory, project: { id: projectID, directory } }, - data: { ticket: "e2e-ticket", expires_in: 60 }, - }), - }), - ) - await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined) - await page.addInitScript(() => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem( - "opencode.global.dat:layout", - JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), - ) - }) - - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - await expectSessionTitle(page, title) - await expect(page.locator("#review-panel")).toBeVisible() - await expectTree(page, 8, "git-0.ts") - - await selectMode(page, "Git changes", "Branch changes") - await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740") - await page.keyboard.press("Control+Backquote") - await expect(page.locator("#terminal-panel")).toBeVisible() - await expectTree(page, 2_773, "action.yml") - await expectStackGeometry(page) - - const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') - await treeViewport.hover() - await page.mouse.wheel(0, 100_000) - await expect - .poll(() => treeViewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) - .toBeLessThanOrEqual(1) - const lastFile = page.getByRole("button", { name: "generated-2738.ts" }) - await expect(lastFile).toBeVisible() - const bottomGap = await lastFile.evaluate((element) => { - const viewport = element.closest(".scroll-view__viewport")!.getBoundingClientRect() - return viewport.bottom - element.getBoundingClientRect().bottom - }) - expect(bottomGap).toBeGreaterThanOrEqual(0) - expect(bottomGap).toBeLessThanOrEqual(16) - const lazyDiff = page.waitForRequest((request) => { - const url = new URL(request.url()) - return ( - url.pathname === "/vcs/diff" && - url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true - ) - }) - await lastFile.click() - await lazyDiff - const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]') - await expect(preview).toContainText("after-1") - detailVersion = 2 - sessionStatus[sessionID] = { type: "busy" } - events.push(statusEvent("busy")) - await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() - const refreshedDiff = page.waitForRequest((request) => { - const url = new URL(request.url()) - return ( - url.pathname === "/vcs/diff" && - url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true - ) - }) - sessionStatus[sessionID] = { type: "idle" } - events.push(statusEvent("idle")) - await refreshedDiff - await expect(preview).toContainText("after-2") - await selectMode(page, "Branch changes", "Git changes") - await expectTree(page, 8, "git-0.ts") - await page.getByRole("button", { name: "git-0.ts" }).click() - await selectMode(page, "Git changes", "Branch changes") - await expectTree(page, 2_773, "action.yml") - - const filter = page.getByRole("searchbox", { name: "Filter files" }) - await filter.fill("generated-2738") - await expectTree(page, 1, "generated-2738.ts") - await filter.fill("") - await expectTree(page, 2_773, "action.yml") - - await page.getByRole("button", { name: "Toggle file tree" }).click() - await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0) - await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0) - await page.getByRole("button", { name: "Toggle file tree" }).click() - await expectTree(page, 2_773, "action.yml") - - await page.keyboard.press("Control+Backquote") - await expect(page.locator("#terminal-panel")).toHaveCount(0) - await expectTree(page, 2_773, "action.yml") - await page.keyboard.press("Control+Backquote") - await expect(page.locator("#terminal-panel")).toBeVisible() - await expectTree(page, 2_773, "action.yml") - - await page.getByRole("button", { name: "Toggle review" }).click() - await expect(page.locator("#review-panel")).toHaveCount(0) - await page.getByRole("button", { name: "Toggle review" }).click() - await expectTree(page, 2_773, "action.yml") - await page.setViewportSize({ width: 1_000, height: 700 }) - await expectTree(page, 2_773, "action.yml") - await expectStackGeometry(page) - await page.setViewportSize({ width: 1_000, height: 120 }) - await page.setViewportSize({ width: 1_400, height: 900 }) - await expectTree(page, 2_773, "action.yml") - await expectStackGeometry(page) -}) - -async function selectMode(page: Page, current: string, next: string) { - await page.getByRole("button", { name: current }).click() - const option = page.getByRole("option", { name: next }) - await expect(option).toBeVisible() - await option.click() -} - -async function expectTree(page: Page, total: number, file: string) { - await expectMountedTree(page, total) - await expect(page.getByRole("button", { name: file })).toBeVisible() -} - -async function expectMountedTree(page: Page, total: number) { - const tree = page.locator('#review-panel [data-component="file-tree-v2"]') - await expect(tree).toHaveAttribute("data-total-rows", String(total)) - await expect - .poll(() => tree.evaluate((element) => element.querySelectorAll('[data-slot="file-tree-v2-row"]').length)) - .toBeGreaterThan(0) - const state = await tree.evaluate((element) => ({ - root: element.getBoundingClientRect().height, - viewport: element.closest(".scroll-view__viewport")!.getBoundingClientRect().height, - rows: element.querySelectorAll('[data-slot="file-tree-v2-row"]').length, - })) - expect(state.viewport).toBeGreaterThan(0) - expect(state.root).toBeGreaterThan(0) - expect(state.rows).toBeGreaterThan(0) - expect(state.rows).toBeLessThanOrEqual(60) -} - -async function expectStackGeometry(page: Page) { - const geometry = await page.evaluate(() => { - const review = document.querySelector("#review-panel")! - const terminal = document.querySelector("#terminal-panel")! - const reviewParent = review.parentElement!.getBoundingClientRect() - const terminalParent = terminal.parentElement!.getBoundingClientRect() - return { - review: review.getBoundingClientRect().height, - reviewParent: reviewParent.height, - terminal: terminal.getBoundingClientRect().height, - terminalParent: terminalParent.height, - } - }) - expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1) - expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1) -} - -function base64Encode(value: string) { - return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") -} - -function statusEvent(type: "busy" | "idle") { - return { - directory, - payload: { type: "session.status", properties: { sessionID, status: { type } } }, - } -} - -function fileDiff(file: string, additions: number, loaded = true, version = 1) { - return { - file, - additions, - deletions: 0, - status: "modified", - patch: loaded - ? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n` - : `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`, - } -} diff --git a/packages/app/e2e/regression/session-list-path-loading.spec.ts b/packages/app/e2e/regression/session-list-path-loading.spec.ts index 3319514df6..4a3855122a 100644 --- a/packages/app/e2e/regression/session-list-path-loading.spec.ts +++ b/packages/app/e2e/regression/session-list-path-loading.spec.ts @@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async ( const pathBlocked = new Promise((resolve) => { releasePath = resolve }) - await page.route("**/api/path?*", async (route) => { - if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback() + await page.route("**/path?*", async (route) => { + if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback() await pathBlocked return route.fallback() }) diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 5ea9d4f761..036eaaef42 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -1,7 +1,6 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" -import { installSseTransport } from "../utils/sse-transport" import { expectSessionTitle } from "../utils/waits" const directory = "C:/OpenCode/RequestDocks" @@ -42,8 +41,7 @@ test("shows a pending question dock", async ({ page }) => { const rejectRequests: string[] = [] page.on("request", (request) => { if (request.method() !== "POST") return - if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`) - rejectRequests.push(request.url()) + if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url()) }) await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() @@ -65,9 +63,7 @@ test("shows a pending question dock", async ({ page }) => { await question.getByRole("radio", { name: /Minimal/ }).click() const reply = page.waitForRequest( - (request) => - request.method() === "POST" && - new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`, + (request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply", ) await question.getByRole("button", { name: "Submit" }).click() expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] }) @@ -100,69 +96,8 @@ test("shows a pending permission dock", async ({ page }) => { const reply = page.waitForRequest((request) => request.method() === "POST") await permission.getByRole("button", { name: "Allow once" }).click() const request = await reply - expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`) - expect(request.postDataJSON()).toEqual({ reply: "once" }) -}) - -test("restores the draft caret before typing after a request dock closes", async ({ page }) => { - const transport = await installSseTransport(page, { - server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, - retry: 20, - }) - await mockServer(page, { questions: [] }) - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - await transport.waitForConnection() - await expectSessionTitle(page, title) - - const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]') - const draft = "keep the caret at the end" - await editor.fill(draft) - await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))) - for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft") - const cursor = draft.length - 4 - await expect - .poll(() => - editor.evaluate((element) => { - const selection = window.getSelection() - if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1 - const range = selection.getRangeAt(0).cloneRange() - range.selectNodeContents(element) - range.setEnd(selection.anchorNode!, selection.anchorOffset) - return range.toString().length - }), - ) - .toBe(cursor) - await transport.send({ - directory, - payload: { - type: "question.asked", - properties: { - id: "question-caret", - sessionID, - questions: [ - { - header: "Continue", - question: "Continue?", - options: [{ label: "Yes", description: "Continue the session" }], - }, - ], - tool: { messageID: "message-caret", callID: "call-caret" }, - }, - }, - }) - const question = page.locator('[data-component="dock-prompt"][data-kind="question"]') - await expect(question).toBeVisible() - await expect(editor).toHaveCount(0) - - await transport.send({ - directory, - payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } }, - }) - await expect(question).toHaveCount(0) - await expect(editor).toBeVisible() - await page.keyboard.press("x") - - await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`) + expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`) + expect(request.postDataJSON()).toEqual({ response: "once" }) }) async function mockServer( @@ -173,7 +108,6 @@ async function mockServer( }, ) { await mockOpenCodeServer(page, { - protocol: "v2", directory, project: { id: projectID, diff --git a/packages/app/e2e/regression/session-timeline-accessibility.spec.ts b/packages/app/e2e/regression/session-timeline-accessibility.spec.ts deleted file mode 100644 index 598763c022..0000000000 --- a/packages/app/e2e/regression/session-timeline-accessibility.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { expect, test } from "@playwright/test" -import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture" - -test("space activates a focused timeline button instead of scrolling", async ({ page }) => { - const shellID = "prt_space_button_shell" - await setupTimeline(page, { - messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])], - settings: { shellToolPartsExpanded: false }, - reducedMotion: true, - }) - const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`) - await trigger.focus() - const before = await scroller.evaluate((element) => element.scrollTop) - await trigger.press("Space") - await expect(trigger).toHaveAttribute("aria-expanded", "true") - expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before) -}) - -function lines(count: number) { - return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") -} diff --git a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts index a9a4738da9..9ed5906bb0 100644 --- a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -1,13 +1,6 @@ import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible, expectSessionTitle } from "../utils/waits" -import { - analyzeVisualObservations, - defineVisualRegions, - startVisualProbe, - stopVisualProbe, - visualPlan, -} from "../utils/visual-stability" const directory = "C:/OpenCode/ContextResizeRegression" const projectID = "proj_context_resize_regression" @@ -43,82 +36,6 @@ test.describe("regression: session timeline context group resize", () => { expect(visibleOverlap).toEqual([]) expect(samples.at(-1)?.expanded).toBe("true") }) - - test("paints a stable exploring to explored transition", async ({ page }) => { - const events: { directory: string; payload: Record }[] = [] - await page.setViewportSize({ width: 1400, height: 900 }) - await mockServer(page, events, [ - ...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), - ...turn(10, true, "running"), - ]) - await configurePage(page) - - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - await expectSessionTitle(page, title) - const devtools = await page.context().newCDPSession(page) - await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 }) - const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first() - await expectAppVisible(context) - await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Exploring") - - const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]` - const regions = defineVisualRegions({ - status: { - selector: `${contextSelector} [data-component="tool-status-title"]`, - opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'], - }, - context: { selector: contextSelector, closest: '[data-timeline-row="AssistantPart"]' }, - following: { - selector: `[data-timeline-part-id="${followingTextID}"]`, - closest: '[data-timeline-row="AssistantPart"]', - }, - }) - await startVisualProbe(page, regions) - for (const [index, delay] of [120, 350, 80, 500].entries()) { - events.push({ - directory, - payload: { - type: "message.part.updated", - properties: { - part: contextTool( - contextIDs[index]!, - id("msg_assistant", 10), - ["read", "glob", "grep", "list"][index]!, - [ - { filePath: "src/recent-a.ts" }, - { path: directory, pattern: "**/*.ts" }, - { path: directory, pattern: "Explored" }, - { path: "src" }, - ][index]!, - ), - }, - }, - }) - await page.waitForTimeout(delay) - } - - await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored") - await page.waitForTimeout(700) - const trace = await stopVisualProbe(page) - const labels = trace.samples - .map((sample) => sample.regions.status?.label) - .filter((value): value is string => !!value) - .filter((value, index, all) => value !== all[index - 1]) - const issues = analyzeVisualObservations( - trace.samples, - visualPlan(regions, [ - { type: "required", regions: ["context", "following"] }, - { type: "opacity", regions: "all" }, - { type: "continuity", regions: "all" }, - { type: "motion", regions: "all" }, - { type: "label-stability", regions: "all" }, - { type: "flow", regions: ["context", "following"] }, - ]), - ) - - expect(labels).toEqual(["Exploring", "Explored"]) - expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([]) - }) }) async function configurePage(page: Page) { @@ -211,7 +128,7 @@ async function sampleExpansion(page: Page) { ) } -function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): Message[] { +function turn(index: number, target: boolean): Message[] { const userID = id("msg_user", index) const assistantID = id("msg_assistant", index) return [ @@ -246,22 +163,10 @@ function turn(index: number, target: boolean, status: "running" | "completed" = }, parts: target ? [ - contextTool( - contextIDs[0]!, - assistantID, - "read", - { filePath: "src/recent-a.ts", offset: 0, limit: 120 }, - status, - ), - contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status), - contextTool( - contextIDs[2]!, - assistantID, - "grep", - { path: directory, pattern: "Explored", include: "*.ts" }, - status, - ), - contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status), + contextTool(contextIDs[0]!, assistantID, "read", { filePath: "src/recent-a.ts", offset: 0, limit: 120 }), + contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }), + contextTool(contextIDs[2]!, assistantID, "grep", { path: directory, pattern: "Explored", include: "*.ts" }), + contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }), { id: followingTextID, sessionID, @@ -283,13 +188,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" = ] } -function contextTool( - partID: string, - messageID: string, - tool: string, - input: Record, - status: "running" | "completed" = "completed", -) { +function contextTool(partID: string, messageID: string, tool: string, input: Record) { return { id: partID, sessionID, @@ -298,7 +197,7 @@ function contextTool( callID: `call_${partID}`, tool, state: { - status, + status: "completed", input, output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`, title: input.filePath || input.path || input.pattern || "completed", @@ -308,19 +207,13 @@ function contextTool( } } -async function mockServer( - page: Page, - events: { directory: string; payload: Record }[] = [], - fixtureMessages = messages, -) { +async function mockServer(page: Page) { await mockOpenCodeServer(page, { directory, project: project(), provider: provider(), sessions: [session()], - pageMessages: () => ({ items: fixtureMessages }), - events: () => events.splice(0, 1), - eventRetry: 50, + pageMessages: () => ({ items: messages }), }) } diff --git a/packages/app/e2e/regression/session-timeline-context-state.spec.ts b/packages/app/e2e/regression/session-timeline-context-state.spec.ts deleted file mode 100644 index 37878325e1..0000000000 --- a/packages/app/e2e/regression/session-timeline-context-state.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - assistantMessage, - partUpdated, - setupTimeline, - toolPart, - userMessage, -} from "../performance/timeline-stability/fixture" - -test("preserves a collapsed context group through count and status updates", async ({ page }) => { - const ids = ["prt_closed_01_read", "prt_closed_02_glob"] - const inputs = { - read: { filePath: "src/a.ts", offset: 0, limit: 120 }, - glob: { path: ".", pattern: "**/*.ts" }, - } - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage( - [toolPart(ids[0]!, "read", "running", inputs.read), toolPart(ids[1]!, "glob", "running", inputs.glob)], - { completed: false }, - ), - ], - }) - const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`) - const trigger = group.locator('[data-slot="collapsible-trigger"]') - await expect(trigger).toHaveAttribute("aria-expanded", "false") - await timeline.send(partUpdated(toolPart(ids[0]!, "read", "completed", inputs.read)), 100) - await timeline.send(partUpdated(toolPart(ids[1]!, "glob", "completed", inputs.glob)), 300) - await expect(trigger).toHaveAttribute("aria-expanded", "false") -}) diff --git a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts deleted file mode 100644 index f07da121c6..0000000000 --- a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { expect, test } from "@playwright/test" -import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" - -test("renders completed write content", async ({ page }) => { - const id = "prt_file_projection_write" - await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - toolPart(id, "write", "completed", { filePath: "src/write.ts", content: "export const written = true\n" }), - ]), - ], - settings: { editToolPartsExpanded: true }, - }) - - await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="write-content"]`)).toBeVisible() -}) - -test("renders a completed single-file patch", async ({ page }) => { - const id = "prt_file_projection_single_patch" - await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - toolPart( - id, - "apply_patch", - "completed", - { files: ["src/a.ts"] }, - { - metadata: { - files: [ - { - filePath: "src/a.ts", - relativePath: "src/a.ts", - type: "update", - additions: 1, - deletions: 1, - before: "export const value = 1\n", - after: "export const value = 2\n", - }, - ], - }, - }, - ), - ]), - ], - settings: { editToolPartsExpanded: true }, - }) - - await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="apply-patch-file-diff"]`)).toBeVisible() -}) diff --git a/packages/app/e2e/regression/session-timeline-file-state.spec.ts b/packages/app/e2e/regression/session-timeline-file-state.spec.ts deleted file mode 100644 index cb228c13c7..0000000000 --- a/packages/app/e2e/regression/session-timeline-file-state.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - assistantMessage, - partUpdated, - setupTimeline, - toolPart, - userMessage, -} from "../performance/timeline-stability/fixture" - -test("updates edit diagnostics without resetting manual collapse state", async ({ page }) => { - const editID = "prt_diagnostics_edit" - const base = editPart(editID, []) - const timeline = await setupTimeline(page, { - messages: [userMessage(), assistantMessage([base])], - settings: { editToolPartsExpanded: true }, - }) - const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first() - await trigger.click() - await expect(trigger).toHaveAttribute("aria-expanded", "false") - await timeline.send( - partUpdated(editPart(editID, [diagnostic("First failure", 2), diagnostic("Second failure", 4)])), - 300, - ) - await expect(trigger).toHaveAttribute("aria-expanded", "false") - await timeline.send(partUpdated(editPart(editID, [])), 300) - await expect(trigger).toHaveAttribute("aria-expanded", "false") -}) - -test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => { - const patchID = "prt_nested_patch" - const files = [patchFile("src/a.ts", "update"), patchFile("src/b.ts", "add"), patchFile("src/old.ts", "delete")] - await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - toolPart( - patchID, - "apply_patch", - "completed", - { files: files.map((file) => file.filePath) }, - { metadata: { files } }, - ), - ]), - ], - settings: { editToolPartsExpanded: true }, - }) - const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`) - const outer = wrapper.locator('[data-slot="collapsible-trigger"]').first() - const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]') - await deleted.getByRole("button").click() - await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true") - await outer.click() - await expect(outer).toHaveAttribute("aria-expanded", "false") - await outer.click() - await expect(outer).toHaveAttribute("aria-expanded", "true") - await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true") -}) - -function patchFile(filePath: string, type: "add" | "update" | "delete") { - return { - filePath, - relativePath: filePath, - type, - additions: type === "delete" ? 0 : 4, - deletions: type === "add" ? 0 : 3, - before: type === "add" ? undefined : source(false), - after: type === "delete" ? undefined : source(true), - } -} - -function editPart(id: string, diagnostics: Record[]) { - return toolPart( - id, - "edit", - "completed", - { filePath: "src/edit.ts" }, - { - metadata: { - filediff: { file: "src/edit.ts", additions: 1, deletions: 1, before: source(false), after: source(true) }, - diagnostics, - }, - }, - ) -} - -function diagnostic(message: string, line: number) { - return { message, severity: 1, range: { start: { line, character: 0 }, end: { line, character: 2 } } } -} - -function source(changed: boolean) { - return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join( - "", - ) -} diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts deleted file mode 100644 index e5ef7998ea..0000000000 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" -import { - assistantMessage, - directory, - messageUpdated, - project, - session, - sessionID, - status, - textPart, - title, - userID, - userMessage, -} from "../performance/timeline-stability/fixture" -import { mockOpenCodeServer } from "../utils/mock-server" -import { installSseTransport } from "../utils/sse-transport" -import { expectSessionTitle } from "../utils/waits" - -const initialPageSize = 20 -const historyPageSize = 200 -const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) => - assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { - id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, - parentID: userID, - created: 1700000001000 + index * 1_000, - completed: index < initialPageSize, - }), -) -const messages = [userMessage(), ...assistants] -const lastAssistant = assistants.at(-1)! -const lastPartID = assistants.at(-1)!.parts[0]!.id -const userPartID = `prt_${userID}_text` -const completed = { - ...lastAssistant.info, - time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 }, -} -const scenarios = [ - { name: "completion", info: completed, idleFirst: false, interrupted: false }, - { - name: "interruption", - info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } }, - idleFirst: true, - interrupted: true, - }, -] as const - -test.use({ viewport: { width: 646, height: 1385 } }) - -for (const scenario of scenarios) { - test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => { - const requests: { before?: string; phase: "start" | "end" }[] = [] - const pages: { before?: string; limit: number }[] = [] - const roots: { sessionID: string; messageID: string }[] = [] - const sequence: string[] = [] - const history = Promise.withResolvers() - const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { - server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`, - retry: 20, - }) - await mockOpenCodeServer(page, { - directory, - project: project(), - provider: { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { - "claude-opus-4-6": { - id: "claude-opus-4-6", - name: "Claude Opus 4.6", - limit: { context: 200_000 }, - }, - }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "claude-opus-4-6" }, - }, - sessions: [session()], - sessionStatus: { [sessionID]: { type: "busy" } }, - beforeMessagesResponse: (request) => (request.before ? history.promise : Promise.resolve()), - onMessages: (request) => { - requests.push(request) - sequence.push(`messages:${request.phase}:${request.before ?? "latest"}`) - }, - onMessage: (request) => { - roots.push(request) - sequence.push(`message:${request.messageID}`) - }, - message: (requestedSessionID, messageID) => { - if (requestedSessionID !== sessionID) return - return messages.find((item) => item.info.id === messageID) - }, - pageMessages: (_, limit, before) => { - pages.push({ before, limit }) - const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length - const start = Math.max(0, end - limit) - return { - items: messages.slice(start, end), - cursor: start > 0 ? messages[start]!.info.id : undefined, - } - }, - }) - await page.addInitScript(() => { - const visibleParts = () => { - const virtual = document.querySelector("[data-timeline-virtual-content]") - const viewport = virtual?.closest(".scroll-view__viewport") - const view = viewport?.getBoundingClientRect() - if (!viewport || !view) return [] - return [...viewport.querySelectorAll("[data-timeline-part-id]")] - .filter((part) => { - const rect = part.getBoundingClientRect() - return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom - }) - .flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : [])) - } - const state = { - armed: false, - hidden: false, - visibleParts: [] as string[], - samples: 0, - stop: false, - arm() { - state.visibleParts = visibleParts() - state.armed = true - }, - } - ;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state - const sample = () => { - if (state.armed) { - const virtual = document.querySelector("[data-timeline-virtual-content]") - const viewport = virtual?.closest(".scroll-view__viewport") - const view = viewport?.getBoundingClientRect() - const visible = (partID: string) => { - const part = viewport?.querySelector(`[data-timeline-part-id="${CSS.escape(partID)}"]`) - const rect = part?.getBoundingClientRect() - return ( - !!rect && !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom - ) - } - if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID))) - state.hidden = true - state.samples++ - } - if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0)) - } - requestAnimationFrame(() => setTimeout(sample, 0)) - }) - - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - await transport.waitForConnection() - await expectSessionTitle(page, title) - await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible() - await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible() - await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2) - expect(requests.filter((request) => request.phase === "end")).toHaveLength(1) - expect(sequence.slice(0, 4)).toEqual([ - "messages:start:latest", - "messages:end:latest", - `message:${userID}`, - `messages:start:${messages.at(-initialPageSize)!.info.id}`, - ]) - await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize) - await page.evaluate(() => { - ;( - window as Window & { - __historyRootProbe?: { arm(): void } - } - ).__historyRootProbe!.arm() - }) - await waitForProbeSamples(page, 0) - expect(await visibleContentHidden(page)).toBe(false) - const beforeHistory = await probeSamples(page) - history.resolve() - await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length) - await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2) - await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() - await waitForProbeSamples(page, beforeHistory) - expect(pages).toEqual([ - { before: undefined, limit: initialPageSize }, - { before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize }, - ]) - expect(roots).toEqual([{ sessionID, messageID: userID }]) - - const message = messageUpdated(scenario.info) - const idle = status("idle") - for (const event of scenario.idleFirst ? [idle, message] : [message, idle]) { - const beforeEvent = await probeSamples(page) - await transport.send(event) - if (event === idle) await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0) - if (event === message && scenario.interrupted) - await expect(page.getByText("Interrupted", { exact: true })).toBeVisible() - await waitForProbeSamples(page, beforeEvent) - const current = await timelineState(page) - expect(current, JSON.stringify(current)).toMatchObject({ virtual: true }) - expect(current.rows, JSON.stringify(current)).toBeGreaterThan(0) - } - - expect(requests[0]).toEqual({ before: undefined, phase: "start", sessionID }) - expect(requests[1]).toEqual({ before: undefined, phase: "end", sessionID }) - await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0) - await expect(page.locator('[data-timeline-row="bottom-spacer"]')).toBeVisible() - if (scenario.interrupted) await expect(page.getByText("Interrupted", { exact: true })).toBeVisible() - expect( - await page.evaluate(() => { - const state = (window as Window & { __historyRootProbe?: { hidden: boolean; stop: boolean } }) - .__historyRootProbe! - state.stop = true - return state.hidden - }), - ).toBe(false) - }) -} - -function timelineState(page: Page) { - return page.evaluate(() => ({ - virtual: !!document.querySelector("[data-timeline-virtual-content]"), - rows: document.querySelectorAll("[data-timeline-key]").length, - })) -} - -function probeSamples(page: Page) { - return page.evaluate( - () => (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples, - ) -} - -async function waitForProbeSamples(page: Page, after: number) { - await page.waitForFunction( - (after) => - (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples >= after + 3, - after, - ) -} - -function visibleContentHidden(page: Page) { - return page.evaluate( - () => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden, - ) -} diff --git a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts deleted file mode 100644 index b303071c87..0000000000 --- a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - assistantMessage, - completedAssistantInfo, - messageUpdated, - partUpdated, - reasoningPart, - setupTimeline, - shell, - status, - textPart, - userMessage, -} from "../performance/timeline-stability/fixture" - -for (const expanded of [false, true]) { - test(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ page }) => { - const id = `prt_shell_default_${expanded}` - const timeline = await setupTimeline(page, { - messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])], - settings: { shellToolPartsExpanded: expanded }, - }) - const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`) - await expect(trigger).toHaveAttribute("aria-expanded", String(expanded)) - await trigger.click() - await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded)) - - await timeline.send(partUpdated(shell(id, "completed", lines(6))), 180) - await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")), 180) - await timeline.send(status("busy"), 100) - await timeline.send(status("idle"), 250) - await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded)) - }) -} - -test("shows and expands a running shell command without shimmering it", async ({ page }) => { - const id = "prt_shell_running_command" - const command = "sleep 10 && echo done" - await setupTimeline(page, { - messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })], - settings: { shellToolPartsExpanded: false }, - }) - - const tool = page.locator(`[data-timeline-part-id="${id}"]`) - await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true") - await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command) - await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0) - await tool.locator('[data-slot="collapsible-trigger"]').click() - await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true") - await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running") -}) - -test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => { - const reasoningID = "prt_reasoning_hidden" - const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false }) - const timeline = await setupTimeline(page, { - messages: [userMessage(), assistant], - settings: { showReasoningSummaries: false }, - cpuRate: 4, - }) - await timeline.send(status("busy"), 150) - - await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() - await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible() - await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0) - await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160) - await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() - await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180) - await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100) - await timeline.send(status("idle"), 300) - await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) - await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0) -}) - -test("moves busy through retry and recovery to final idle content", async ({ page }) => { - const assistant = assistantMessage([], { completed: false }) - const timeline = await setupTimeline(page, { - messages: [ - userMessage(undefined, { - summary: { - diffs: [ - { - file: "src/retry.ts", - additions: 1, - deletions: 1, - patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true", - }, - ], - }, - }), - assistant, - ], - }) - await timeline.send(status("busy"), 140) - await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() - await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0) - await timeline.send(status("retry"), 180) - await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible() - await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) - await timeline.send(status("busy", 2), 180) - await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() - await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140) - await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100) - await timeline.send(status("idle"), 350) - await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0) - await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) - await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() -}) - -function lines(count: number) { - return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n") -} diff --git a/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts b/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts deleted file mode 100644 index 3901f8865e..0000000000 --- a/packages/app/e2e/regression/session-timeline-locale-projection.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { expect, test } from "@playwright/test" -import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture" - -for (const profile of [ - { locale: "de", label: "Erkundet" }, - { locale: "ar", label: "تم الاستكشاف" }, -] as const) { - test(`projects translated context status in ${profile.locale}`, async ({ page }) => { - const ids = [`prt_locale_${profile.locale}_01_read`, `prt_locale_${profile.locale}_02_glob`] - await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }), - toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }), - ]), - ], - locale: profile.locale, - }) - - const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`) - await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", profile.label) - await expect(page.locator("html")).toHaveAttribute("lang", profile.locale) - }) -} diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts deleted file mode 100644 index b1aabcc32c..0000000000 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - assistantMessage, - setupTimeline, - status, - toolPart, - userMessage, - userText, - type PartSeed, -} from "../performance/timeline-stability/fixture" - -test.describe("session timeline projection", () => { - test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => { - const parts = [ - toolPart("prt_01_read", "read", "completed", { filePath: "src/a.ts" }), - toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }), - toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }), - toolPart("prt_04_list", "list", "completed", { path: "src" }), - toolPart("prt_webfetch", "webfetch", "completed", { url: "https://example.com" }), - toolPart( - "prt_websearch", - "websearch", - "completed", - { query: "timeline stability" }, - { output: "https://example.com/result" }, - ), - toolPart("prt_task", "task", "completed", { description: "Inspect timeline", subagent_type: "explore" }), - toolPart( - "prt_bash", - "bash", - "completed", - { command: "printf stable" }, - { output: "stable", title: "printf stable" }, - ), - editPart("prt_edit"), - toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }), - patchPart("prt_patch"), - toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }), - toolPart( - "prt_question", - "question", - "completed", - { questions: [{ question: "Keep stable?", header: "Stability", options: [] }] }, - { metadata: { answers: [["Yes"]] } }, - ), - toolPart("prt_skill", "skill", "completed", { name: "stability" }), - toolPart("prt_custom", "custom_mcp_tool", "completed", { target: "timeline", count: 2 }), - ] - await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) - - await expect( - page.locator('[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list"]'), - ).toBeVisible() - for (const id of [ - "prt_webfetch", - "prt_websearch", - "prt_task", - "prt_bash", - "prt_edit", - "prt_write", - "prt_patch", - "prt_question", - "prt_skill", - "prt_custom", - ]) { - await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible() - } - await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0) - }) - - test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => { - const firstUser = userMessage( - [ - userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", { - id: "prt_comment", - synthetic: true, - metadata: { - opencodeComment: { - path: "src/a.ts", - selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 }, - comment: "Keep this stable", - }, - }, - }), - userText("Continue after the comment", { id: "prt_visible_user" }), - ], - { summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } }, - ) - const aborted = assistantMessage( - [ - { id: "prt_before_abort", type: "text", text: "Before interruption" }, - { id: "prt_compaction", type: "compaction", auto: true }, - ], - { - id: "msg_1001_assistant_aborted", - error: { name: "MessageAbortedError", data: { message: "Stopped" } }, - }, - ) - const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], { - id: "msg_1002_assistant_failed", - error: { - name: "APIError", - data: { - message: JSON.stringify({ error: { type: "provider_error", message: "Visible provider failure" } }), - isRetryable: false, - }, - }, - created: 1700000003000, - }) - const nextUser = userMessage([userText("Second turn", { id: "prt_second_user" })], { - id: "msg_2000_second_user", - created: 1700000005000, - }) - const nextAssistant = assistantMessage([{ id: "prt_second_text", type: "text", text: "Second response" }], { - id: "msg_2001_second_assistant", - parentID: "msg_2000_second_user", - created: 1700000006000, - }) - const timeline = await setupTimeline(page, { messages: [firstUser, aborted, failed, nextUser, nextAssistant] }) - await timeline.send(status("idle"), 100) - const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - await scroller.evaluate((element) => (element.scrollTop = 0)) - - await expect(page.locator('[data-timeline-row="TurnDivider"]')).toHaveCount(1) - await expect(page.getByText("Session compacted", { exact: true })).toBeVisible() - await expect(page.getByText("Visible provider failure")).toBeVisible() - await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight)) - await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible() - }) - - test("renders comment strips and historical diff summary overflow", async ({ page }) => { - const user = userMessage( - [ - userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", { - id: "prt_comment_only", - synthetic: true, - metadata: { - opencodeComment: { - path: "src/a.ts", - selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 }, - comment: "Keep this stable", - }, - }, - }), - userText("Continue after the comment", { id: "prt_comment_visible" }), - ], - { summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } }, - ) - const nextUser = userMessage(undefined, { id: "msg_2000_diff_next_user", created: 1700000010000 }) - const nextAssistant = assistantMessage([], { - id: "msg_2001_diff_next_assistant", - parentID: "msg_2000_diff_next_user", - created: 1700000011000, - }) - await setupTimeline(page, { - messages: [user, assistantMessage(), nextUser, nextAssistant], - settings: { newLayoutDesigns: false }, - }) - const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) - await scroller.evaluate((element) => (element.scrollTop = 0)) - - await expect(page.locator('[data-timeline-row="CommentStrip"]')).toBeVisible() - await expect(page.getByText("Keep this stable", { exact: true })).toBeVisible() - await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() - await expect(page.getByText(/show all/i)).toBeVisible() - }) - - test("renders interruption independently when the turn is not compacted", async ({ page }) => { - const user = userMessage() - const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], { - id: "msg_1001_before", - error: { name: "MessageAbortedError", data: { message: "Stopped" } }, - }) - const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], { - id: "msg_1002_after", - created: 1700000003000, - }) - await setupTimeline(page, { messages: [user, before, after] }) - - await expect(page.getByText("Interrupted", { exact: true })).toBeVisible() - const rows = await page - .locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]') - .evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row"))) - expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"]) - }) - - test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => { - const text = "Use @explore with @src/a.ts and inspect the attachments" - const parts: PartSeed<"user">[] = [ - userText(text, { id: "prt_user_rich" }), - { - id: "prt_user_image", - type: "file", - mime: "image/png", - filename: "pixel.png", - url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", - }, - { - id: "prt_user_attachment", - type: "file", - mime: "application/json", - filename: "tsconfig.json", - url: "data:application/json;base64,e30=", - }, - { - id: "prt_user_reference", - type: "file", - mime: "text/plain", - filename: "a.ts", - url: "src/a.ts", - source: { type: "file", path: "src/a.ts", text: { value: "@src/a.ts", start: 18, end: 27 } }, - }, - { - id: "prt_user_agent", - type: "agent", - name: "explore", - source: { value: "@explore", start: 4, end: 12 }, - }, - ] - await setupTimeline(page, { messages: [userMessage(parts), assistantMessage()] }) - - await expect(page.getByAltText("pixel.png")).toBeVisible() - await expect(page.getByText("tsconfig.json")).toBeVisible() - await expect(page.getByText("@src/a.ts", { exact: true })).toBeVisible() - await expect(page.getByText("@explore", { exact: true })).toBeVisible() - }) -}) - -function editPart(id: string) { - return toolPart( - id, - "edit", - "completed", - { filePath: "src/a.ts" }, - { - metadata: { - filediff: { - file: "src/a.ts", - additions: 1, - deletions: 1, - before: "export const value = 1\n", - after: "export const value = 2\n", - }, - }, - }, - ) -} - -function patchPart(id: string) { - return toolPart( - id, - "apply_patch", - "completed", - { files: ["src/a.ts", "src/b.ts"] }, - { - metadata: { - files: [ - patchFile("src/a.ts", "update"), - patchFile("src/b.ts", "add"), - patchFile("src/old.ts", "delete"), - { ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" }, - ], - }, - }, - ) -} - -function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") { - return { - filePath, - relativePath: filePath, - type, - additions: type === "delete" ? 0 : 1, - deletions: type === "add" ? 0 : 1, - before: type === "add" ? undefined : "export const before = true\n", - after: type === "delete" ? undefined : "export const after = true\n", - } -} - -function summaryDiff(index: number) { - return { - file: `src/diff-${index}.ts`, - additions: 1, - deletions: 1, - patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`, - } -} diff --git a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts deleted file mode 100644 index 7c0864e584..0000000000 --- a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - assistantMessage, - reasoningPart, - setupTimeline, - status, - textPart, - toolPart, - userMessage, -} from "../performance/timeline-stability/fixture" - -const profiles = [ - { name: "summaries off no reasoning", summaries: false, reasoning: "", other: false, thinking: true, body: false }, - { - name: "summaries off reasoning heading", - summaries: false, - reasoning: "## Inspecting stability", - other: false, - thinking: true, - body: false, - }, - { - name: "summaries off with visible tool", - summaries: false, - reasoning: "## Inspecting stability", - other: true, - thinking: true, - body: false, - }, - { name: "summaries on no content", summaries: true, reasoning: "", other: false, thinking: true, body: false }, - { - name: "summaries on blank reasoning", - summaries: true, - reasoning: " ", - other: false, - thinking: true, - body: false, - }, - { - name: "summaries on visible reasoning", - summaries: true, - reasoning: "## Inspecting stability", - other: false, - thinking: false, - body: true, - }, - { - name: "summaries on visible tool no reasoning", - summaries: true, - reasoning: "", - other: true, - thinking: false, - body: false, - }, -] as const - -for (const profile of profiles) { - test(`projects busy reasoning profile ${profile.name}`, async ({ page }) => { - const reasoningID = `prt_reasoning_matrix_${profiles.indexOf(profile)}` - const parts = [ - ...(profile.reasoning ? [reasoningPart(reasoningID, profile.reasoning)] : []), - ...(profile.other - ? [toolPart(`prt_reasoning_tool_${profiles.indexOf(profile)}`, "skill", "running", { name: "inspect" })] - : []), - ] - const timeline = await setupTimeline(page, { - messages: [userMessage(), assistantMessage(parts, { completed: false })], - settings: { showReasoningSummaries: profile.summaries }, - }) - await timeline.send(status("busy"), 150) - - await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0) - await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(profile.body ? 1 : 0) - if (!profile.summaries && profile.reasoning.trim()) { - await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible() - } - }) -} - -test("does not infer reasoning visibility from provider identity", async ({ page }) => { - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([textPart("prt_provider_text", "No reasoning payload")], { completed: false }), - ], - settings: { showReasoningSummaries: true }, - }) - await timeline.send(status("busy"), 150) - - await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) - await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0) - await expect(page.locator('[data-timeline-part-id="prt_provider_text"]')).toBeVisible() -}) diff --git a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts deleted file mode 100644 index ad35eef601..0000000000 --- a/packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - assistantMessage, - completedAssistantInfo, - messageUpdated, - partUpdated, - setupTimeline, - shell, - status, - textPart, - toolPart, - userMessage, -} from "../performance/timeline-stability/fixture" - -test("groups singleton and separated context operations at correct boundaries", async ({ page }) => { - const parts = [ - toolPart("prt_boundary_01_read", "read", "completed", { filePath: "src/a.ts" }), - textPart("prt_boundary_02_text", "Boundary text"), - toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }), - toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }), - shell("prt_boundary_05_shell", "completed", "done"), - toolPart("prt_boundary_06_list", "list", "completed", { path: "src" }), - ] - await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) - - await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible() - await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible() - await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible() - await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5) -}) - -test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => { - const textID = "prt_event_order_text" - const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false }) - const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] }) - await timeline.send(status("busy"), 100) - await timeline.send(status("idle"), 100) - await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120) - await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 250) - - await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) - await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("Final after early idle") -}) diff --git a/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts deleted file mode 100644 index 54139cc371..0000000000 --- a/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { expect, test, type Locator, type Page } from "@playwright/test" -import { - assistantMessage, - setupTimeline, - shell, - textPart, - toolPart, - userMessage, -} from "../performance/timeline-stability/fixture" - -for (const deviceScaleFactor of [1.25, 1.5]) { - test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => { - const shellID = "prt_shell_outline" - const timeline = await setupTimeline(page, { - messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])], - settings: { newLayoutDesigns: true, shellToolPartsExpanded: true }, - reducedMotion: true, - deviceScaleFactor, - }) - const part = page.locator(`[data-timeline-part-id="${shellID}"]`) - const output = part.locator('[data-component="bash-output"]') - const row = page.locator("[data-timeline-key]", { has: part }) - await expect(output).toBeVisible() - await timeline.settle() - - const geometry = await row.evaluate((element) => { - const output = element.querySelector('[data-component="bash-output"]') - if (!output) throw new Error("Shell output is unavailable") - const rowRect = element.getBoundingClientRect() - const outputRect = output.getBoundingClientRect() - // Match a rounded-down measurement at a fractional device-pixel phase. - element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px` - element.style.transform = "translateY(0.25px)" - output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)") - output.style.setProperty("background", "rgb(0, 0, 0)", "important") - const style = getComputedStyle(output) - return { - outputWidth: outputRect.width, - outputHeight: outputRect.height, - borderColor: style.borderTopColor, - boxShadow: style.boxShadow, - clipMargin: getComputedStyle(element).overflowClipMargin, - } - }) - await timeline.settle() - - const clipped = await row.evaluate((element) => { - const output = element.querySelector('[data-component="bash-output"]')! - return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom - }) - expect(clipped).toBeCloseTo(0.49, 1) - - expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor) - const edges = await captureCardEdges(page, output) - - expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2) - expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2) - expect(geometry.borderColor).toBe("rgb(255, 0, 255)") - expect(geometry.boxShadow).toBe("none") - expect(geometry.clipMargin).toBe("0.5px") - expect(edges.magenta.top).toBeGreaterThan(0.75) - expect(edges.magenta.bottom).toBeGreaterThan(0.75) - expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2) - }) -} - -test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => { - const patchID = "prt_patch_outline" - const file = { - filePath: "src/outline.ts", - relativePath: "src/outline.ts", - type: "update", - additions: 1, - deletions: 1, - before: "const outline = false\n", - after: "const outline = true\n", - } - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }), - ]), - ], - settings: { editToolPartsExpanded: true, newLayoutDesigns: true }, - reducedMotion: true, - }) - const part = page.locator(`[data-timeline-part-id="${patchID}"]`) - const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]') - const row = page.locator("[data-timeline-key]", { has: part }) - await expect(card).toBeVisible() - await timeline.settle() - - const geometry = await row.evaluate((element) => { - const card = element.querySelector('[data-component="accordion"][data-scope="apply-patch"]') - if (!card) throw new Error("Patch card is unavailable") - const rowRect = element.getBoundingClientRect() - const cardRect = card.getBoundingClientRect() - element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px` - const clipMargin = getComputedStyle(element).overflowClipMargin - const bottom = element.getBoundingClientRect().bottom - return { - overflow: card.getBoundingClientRect().bottom - bottom, - paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin), - clipMargin, - cardWidth: cardRect.width, - cardHeight: cardRect.height, - } - }) - await timeline.settle() - - expect(geometry.overflow).toBeCloseTo(0.49, 1) - expect(geometry.paintOverflow).toBeLessThanOrEqual(0) - const edges = await captureCardEdges(page, card) - expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2) - expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2) - expect(edges.luminance.top).toBeLessThan(245) - expect(edges.luminance.bottom).toBeLessThan(245) - expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10) - expect(geometry.clipMargin).toBe("0.5px") -}) - -test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => { - const secondUserID = "msg_outline_second_user" - await setupTimeline(page, { - messages: [ - userMessage(undefined, { - summary: { - diffs: [ - { - file: "src/summary.ts", - additions: 1, - deletions: 1, - patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2", - }, - ], - }, - }), - assistantMessage([textPart("prt_outline_text", "Assistant text")]), - userMessage(undefined, { id: secondUserID, created: 1700000010000 }), - assistantMessage([], { - id: "msg_outline_second_assistant", - parentID: secondUserID, - created: 1700000011000, - }), - ], - }) - await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() - await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible() - - const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) => - elements.map((element) => ({ - tag: element.querySelector("[data-timeline-row]")?.dataset.timelineRow, - clipMargin: getComputedStyle(element).overflowClipMargin, - })), - ) - expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true) - expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }]) -}) - -async function captureCardEdges(page: Page, card: Locator) { - const box = await card.boundingBox() - if (!box) throw new Error("Tool card bounds are unavailable") - const viewport = page.viewportSize() - if (!viewport) throw new Error("Viewport bounds are unavailable") - const screenshot = await page.screenshot() - return page.evaluate( - async ({ source, box, viewport }) => { - const image = new Image() - image.src = source - await image.decode() - const canvas = document.createElement("canvas") - canvas.width = image.naturalWidth - canvas.height = image.naturalHeight - const context = canvas.getContext("2d") - if (!context) throw new Error("2D canvas is unavailable") - context.drawImage(image, 0, 0) - const scale = { - x: image.naturalWidth / viewport.width, - y: image.naturalHeight / viewport.height, - } - const rows = (candidates: number[]) => { - const left = Math.floor((box.x + 8) * scale.x) - const width = Math.floor((box.width - 16) * scale.x) - return candidates.map((row) => { - const pixels = context.getImageData(left, row, width, 1).data - const indexes = Array.from({ length: width }, (_, index) => index * 4) - return { - luminance: - indexes - .map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3) - .reduce((sum, value) => sum + value, 0) / width, - magenta: - indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200) - .length / width, - } - }) - } - const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data - const columns = new Uint32Array(image.naturalWidth) - for (let index = 0; index < pixels.length; index += 4) { - if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue - columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1 - } - const top = box.y * scale.y - const bottom = (box.y + box.height) * scale.y - const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)]) - const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1]) - return { - box, - luminance: { - top: Math.min(...topRows.map((row) => row.luminance)), - bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance, - }, - magenta: { - top: Math.max(...topRows.map((row) => row.magenta)), - bottom: Math.max(...bottomRows.map((row) => row.magenta)), - vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length, - }, - } - }, - { - source: `data:image/png;base64,${screenshot.toString("base64")}`, - viewport, - box, - }, - ) -} diff --git a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts deleted file mode 100644 index 99f1acf270..0000000000 --- a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - assistantMessage, - partUpdated, - setupTimeline, - toolPart, - userMessage, -} from "../performance/timeline-stability/fixture" - -test("renders every tool error outcome without leaking hidden tools", async ({ page }) => { - const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"] - const parts = ordinary.map((tool, index) => - toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }), - ) - parts.push( - toolPart("prt_question_dismissed", "question", "error", questionInput(), { - error: "The user dismissed this question", - }), - toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }), - toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }), - ) - await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) - - await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1) - await expect(page.getByText(/dismissed/i)).toBeVisible() - await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0) - for (let index = 0; index < ordinary.length; index++) { - await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible() - } -}) - -test("transitions shell and question through running error outcomes", async ({ page }) => { - const shellID = "prt_transition_error_shell" - const questionID = "prt_transition_error_question" - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage( - [ - toolPart(shellID, "bash", "pending", { command: "exit 1" }), - toolPart(questionID, "question", "pending", questionInput()), - ], - { completed: false }, - ), - ], - }) - await timeline.waitForPart(shellID) - await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) - await timeline.send(partUpdated(toolPart(shellID, "bash", "running", { command: "exit 1" })), 120) - await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180) - await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0) - await timeline.send( - partUpdated(toolPart(shellID, "bash", "error", { command: "exit 1" }, { error: "Command exited 1" })), - 180, - ) - await timeline.send( - partUpdated( - toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }), - ), - 250, - ) - - await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible() - await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i) -}) - -test("labels all web search provider variants", async ({ page }) => { - const parts = [ - toolPart( - "prt_search_parallel", - "websearch", - "completed", - { query: "parallel" }, - { metadata: { provider: "parallel" } }, - ), - toolPart("prt_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }), - toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }), - ] - await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) - - await expect(page.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible() - await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible() - await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible() -}) - -function questionInput() { - return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] } -} - -function errorInput(tool: string) { - if (tool === "bash") return { command: "exit 1" } - if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" } - if (tool === "apply_patch") return { files: ["src/error.ts"] } - if (tool === "webfetch") return { url: "https://example.com" } - if (tool === "websearch") return { query: "failure" } - if (tool === "task") return { description: "Fail task", subagent_type: "explore" } - if (tool === "skill") return { name: "failure" } - return { target: "failure" } -} diff --git a/packages/app/e2e/regression/session-timeline-tool-state.spec.ts b/packages/app/e2e/regression/session-timeline-tool-state.spec.ts deleted file mode 100644 index 63646f4454..0000000000 --- a/packages/app/e2e/regression/session-timeline-tool-state.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - assistantMessage, - partUpdated, - setupTimeline, - toolPart, - userMessage, -} from "../performance/timeline-stability/fixture" - -test("updates expanded web search links without resetting expansion", async ({ page }) => { - const searchID = "prt_websearch_mutation" - const input = { query: "timeline stability" } - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([toolPart(searchID, "websearch", "completed", input, { output: "https://example.com/one" })]), - ], - }) - const wrapper = page.locator(`[data-timeline-part-id="${searchID}"]`) - const trigger = wrapper.locator('[data-slot="collapsible-trigger"]') - await trigger.click() - await expect(trigger).toHaveAttribute("aria-expanded", "true") - await timeline.send( - partUpdated( - toolPart(searchID, "websearch", "completed", input, { - output: "https://example.com/one\nhttps://example.com/two", - }), - ), - 300, - ) - await expect(trigger).toHaveAttribute("aria-expanded", "true") - await expect(wrapper.locator('a[href="https://example.com/two"]')).toBeVisible() -}) - -test("preserves an expanded tool error card across duplicate delivery", async ({ page }) => { - const toolID = "prt_duplicate_error" - const failed = toolPart(toolID, "bash", "error", { command: "exit 1" }, { error: "Command failed visibly" }) - const timeline = await setupTimeline(page, { messages: [userMessage(), assistantMessage([failed])] }) - const wrapper = page.locator(`[data-timeline-part-id="${toolID}"]`) - const trigger = wrapper.locator('[data-slot="collapsible-trigger"]') - await trigger.click() - await expect(trigger).toHaveAttribute("aria-expanded", "true") - await timeline.send(partUpdated(failed), 150) - await timeline.send(partUpdated(failed), 250) - await expect(trigger).toHaveAttribute("aria-expanded", "true") - await expect(wrapper).toContainText("Command failed visibly") -}) - -test("renders multiple question answers and preserves open state on answer updates", async ({ page }) => { - const questionID = "prt_multi_question" - const input = { - questions: [ - { header: "First", question: "First choice?", options: [] }, - { header: "Second", question: "Second choice?", options: [], multiple: true }, - ], - } - const timeline = await setupTimeline(page, { - messages: [ - userMessage(), - assistantMessage([ - toolPart(questionID, "question", "completed", input, { metadata: { answers: [["A"], ["B", "C"]] } }), - ]), - ], - }) - const wrapper = page.locator(`[data-timeline-part-id="${questionID}"]`) - const trigger = wrapper.locator('[data-slot="collapsible-trigger"]') - await expect(trigger).toHaveAttribute("aria-expanded", "true") - await timeline.send( - partUpdated( - toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Updated"], ["B", "C"]] } }), - ), - 300, - ) - await expect(trigger).toHaveAttribute("aria-expanded", "true") - await expect(wrapper).toContainText("Updated") - await expect(wrapper).toContainText("B, C") -}) diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts deleted file mode 100644 index 778ff3a3af..0000000000 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { expect, test } from "@playwright/test" -import { - assistantMessage, - partUpdated, - setupTimeline, - status, - textPart, - userMessage, -} from "../performance/timeline-stability/fixture" - -test("keeps one connection open while delivering multiple events", async ({ page }) => { - const timeline = await setupTimeline(page) - - const first = await timeline.transport.send(partUpdated(textPart("prt_transport_first", "first event"))) - const second = await timeline.transport.send(partUpdated(textPart("prt_transport_second", "second event"))) - - await timeline.waitForPart("prt_transport_first") - await timeline.waitForPart("prt_transport_second") - expect(first.connectionID).toBe(second.connectionID) - expect(await timeline.transport.connections()).toHaveLength(1) - expect(await timeline.transport.acknowledgements()).toHaveLength(2) -}) - -test("delivers a burst from one stream chunk", async ({ page }) => { - const timeline = await setupTimeline(page) - const acknowledgements = await timeline.transport.burst([ - partUpdated(textPart("prt_transport_burst_a", "burst a")), - partUpdated(textPart("prt_transport_burst_b", "burst b")), - ]) - - await timeline.waitForPart("prt_transport_burst_a") - await timeline.waitForPart("prt_transport_burst_b") - expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1]) - expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(2) -}) - -test("parses split JSON and a split multibyte code point", async ({ page }) => { - const timeline = await setupTimeline(page) - const payload = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603")) - const encoded = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`) - const snowman = new TextEncoder().encode("\u2603")[0]! - const multibyte = encoded.indexOf(snowman) - - const acknowledgement = await timeline.transport.split(payload, [9, multibyte + 1, multibyte + 2]) - - await timeline.waitForPart("prt_transport_split") - await expect(page.locator('[data-timeline-part-id="prt_transport_split"]')).toContainText( - "split snowman \u2603\u2603\u2603", - ) - expect(acknowledgement.chunkCount).toBe(4) -}) - -test("delivers server heartbeat without mutating the timeline", async ({ page }) => { - const timeline = await setupTimeline(page, { - messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])], - }) - const before = await page.locator("[data-timeline-row]").allTextContents() - - await timeline.transport.heartbeat() - await timeline.settle() - - expect(await page.locator("[data-timeline-row]").allTextContents()).toEqual(before) - expect(await timeline.transport.connections()).toHaveLength(1) -}) - -test("reconnects after a clean close", async ({ page }) => { - const timeline = await setupTimeline(page, { eventRetry: 10 }) - const first = await timeline.transport.waitForConnection() - - await timeline.transport.close() - const second = await timeline.transport.waitForConnection({ after: first.id }) - await timeline.transport.send(partUpdated(textPart("prt_transport_close", "after close"))) - - await timeline.waitForPart("prt_transport_close") - expect(second.id).toBeGreaterThan(first.id) - expect((await timeline.transport.connections())[0]?.endedBy).toBe("close") -}) - -test("reconnects after a stream error", async ({ page }) => { - const timeline = await setupTimeline(page, { eventRetry: 10 }) - const first = await timeline.transport.waitForConnection() - - await timeline.transport.error("contract failure") - const second = await timeline.transport.waitForConnection({ after: first.id }) - await timeline.transport.send(status("busy")) - - await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2) - expect(second.id).toBeGreaterThan(first.id) - expect((await timeline.transport.connections())[0]?.endedBy).toBe("error") -}) - -test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { - const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" }) - const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { - id: "timeline-event-7", - }) - await timeline.waitForPart("prt_transport_id") - - await timeline.transport.error("retry with event id") - const connection = await timeline.transport.waitForConnection({ after: first.connectionID }) - - expect(first.eventID).toBe("timeline-event-7") - expect(connection.headers["last-event-id"]).toBeUndefined() -}) - -test("passes through non-event fetches", async ({ page }) => { - const timeline = await setupTimeline(page) - - const health = await page.evaluate(async () => { - const response = await fetch("/global/health") - return response.json() - }) - - expect(health).toEqual({ healthy: true }) - expect(await timeline.transport.connections()).toHaveLength(1) -}) diff --git a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts index 55e7121275..603c411d55 100644 --- a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts +++ b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts @@ -27,7 +27,6 @@ test("animates todo lifecycle without replaying it across session tabs", async ( test.setTimeout(90_000) const events: EventPayload[] = [] const todos: Record = { [sourceID]: [], [otherID]: [] } - const sessionStatus: Record = {} await mockOpenCodeServer(page, { directory, @@ -57,11 +56,9 @@ test("animates todo lifecycle without replaying it across session tabs", async ( default: { providerID: "opencode", modelID: "claude-opus-4-6" }, }, sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)], - sessionStatus: { [sourceID]: { type: "busy" } }, pageMessages: () => ({ items: [] }), events: () => events.splice(0, 1), eventRetry: 16, - sessionStatus: () => sessionStatus, todos: (sessionID) => todos[sessionID] ?? [], }) await configurePage(page) @@ -71,7 +68,6 @@ test("animates todo lifecycle without replaying it across session tabs", async ( const dock = page.locator('[data-component="session-todo-dock"]') await expect(dock).toHaveCount(0) - sessionStatus[sourceID] = { type: "busy" } events.push(statusEvent(sourceID, "busy")) await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts deleted file mode 100644 index 019cc156ec..0000000000 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" -import { currentSession, mockOpenCodeServer } from "../utils/mock-server" -import { expectSessionTitle } from "../utils/waits" - -const directory = "C:/OpenCode/SubagentNavigation" -const projectID = "proj_subagent_navigation" -const parentID = "ses_subagent_parent" -const childID = "ses_subagent_child" -const parentTitle = "Parent session" -const childTitle = "Subagent child session" -// Child session pages derive their heading from the task part that spawned them. -const taskDescription = "Inspect child navigation" - -type EventPayload = { directory: string; payload: Record } - -test.use({ viewport: { width: 1440, height: 900 } }) - -test("navigates to a subagent child session missing from the session list", async ({ page }) => { - await setup(page) - await openChildFromParent(page) - - await expectSessionTitle(page, taskDescription) - await expect(page.getByRole("heading", { name: parentTitle })).toHaveCount(0) - - const titlebarRight = page.locator("#opencode-titlebar-right") - await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1) -}) - -test("shows the not found fallback when the viewed session is deleted", async ({ page }) => { - const events: EventPayload[] = [] - await setup(page, () => events.splice(0, 1)) - await openChildFromParent(page) - await expectSessionTitle(page, taskDescription) - - events.push({ - directory, - payload: { type: "session.deleted", properties: { info: childSession() } }, - }) - - await expect(page.getByText("This session cannot be found")).toBeVisible() - await expect(page.getByRole("button", { name: "Close Tab" })).toBeVisible() - await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0) -}) - -async function setup(page: Page, events?: () => EventPayload[]) { - await mockOpenCodeServer(page, { - directory, - project: { - id: projectID, - worktree: directory, - vcs: "git", - name: "subagent-navigation", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { - "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } }, - }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "claude-opus-4-6" }, - }, - sessions: [session(parentID, parentTitle, 1700000000000), childSession()], - pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }), - events, - eventRetry: events ? 16 : undefined, - }) - // The child session resolves by ID but is absent from the session list, - // matching a subagent session that has not been loaded into the list cache yet. - await page.route( - (url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"), - (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify({ - data: [currentSession(session(parentID, parentTitle, 1700000000000))], - cursor: {}, - }), - }), - ) - await configurePage(page) -} - -async function openChildFromParent(page: Page) { - await page.goto(sessionHref(parentID)) - await expectSessionTitle(page, parentTitle) - - const card = page.locator(`a[href="${sessionHref(childID)}"]`) - await expect(card).toBeVisible() - await card.click() - - await expect(page).toHaveURL(new RegExp(`/server/.+/session/${childID}$`), { timeout: 15_000 }) -} - -function session(id: string, title: string, created: number, extra?: Record) { - return { - id, - slug: id, - projectID, - directory, - title, - version: "dev", - time: { created, updated: created }, - ...extra, - } -} - -function childSession() { - return session(childID, childTitle, 1700000001000, { parentID }) -} - -function parentMessages() { - const userID = "msg_user_0001" - const assistantID = "msg_assistant_0001" - return [ - { - info: { - id: userID, - sessionID: parentID, - role: "user", - time: { created: 1700000000000 }, - agent: "build", - model: { providerID: "opencode", modelID: "claude-opus-4-6" }, - }, - parts: [ - { - id: "prt_user_text_0001", - sessionID: parentID, - messageID: userID, - type: "text", - text: "Delegate work to a subagent", - }, - ], - }, - { - info: { - id: assistantID, - sessionID: parentID, - role: "assistant", - time: { created: 1700000001000, completed: 1700000002000 }, - parentID: userID, - modelID: "claude-opus-4-6", - providerID: "opencode", - mode: "build", - agent: "build", - path: { cwd: directory, root: directory }, - cost: 0.01, - tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, - finish: "stop", - }, - parts: [ - { - id: "prt_tool_task_0001", - sessionID: parentID, - messageID: assistantID, - type: "tool", - callID: "call_task_0001", - tool: "task", - state: { - status: "completed", - input: { description: taskDescription, subagent_type: "explore" }, - output: "Subagent finished", - title: taskDescription, - metadata: { sessionId: childID }, - time: { start: 1700000001000, end: 1700000002000 }, - }, - }, - ], - }, - ] -} - -async function configurePage(page: Page) { - const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` - await page.addInitScript( - ({ directory, server, sessionId }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem( - "opencode.global.dat:server", - JSON.stringify({ - projects: { local: [{ worktree: directory, expanded: true }] }, - lastProject: { local: directory }, - }), - ) - localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ type: "session", server, sessionId }])) - }, - { directory, server, sessionId: parentID }, - ) -} - -function sessionHref(sessionID: string) { - const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` - return `/server/${base64Encode(server)}/session/${sessionID}` -} diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts deleted file mode 100644 index b969b590d8..0000000000 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { expect, test, type Page, type Route } from "@playwright/test" -import { base64Encode } from "@opencode-ai/core/util/encode" -import { currentSession } from "../utils/mock-server" - -const server = "http://127.0.0.1:4096" -const sessionA = session("ses_tab_a", "Tab A session") -const sessionB = session("ses_tab_b", "Tab B session") -const sessionC = session("ses_tab_c", "Tab C session") -const unresolvedSessionID = "ses_tab_unresolved" - -test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => { - await mockServer(page) - await page.addInitScript( - ({ server, sessionA, sessionB }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify([ - { type: "session", server, sessionId: sessionA }, - { type: "session", server, sessionId: sessionB }, - ]), - ) - }, - { server, sessionA: sessionA.id, sessionB: sessionB.id }, - ) - - const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}` - const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}` - await page.goto(hrefA) - await expect(page.getByText(sessionA.title).first()).toBeVisible() - - const linkB = page.locator(`a[data-titlebar-tab-link][href="${hrefB}"]`) - await expect(linkB).toBeVisible() - const box = await linkB.boundingBox() - if (!box) throw new Error("tab link has no bounding box") - await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) - await page.mouse.down() - - // Navigation must happen on mousedown, before the button is released. - await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) - await page.mouse.up() - await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) -}) - -test("keyboard navigation follows the visible tab order", async ({ page }) => { - await mockServer(page) - await page.addInitScript( - ({ server, sessionA, unresolved, sessionC }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify([ - { type: "session", server, sessionId: sessionA }, - { type: "session", server, sessionId: unresolved }, - { type: "session", server, sessionId: sessionC }, - ]), - ) - }, - { server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id }, - ) - - const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}` - const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}` - await page.goto(hrefA) - await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2) - await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible() - - await page.keyboard.press("Control+Alt+ArrowRight") - - await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) -}) - -function session(id: string, title: string) { - return { - id, - slug: id, - projectID: "project-tabs", - directory: "C:/tab-project", - title, - version: "dev", - time: { created: 1, updated: 1 }, - } -} - -async function mockServer(page: Page) { - const sessions = [sessionA, sessionB, sessionC] - await page.route("**/*", async (route) => { - const url = new URL(route.request().url()) - if (url.origin !== server) return route.fallback() - if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname)) - return new Promise(() => {}) - if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") - return sse(route) - if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) - if (url.pathname === "/api/session/active") return json(route, { data: {} }) - const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`) - if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) - if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`)) - return json(route, { data: [], cursor: {} }) - const byId = sessions.find((item) => url.pathname === `/session/${item.id}`) - if (byId) return json(route, byId) - if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) - if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, []) - if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) - if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) - return json(route, []) - if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) - if (url.pathname === "/provider") - return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) - if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) - if (url.pathname === "/project" || url.pathname === "/project/current") { - const project = { - id: sessionA.projectID, - worktree: sessionA.directory, - vcs: "git", - time: { created: 1, updated: 1 }, - sandboxes: [], - } - return json(route, url.pathname === "/project" ? [project] : project) - } - if (url.pathname === "/path") - return json(route, { - state: sessionA.directory, - config: sessionA.directory, - worktree: sessionA.directory, - directory: sessionA.directory, - home: sessionA.directory, - }) - if (url.pathname === "/api/path") - return json(route, { - state: sessionA.directory, - config: sessionA.directory, - worktree: sessionA.directory, - directory: sessionA.directory, - home: sessionA.directory, - }) - if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) - if (url.pathname === "/api/vcs") - return json(route, { - location: { directory: sessionA.directory }, - data: { branch: "main", defaultBranch: "main" }, - }) - return json(route, {}) - }) -} - -function json(route: Route, body: unknown, status = 200) { - return route.fulfill({ - status, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify(body), - }) -} - -function sse(route: Route) { - return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) -} diff --git a/packages/app/e2e/regression/terminal-composer-focus.spec.ts b/packages/app/e2e/regression/terminal-composer-focus.spec.ts deleted file mode 100644 index 99bf689085..0000000000 --- a/packages/app/e2e/regression/terminal-composer-focus.spec.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectSessionTitle } from "../utils/waits" - -const directory = "C:/OpenCode/TerminalComposerFocus" -const projectID = "proj_terminal_composer_focus" -const sessionID = "ses_terminal_composer_focus" -const ptyID = "pty_terminal_composer_focus" -const newPtyID = "pty_terminal_composer_focus_new" - -test.use({ viewport: { width: 1440, height: 900 } }) - -test.beforeEach(async ({ page }) => { - await mockOpenCodeServer(page, { - protocol: "v2", - directory, - project: { - id: projectID, - worktree: directory, - vcs: "git", - name: "terminal-composer-focus", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "test" }, - }, - sessions: [ - { - id: sessionID, - slug: "terminal-composer-focus", - projectID, - directory, - title: "Terminal composer focus", - version: "dev", - time: { created: 1700000000000, updated: 1700000000000 }, - }, - ], - pageMessages: () => ({ items: [] }), - }) - await page.route("**/api/pty*", (route) => { - expect(new URL(route.request().url()).searchParams.get("location[directory]")).toBe(directory) - return route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), - }) - }) - await page.route(`**/api/pty/${ptyID}*`, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), - }), - ) - await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), - }), - ) - await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined) - await page.addInitScript(() => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - }) -}) - -test("routes typing to the composer unless the open terminal is focused", async ({ page }) => { - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - await expectSessionTitle(page, "Terminal composer focus") - - const composer = page.locator('[data-component="prompt-input"]') - const terminal = page.locator('[data-component="terminal"]') - await page.keyboard.press("Control+Backquote") - await expect(terminal).toBeVisible() - await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true) - - await page.keyboard.type("x") - await expect(composer).toHaveText("") - - await page.waitForTimeout(300) - await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) - await page.keyboard.type("a") - - await expect(composer).toBeFocused() - await expect(composer).toHaveText("a") -}) - -test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => { - const ghostty = Promise.withResolvers() - const release = Promise.withResolvers() - const created = { count: 0 } - await page.route("**/api/pty*", (route) => { - created.count += 1 - return route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), - }) - }) - await page.route(/ghostty-web/, async (route) => { - ghostty.resolve() - await release.promise - await route.continue() - }) - await seedCachedTerminal(page) - - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" }) - await expectSessionTitle(page, "Terminal composer focus") - - const composer = page.locator('[data-component="prompt-input"]') - const terminal = page.locator('[data-component="terminal"]') - await expect(terminal).toBeVisible() - expect(created.count).toBe(0) - await ghostty.promise - await composer.click() - await expect(composer).toBeFocused() - - release.resolve() - await expect(terminal.locator("textarea")).toHaveCount(1) - await page.waitForTimeout(300) - await expect(composer).toBeFocused() -}) - -test("keeps newer composer focus while an explicit terminal open finishes", async ({ page }) => { - const ghostty = Promise.withResolvers() - const release = Promise.withResolvers() - await page.route(/ghostty-web/, async (route) => { - ghostty.resolve() - await release.promise - await route.continue() - }) - - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - await expectSessionTitle(page, "Terminal composer focus") - - const composer = page.locator('[data-component="prompt-input"]') - const terminal = page.locator('[data-component="terminal"]') - await page.keyboard.press("Control+Backquote") - await expect(terminal).toBeVisible() - await ghostty.promise - await composer.click() - await expect(composer).toBeFocused() - - release.resolve() - await expect(terminal.locator("textarea")).toHaveCount(1) - await page.waitForTimeout(50) - await expect(composer).toBeFocused() -}) - -test("focuses a terminal created from the new-terminal button", async ({ page }) => { - const created = { count: 0 } - await page.route("**/api/pty*", (route) => { - created.count += 1 - const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2") - return route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ location: ptyLocation(), data: next }), - }) - }) - await page.route(`**/api/pty/${newPtyID}*`, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(newPtyID, "Terminal 2") }), - }), - ) - await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), - }), - ) - await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined) - - await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - await expectSessionTitle(page, "Terminal composer focus") - - const composer = page.locator('[data-component="prompt-input"]') - const terminal = page.locator('[data-component="terminal"]') - await page.keyboard.press("Control+Backquote") - await expect(terminal.locator("textarea")).toHaveCount(1) - await composer.click() - await expect(composer).toBeFocused() - - await page.getByRole("button", { name: "New terminal" }).click() - await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true") - await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true) -}) - -function seedCachedTerminal(page: Page) { - return page.addInitScript( - ({ terminalKey, ptyID }) => { - localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } })) - localStorage.setItem( - terminalKey, - JSON.stringify({ - active: ptyID, - all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }], - }), - ) - }, - { terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID }, - ) -} - -function ptyLocation() { - return { directory, project: { id: projectID, directory } } -} - -function ptyInfo(id: string, title: string) { - return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 } -} diff --git a/packages/app/e2e/regression/terminal-hidden.spec.ts b/packages/app/e2e/regression/terminal-hidden.spec.ts index 8e08d60ff2..357d9ac203 100644 --- a/packages/app/e2e/regression/terminal-hidden.spec.ts +++ b/packages/app/e2e/regression/terminal-hidden.spec.ts @@ -7,10 +7,9 @@ const projectID = "proj_hidden_terminal_regression" const sessionID = "ses_hidden_terminal_regression" const title = "Hidden terminal regression" -test("unmounts the terminal panel while it is hidden", async ({ page }) => { +test("unmounts the terminal renderer while the pane is hidden", async ({ page }) => { await page.setViewportSize({ width: 1400, height: 900 }) await mockOpenCodeServer(page, { - protocol: "v2", directory, project: { id: projectID, @@ -44,53 +43,17 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => { ], pageMessages: () => ({ items: [] }), }) - await page.route("**/api/pty*", (route) => + await page.route("**/pty", (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ - location: { directory, project: { id: projectID, directory } }, - data: { - id: "pty_hidden_terminal", - title: "Terminal 1", - command: "cmd.exe", - args: [], - cwd: directory, - status: "running", - pid: 1, - }, - }), + body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }), }), ) - await page.route("**/api/pty/pty_hidden_terminal*", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - location: { directory, project: { id: projectID, directory } }, - data: { - id: "pty_hidden_terminal", - title: "Terminal 1", - command: "cmd.exe", - args: [], - cwd: directory, - status: "running", - pid: 1, - }, - }), - }), + await page.route("**/pty/pty_hidden_terminal", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), ) - await page.route("**/api/pty/pty_hidden_terminal/connect-token*", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - location: { directory, project: { id: projectID, directory } }, - data: { ticket: "e2e-ticket", expires_in: 60 }, - }), - }), - ) - await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined) + await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, title) @@ -101,14 +64,13 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => { await expect(page.locator('[data-component="terminal"]')).toBeVisible() await page.keyboard.press("Control+Backquote") - await expect(panel).toHaveCount(0) + await expect(panel).toHaveAttribute("aria-hidden", "true") await expect(page.locator('[data-component="terminal"]')).toHaveCount(0) await page.setViewportSize({ width: 1200, height: 700 }) await expect(page.locator('[data-component="terminal"]')).toHaveCount(0) await page.keyboard.press("Control+Backquote") - await expect(panel).toBeVisible() await expect(page.locator('[data-component="terminal"]')).toBeVisible() }) diff --git a/packages/app/e2e/regression/terminal-tab-switch.spec.ts b/packages/app/e2e/regression/terminal-tab-switch.spec.ts deleted file mode 100644 index 165920753c..0000000000 --- a/packages/app/e2e/regression/terminal-tab-switch.spec.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" -import { expect, test, type Page } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectSessionTitle } from "../utils/waits" - -const directory = "C:/OpenCode/TerminalTabSwitch" -const projectID = "proj_terminal_tab_switch" -const sessionA = "ses_terminal_tab_a" -const sessionB = "ses_terminal_tab_b" -const titleA = "Alpha session" -const titleB = "Beta session" -const ptyID = "pty_tab_switch" -const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` -// Marks the terminal DOM node so a remount (fresh node) is detectable. -const PROBE = "original" - -test.use({ viewport: { width: 1440, height: 900 } }) - -// Terminals are workspace-scoped: switching between session tabs in the same -// workspace must keep the terminal mounted and its PTY connection open instead -// of tearing it down and reconnecting. -test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => { - const connections = await setup(page) - - await page.goto(sessionHref(sessionA)) - await expectSessionTitle(page, titleA) - - await page.keyboard.press("Control+Backquote") - const terminal = page.locator('[data-component="terminal"]') - await expect(terminal).toBeVisible() - await expect.poll(() => connections.length).toBe(1) - const connection = new URL(connections[0]!) - expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`) - expect(connection.searchParams.get("location[directory]")).toBe(directory) - expect(connection.searchParams.get("ticket")).toBeNull() - await writeProbe(page) - - await switchTab(page, titleB) - await expectSessionTitle(page, titleB) - await expect(terminal).toBeVisible() - expect(await readProbe(page)).toBe(PROBE) - expect(connections.length).toBe(1) - - await switchTab(page, titleA) - await expectSessionTitle(page, titleA) - await expect(terminal).toBeVisible() - expect(await readProbe(page)).toBe(PROBE) - expect(connections.length).toBe(1) -}) - -type Probed = HTMLElement & { __e2eProbe?: string } - -async function switchTab(page: Page, title: string) { - await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click() -} - -async function writeProbe(page: Page) { - await page.locator('[data-component="terminal"]').evaluate((el, probe) => { - ;(el as Probed).__e2eProbe = probe - }, PROBE) -} - -async function readProbe(page: Page) { - return page.locator('[data-component="terminal"]').evaluate((el) => (el as Probed).__e2eProbe) -} - -async function setup(page: Page) { - await mockOpenCodeServer(page, { - protocol: "v2", - directory, - project: { - id: projectID, - worktree: directory, - vcs: "git", - name: "terminal-tab-switch", - time: { created: 1700000000000, updated: 1700000000000 }, - sandboxes: [], - }, - provider: { - all: [ - { - id: "opencode", - name: "OpenCode", - models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, - }, - ], - connected: ["opencode"], - default: { providerID: "opencode", modelID: "test" }, - }, - sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], - pageMessages: () => ({ items: [] }), - }) - await page.route("**/api/pty*", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }), - }), - ) - await page.route(`**/api/pty/${ptyID}*`, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }), - }), - ) - await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => { - expect(route.request().headers()["x-opencode-ticket"]).toBe("1") - const url = new URL(route.request().url()) - expect(url.searchParams.get("location[directory]")).toBe(directory) - return route.fulfill({ - status: 200, - contentType: "application/json", - headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), - }) - }) - const connections: string[] = [] - await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => { - connections.push(ws.url()) - }) - - await page.addInitScript( - ({ directory, server, sessions }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem( - "opencode.global.dat:server", - JSON.stringify({ - projects: { local: [{ worktree: directory, expanded: true }] }, - lastProject: { local: directory }, - }), - ) - localStorage.setItem( - "opencode.window.browser.dat:tabs", - JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))), - ) - }, - { directory, server, sessions: [sessionA, sessionB] }, - ) - return connections -} - -function session(id: string, title: string, created: number) { - return { - id, - slug: id, - projectID, - directory, - title, - version: "dev", - time: { created, updated: created }, - } -} - -function sessionHref(sessionID: string) { - return `/server/${base64Encode(server)}/session/${sessionID}` -} - -function ptyLocation() { - return { directory, project: { id: projectID, directory } } -} - -function ptyInfo() { - return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 } -} diff --git a/packages/app/e2e/reproduction/timeline-suspense/index.html b/packages/app/e2e/reproduction/timeline-suspense/index.html deleted file mode 100644 index 995008a9f7..0000000000 --- a/packages/app/e2e/reproduction/timeline-suspense/index.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - Timeline Suspense Reproduction - - - -
- - - diff --git a/packages/app/e2e/reproduction/timeline-suspense/main.tsx b/packages/app/e2e/reproduction/timeline-suspense/main.tsx deleted file mode 100644 index cdcd809be3..0000000000 --- a/packages/app/e2e/reproduction/timeline-suspense/main.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import { createResource, createSignal, For, onMount, Suspense } from "solid-js" -import { render } from "solid-js/web" -import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual" -import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset" - -const rowCount = 2_000 -const rowHeight = 40 -const parameters = new URLSearchParams(location.search) -const resourceMode = parameters.get("resource") === "guard" ? "guard" : "baseline" -const reconnectMode = parameters.get("reconnect") === "candidate" ? "candidate" : "baseline" - -type MutationEvent = { - kind: "removed" | "added" - callbackTime: number - callbackFrame: number - routeConnectedInCallback: boolean - nativeOffsetInCallback: number -} - -type Snapshot = { - mode: { - resource: "baseline" | "guard" - reconnect: "baseline" | "candidate" - } - operation: { - sequence: number - phase: string - time: number - frame: number - } - resourceState: string - routeConnected: boolean - viewportConnected: boolean - viewportOwnedByRoute: boolean - sameRoute: boolean - sameViewport: boolean - sameSurface: boolean - sameMountedRows: boolean - nativeOffset: number - coreOffset: number - rangeStart: number - rangeEnd: number - indexes: number[] - domIndexes: number[] - logicalSurfaceHeight: number - renderedSurfaceHeight: number - viewportClientHeight: number - viewportScrollHeight: number - visibleRows: number - minimumRowTop: number - domScrollEvents: number - lastScrollTrusted: boolean - coreOffsetCallbackCalls: number - offsetCallbackSources: "observer"[] - rectObserverCallbacks: number - ignoredDetachedZeroRects: number - syntheticScrollDispatches: number - mutationEvents: MutationEvent[] -} - -declare global { - interface Window { - timelineSuspense: { - prepare: () => Promise - trigger: () => void - resolve: () => void - frames: (count?: number) => Promise - snapshot: () => Snapshot - } - } -} - -function App() { - const [refresh, setRefresh] = createSignal(false) - let resolveResource: (() => void) | undefined - const [resource] = createResource( - refresh, - (version) => - new Promise((resolve) => { - resolveResource = () => resolve(`settled-${version}`) - }), - { initialValue: "settled" }, - ) - - function Route() { - let route: HTMLElement | undefined - let viewport: HTMLDivElement | undefined - let surface: HTMLDivElement | undefined - let initialRoute: HTMLElement | undefined - let initialViewport: HTMLDivElement | undefined - let initialSurface: HTMLDivElement | undefined - let initialRows: HTMLElement[] = [] - let phase = "mounting" - let browserFrame = 0 - let snapshotSequence = 0 - let domScrollEvents = 0 - let lastScrollTrusted = false - let coreOffsetCallbackCalls = 0 - let rectObserverCallbacks = 0 - let ignoredDetachedZeroRects = 0 - const offsetCallbackSources: "observer"[] = [] - const mutationEvents: MutationEvent[] = [] - const virtualizer = createVirtualizer({ - count: rowCount, - getScrollElement: () => viewport ?? null, - estimateSize: () => rowHeight, - initialRect: { width: 900, height: 600 }, - overscan: 2, - observeElementRect: (instance, callback) => - observeElementRect(instance, (rect) => { - rectObserverCallbacks++ - // A fixed 600px viewport has no usable geometry while detached. Keep the last connected rect. - if (!instance.scrollElement?.isConnected && rect.height === 0) { - ignoredDetachedZeroRects++ - return - } - callback(rect) - }), - observeElementOffset: (instance, callback) => { - const deliver = (offset: number, isScrolling: boolean) => { - coreOffsetCallbackCalls++ - offsetCallbackSources.push("observer") - callback(offset, isScrolling) - } - if (reconnectMode === "candidate") return observeElementOffsetReconnectAware(instance, deliver) - return observeElementOffset(instance, deliver) - }, - }) - - const frames = async (count = 2) => { - for (let index = 0; index < count; index++) { - await new Promise((resolve) => requestAnimationFrame(() => resolve())) - } - } - const mountedRows = () => [...(surface?.querySelectorAll("[data-row-index]") ?? [])] - const snapshot = (): Snapshot => { - const rows = mountedRows() - const view = viewport?.getBoundingClientRect() - const visibleRows = - viewport?.isConnected && view - ? rows.filter((row) => { - const rect = row.getBoundingClientRect() - return rect.bottom > view.top && rect.top < view.bottom - }).length - : 0 - return { - mode: { resource: resourceMode, reconnect: reconnectMode }, - operation: { - sequence: ++snapshotSequence, - phase, - time: performance.now(), - frame: browserFrame, - }, - resourceState: resource.state, - routeConnected: route?.isConnected ?? false, - viewportConnected: viewport?.isConnected ?? false, - viewportOwnedByRoute: !!route && !!viewport && route.contains(viewport), - sameRoute: route === initialRoute, - sameViewport: viewport === initialViewport, - sameSurface: surface === initialSurface, - sameMountedRows: - initialRows.length > 0 && - initialRows.length === rows.length && - initialRows.every((row, index) => row === rows[index]), - nativeOffset: viewport?.scrollTop ?? -1, - coreOffset: virtualizer.scrollOffset ?? -1, - rangeStart: virtualizer.range?.startIndex ?? -1, - rangeEnd: virtualizer.range?.endIndex ?? -1, - indexes: virtualizer.getVirtualItems().map((item) => item.index), - domIndexes: rows.map((row) => Number(row.dataset.rowIndex)), - logicalSurfaceHeight: Number.parseFloat(surface?.style.height ?? "-1"), - renderedSurfaceHeight: surface?.getBoundingClientRect().height ?? -1, - viewportClientHeight: viewport?.clientHeight ?? -1, - viewportScrollHeight: viewport?.scrollHeight ?? -1, - visibleRows, - minimumRowTop: - rows.length && view ? Math.min(...rows.map((row) => row.getBoundingClientRect().top - view.top)) : -1, - domScrollEvents, - lastScrollTrusted, - coreOffsetCallbackCalls, - offsetCallbackSources: [...offsetCallbackSources], - rectObserverCallbacks, - ignoredDetachedZeroRects, - syntheticScrollDispatches: 0, - mutationEvents: mutationEvents.map((event) => ({ ...event })), - } - } - - onMount(() => { - if (!route || !viewport || !surface) throw new Error("Timeline fixture did not mount") - const routeRoot = route.parentElement - if (!routeRoot) throw new Error("Timeline route root did not mount") - initialRoute = route - initialViewport = viewport - initialSurface = surface - viewport.addEventListener("scroll", (event) => { - domScrollEvents++ - lastScrollTrusted = event.isTrusted - }) - const countFrames = () => { - browserFrame++ - requestAnimationFrame(countFrames) - } - requestAnimationFrame(countFrames) - new MutationObserver((records) => { - const callbackTime = performance.now() - records.forEach((record) => { - ;([...(record.removedNodes ?? [])] as Node[]).forEach((node) => { - if (node !== route) return - phase = "detached" - mutationEvents.push({ - kind: "removed", - callbackTime, - callbackFrame: browserFrame, - routeConnectedInCallback: route.isConnected, - nativeOffsetInCallback: viewport.scrollTop, - }) - }) - ;([...(record.addedNodes ?? [])] as Node[]).forEach((node) => { - if (node !== route) return - phase = "reinserted" - mutationEvents.push({ - kind: "added", - callbackTime, - callbackFrame: browserFrame, - routeConnectedInCallback: route.isConnected, - nativeOffsetInCallback: viewport.scrollTop, - }) - }) - }) - }).observe(routeRoot, { childList: true }) - window.timelineSuspense = { - prepare: async () => { - phase = "preparing" - await frames(2) - viewport.scrollTop = viewport.scrollHeight - await frames(3) - await new Promise((resolve) => setTimeout(resolve, 200)) - await frames(2) - initialRows = mountedRows() - phase = "prepared" - return snapshot() - }, - trigger: () => { - phase = "triggering" - setRefresh(true) - }, - resolve: () => { - if (!resolveResource) throw new Error("Resource is not pending") - phase = "resolving" - resolveResource() - }, - frames, - snapshot, - } - }) - - return ( -
- -
-
- - {(item) => ( -
- logical row {item.index} -
- )} -
-
-
-
- ) - } - - return ( -
- - - -
- ) -} - -render(() => , document.getElementById("root")!) diff --git a/packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts b/packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts deleted file mode 100644 index 7fff1737ae..0000000000 --- a/packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { defineConfig, devices } from "@playwright/test" - -const port = Number(process.env.PLAYWRIGHT_TIMELINE_SUSPENSE_PORT ?? 4317) - -export default defineConfig({ - testDir: ".", - testMatch: "timeline-suspense.repro.ts", - outputDir: "../../test-results/timeline-suspense", - fullyParallel: false, - workers: 1, - retries: 0, - reporter: "line", - timeout: 30_000, - expect: { - timeout: 10_000, - }, - webServer: { - command: `bunx vite --config vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`, - cwd: import.meta.dirname, - url: `http://127.0.0.1:${port}`, - reuseExistingServer: false, - }, - use: { - baseURL: `http://127.0.0.1:${port}`, - trace: "retain-on-failure", - screenshot: "only-on-failure", - }, - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - ], -}) diff --git a/packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts b/packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts deleted file mode 100644 index 3edae33a9b..0000000000 --- a/packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { expect, test, type Page } from "@playwright/test" - -test.beforeEach(async ({ page }) => { - page.on("pageerror", (error) => console.error(error)) - await page.goto("/") - await expect.poll(() => page.evaluate(() => !!window.timelineSuspense)).toBe(true) -}) - -test("desired: preserves visible timeline continuity across descendant resource suspension", async ({ page }) => { - await page.goto("/?reconnect=candidate") - await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.reconnect)).toBe("candidate") - const before = await prepare(page) - await triggerBaselineSuspension(page) - const pending = await page.evaluate(() => window.timelineSuspense.snapshot()) - expect(pending.nativeOffset).toBe(0) - expect(pending.coreOffset).toBe(before.coreOffset) - expect(pending.indexes).toEqual(before.indexes) - expect(pending.sameRoute).toBe(true) - expect(pending.sameViewport).toBe(true) - expect(pending.sameSurface).toBe(true) - expect(pending.sameMountedRows).toBe(true) - - await resolveSuspension(page) - await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().coreOffset)).toBe(0) - await page.waitForTimeout(250) - await page.evaluate(() => window.timelineSuspense.frames(2)) - const after = await page.evaluate(() => window.timelineSuspense.snapshot()) - expect(after.sameRoute).toBe(true) - expect(after.sameViewport).toBe(true) - expect(after.sameSurface).toBe(true) - expect(after.nativeOffset).toBe(0) - expect(after.coreOffset).toBe(0) - expect(after.rangeStart).toBeLessThan(10) - expect(after.visibleRows, diagnostic({ before, pending, after })).toBeGreaterThan(0) - expect(after.domScrollEvents).toBe(before.domScrollEvents) - expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls + 1) - expect(after.offsetCallbackSources.at(-1)).toBe("observer") - expect(after.syntheticScrollDispatches).toBe(0) -}) - -test("forensic: proves detached same-node viewport leaves TanStack's bottom range blank until a real scroll", async ({ - page, -}) => { - const before = await prepare(page) - const beforeRows = before.domIndexes - expect(before.mode).toEqual({ resource: "baseline", reconnect: "baseline" }) - expect(before.logicalSurfaceHeight).toBe(80_000) - expect(before.renderedSurfaceHeight).toBe(80_000) - expect(before.viewportClientHeight).toBe(600) - expect(before.viewportScrollHeight).toBe(80_000) - expect(before.rangeStart).toBeGreaterThan(1_900) - expect(before.nativeOffset).toBe(before.coreOffset) - expect(before.visibleRows).toBeGreaterThan(0) - - await triggerBaselineSuspension(page) - const pending = await page.evaluate(() => window.timelineSuspense.snapshot()) - expect(pending.resourceState).toBe("refreshing") - expect(pending.routeConnected).toBe(false) - expect(pending.viewportConnected).toBe(false) - expect(pending.viewportOwnedByRoute).toBe(true) - expect(pending.nativeOffset).toBe(0) - expect(pending.coreOffset).toBe(before.coreOffset) - expect(pending.rangeStart).toBe(before.rangeStart) - expect(pending.rangeEnd).toBe(before.rangeEnd) - expect(pending.indexes).toEqual(before.indexes) - expect(pending.domIndexes).toEqual(beforeRows) - expect(pending.sameMountedRows).toBe(true) - expect(pending.domScrollEvents).toBe(before.domScrollEvents) - expect(pending.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls) - expect(pending.ignoredDetachedZeroRects).toBeGreaterThan(before.ignoredDetachedZeroRects) - expect(pending.mutationEvents).toHaveLength(1) - expect(pending.mutationEvents[0]).toMatchObject({ - kind: "removed", - routeConnectedInCallback: false, - nativeOffsetInCallback: 0, - }) - expect(pending.mutationEvents[0]!.callbackTime).toBeLessThanOrEqual(pending.operation.time) - expect(pending.mutationEvents[0]!.callbackFrame).toBeLessThanOrEqual(pending.operation.frame) - - const after = await resolveSuspension(page) - expect(after.resourceState).toBe("ready") - expect(after.routeConnected).toBe(true) - expect(after.viewportConnected).toBe(true) - expect(after.viewportOwnedByRoute).toBe(true) - expect(after.sameRoute).toBe(true) - expect(after.sameViewport).toBe(true) - expect(after.sameSurface).toBe(true) - expect(after.sameMountedRows).toBe(true) - expect(after.nativeOffset).toBe(0) - expect(after.coreOffset).toBe(before.coreOffset) - expect(after.rangeStart).toBe(before.rangeStart) - expect(after.rangeEnd).toBe(before.rangeEnd) - expect(after.indexes).toEqual(before.indexes) - expect(after.domIndexes).toEqual(beforeRows) - expect(after.domScrollEvents).toBe(before.domScrollEvents) - expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls) - expect(after.mutationEvents).toHaveLength(2) - expect(after.mutationEvents[1]).toMatchObject({ - kind: "added", - routeConnectedInCallback: true, - nativeOffsetInCallback: 0, - }) - expect(after.mutationEvents[1]!.callbackTime).toBeLessThanOrEqual(after.operation.time) - expect(after.mutationEvents[1]!.callbackFrame).toBeLessThanOrEqual(after.operation.frame) - expect(after.visibleRows).toBe(0) - expect(after.minimumRowTop).toBeGreaterThan(50_000) - expect(after.syntheticScrollDispatches).toBe(0) - - await page.locator("[data-viewport]").hover() - await page.mouse.wheel(0, 80) - await expect - .poll(() => - page.evaluate(() => { - const value = window.timelineSuspense.snapshot() - return value.nativeOffset > 0 && value.coreOffset === value.nativeOffset - }), - ) - .toBe(true) - await page.evaluate(() => window.timelineSuspense.frames(2)) - const recovered = await page.evaluate(() => window.timelineSuspense.snapshot()) - expect(recovered.domScrollEvents).toBeGreaterThan(after.domScrollEvents) - expect(recovered.coreOffsetCallbackCalls).toBeGreaterThan(after.coreOffsetCallbackCalls) - expect(recovered.offsetCallbackSources.at(-1)).toBe("observer") - expect(recovered.lastScrollTrusted).toBe(true) - expect(recovered.rangeStart).toBeLessThan(10) - expect(recovered.visibleRows).toBeGreaterThan(0) -}) - -test("matrix: fixture-only settled-resource guard keeps the route connected", async ({ page }) => { - await page.goto("/?resource=guard") - await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.resource)).toBe("guard") - const before = await prepare(page) - - await page.evaluate(() => window.timelineSuspense.trigger()) - await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing") - await page.evaluate(() => window.timelineSuspense.frames(3)) - const pending = await page.evaluate(() => window.timelineSuspense.snapshot()) - expect(pending.routeConnected).toBe(true) - expect(pending.mutationEvents).toEqual([]) - expect(pending.nativeOffset).toBe(before.nativeOffset) - expect(pending.coreOffset).toBe(before.coreOffset) - expect(pending.visibleRows).toBeGreaterThan(0) - - const after = await resolveSuspension(page) - expect(after.routeConnected).toBe(true) - expect(after.nativeOffset).toBe(before.nativeOffset) - expect(after.coreOffset).toBe(before.coreOffset) - expect(after.visibleRows).toBeGreaterThan(0) -}) - -async function prepare(page: Page) { - const before = await page.evaluate(() => window.timelineSuspense.prepare()) - expect(before.routeConnected).toBe(true) - expect(before.viewportConnected).toBe(true) - expect(before.viewportOwnedByRoute).toBe(true) - expect(before.sameMountedRows).toBe(true) - expect(before.rangeStart).toBeGreaterThan(1_900) - expect(before.nativeOffset).toBe(before.coreOffset) - return before -} - -async function triggerBaselineSuspension(page: Page) { - await page.evaluate(() => window.timelineSuspense.trigger()) - await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing") - await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(false) - await page.evaluate(() => window.timelineSuspense.frames(3)) -} - -async function resolveSuspension(page: Page) { - await page.evaluate(() => window.timelineSuspense.resolve()) - await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("ready") - await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(true) - await page.evaluate(() => window.timelineSuspense.frames(3)) - return page.evaluate(() => window.timelineSuspense.snapshot()) -} - -function diagnostic(value: unknown) { - return JSON.stringify(value, null, 2) -} diff --git a/packages/app/e2e/reproduction/timeline-suspense/vite.config.ts b/packages/app/e2e/reproduction/timeline-suspense/vite.config.ts deleted file mode 100644 index efb75ab731..0000000000 --- a/packages/app/e2e/reproduction/timeline-suspense/vite.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "vite" -import solid from "vite-plugin-solid" - -export default defineConfig({ - root: import.meta.dirname, - plugins: [solid()], -}) diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index bdf3f55bdc..a73cc0ccdd 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -736,5 +736,5 @@ async function switchTitlebarSession(page: Page, sessionID: string, title: strin } async function expectSessionReady(page: Page) { - await expectAppVisible(page.getByRole("textbox", { name: "Prompt" })) + await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i })) } diff --git a/packages/app/e2e/tsconfig.json b/packages/app/e2e/tsconfig.json index 4a6046e4fa..3f1cad80cb 100644 --- a/packages/app/e2e/tsconfig.json +++ b/packages/app/e2e/tsconfig.json @@ -5,16 +5,5 @@ "rootDir": "..", "types": ["node", "bun"] }, - "include": [ - "./performance/timeline-stability/**/*.spec.ts", - "./performance/timeline-stability/fixture.test.ts", - "./performance/timeline-stability/fixture.ts", - "./performance/unit/visual-stability.test.ts", - "./reproduction/timeline-suspense/**/*.ts", - "./reproduction/timeline-suspense/**/*.tsx", - "../src/pages/session/timeline/observe-element-offset.ts", - "./regression/new-session-panel-corner.spec.ts", - "./regression/session-timeline-context-resize.spec.ts", - "./utils/**/*.ts" - ] + "include": ["./**/*.ts"] } diff --git a/packages/app/e2e/user-story/model-selection-flow.spec.ts b/packages/app/e2e/user-story/model-selection-flow.spec.ts deleted file mode 100644 index 22b8bb41fe..0000000000 --- a/packages/app/e2e/user-story/model-selection-flow.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { expect, test } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" -import { expectAppVisible } from "../utils/waits" - -const directory = "C:/OpenCode/NewProject" - -test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => { - let connectedGo = false - let pendingGo = false - const connections: Array<{ integrationID: string; body: unknown }> = [] - - await mockOpenCodeServer(page, { - directory, - project: { - id: "proj_model_selection_flow", - worktree: directory, - vcs: "git", - name: "NewProject", - time: { created: 1_700_000_000_000, updated: 1_700_000_000_000 }, - sandboxes: [], - }, - provider: () => ({ - all: [ - { - id: "opencode", - name: "OpenCode", - models: { - "free-model": { - id: "free-model", - name: "Free Model", - cost: { input: 0, output: 0 }, - limit: { context: 200_000 }, - }, - }, - }, - { - id: "opencode-go", - name: "OpenCode Go", - models: { - "go-model-1": { - id: "go-model-1", - name: "Go Model 1", - cost: { input: 1, output: 1 }, - limit: { context: 200_000 }, - }, - }, - }, - ], - connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"], - default: { providerID: "opencode", modelID: "free-model" }, - }), - integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] }, - onConnectKey: (input) => { - connections.push(input) - if (input.integrationID === "opencode-go") pendingGo = true - }, - onInstanceDispose: () => { - if (pendingGo) connectedGo = true - }, - sessions: [], - pageMessages: () => ({ items: [] }), - fileList: (path) => - path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }], - findFiles: () => ["NewProject"], - }) - await page.addInitScript(() => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } })) - }) - - await page.goto("/") - const addProject = page.locator('[data-action="home-add-project-row"]') - await expectAppVisible(addProject) - await addProject.click() - await page.locator("[data-directory-path]").click() - - await page.locator('[data-action="home-new-session"]').click() - await expectAppVisible(page.locator('[data-component="prompt-input-v2"]')) - - const modelControl = page.locator('[data-action="prompt-model"]') - await modelControl.click() - await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode") - - await page.locator('[data-provider-id="opencode-go"]').click() - await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key") - await page.locator('[data-action="provider-connect-submit"]').click() - await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0) - expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }]) - - await expect(modelControl).toHaveAttribute("data-control-type", "popover") - await modelControl.click() - const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]') - await expect(goModel).toBeVisible() - await goModel.click() - - await expect(modelControl).toContainText("Go Model 1") -}) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 76987421b6..875c3b7a96 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -1,39 +1,29 @@ import type { Page, Route } from "@playwright/test" const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"]) -const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"]) +const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"]) export interface MockServerConfig { - protocol?: "v1" | "v2" - provider: unknown | (() => unknown) - integrationMethods?: Record - onConnectKey?: (input: { integrationID: string; body: unknown }) => void - onInstanceDispose?: () => void + provider: unknown directory: string project: unknown sessions: ({ id: string } & Record)[] pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string } vcsDiff?: unknown[] messageDelay?: number - beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void - message?: (sessionID: string, messageID: string) => unknown - onMessage?: (input: { sessionID: string; messageID: string }) => void events?: () => unknown[] eventRetry?: number todos?: (sessionID: string) => unknown[] permissions?: unknown[] | (() => unknown[]) questions?: unknown[] | (() => unknown[]) - fileList?: (path: string) => unknown | Promise - fileContent?: (path: string) => unknown | Promise - findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown - sessionStatus?: Record | (() => Record) } export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const cursors = new Map() let nextCursor = 0 const staticRoutes: Record = { + "/provider": config.provider, "/path": { state: config.directory, config: config.directory, @@ -57,249 +47,34 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (url.port !== targetPort && url.port !== appPort) return route.fallback() const path = url.pathname - if (path === "/global/event" || path === "/event" || path === "/api/event") { - const events = config.events?.() - return sse( - route, - path === "/api/event" - ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])] - : [ - ...(path === "/global/event" - ? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }] - : []), - ...(events ?? []), - ], - config.eventRetry, - ) - } - if (path === "/global/health") - return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true }) - if (path === "/api/health" && config.protocol === "v2") - return json(route, { healthy: true, version: "2.0.0", pid: 1 }) - if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true }) - if (path === "/provider") - return json(route, typeof config.provider === "function" ? config.provider() : config.provider) - if (path === "/provider/auth") return json(route, config.integrationMethods ?? {}) - const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1] - if (legacyAuth && route.request().method() === "PUT") { - config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() }) - return json(route, true) - } - if (path === "/instance/dispose" && route.request().method() === "POST") { - config.onInstanceDispose?.() - return json(route, true) - } + if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) + if (path === "/global/health") return json(route, { healthy: true }) if (path === "/permission") return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) if (path === "/question") return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? [])) - if (path === "/session/status") - return json( - route, - typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}), - ) if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff) - if (path === "/file" && config.fileList) - return json(route, await config.fileList(url.searchParams.get("path") ?? "")) - if (path === "/file/content" && config.fileContent) - return json(route, await config.fileContent(url.searchParams.get("path") ?? "")) - if (path === "/find/file" && config.findFiles) - return json( - route, - await config.findFiles({ - query: url.searchParams.get("query") ?? "", - dirs: url.searchParams.get("dirs") ?? undefined, - limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined, - }), - ) - if (path === "/api/reference") - return json(route, { - location: { - directory: config.directory, - project: { id: (config.project as { id?: string }).id, directory: config.directory }, - }, - data: [], - }) - if (path === "/api/agent") - return json(route, { - location: location(config), - data: [ - { - id: "build", - name: "Build", - mode: "primary", - hidden: false, - request: { settings: {}, headers: {}, body: {} }, - permissions: [], - }, - ], - }) - if (path === "/api/command") return json(route, { location: location(config), data: [] }) - if (path === "/api/mcp") return json(route, { location: location(config), data: [] }) - if (path === "/api/mcp/resource") - return json(route, { location: location(config), data: { resources: [], templates: [] } }) - const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1] - if (integration && route.request().method() === "GET") - return json(route, { - location: location(config), - data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] }, - }) - const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1] - if (integrationConnect && route.request().method() === "POST") { - config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() }) - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - if (path === "/api/project") return json(route, [config.project]) - if (path === "/api/project/current") - return json(route, { id: (config.project as { id?: string }).id, directory: config.directory }) - if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project) - if (path === "/api/path") - return json(route, { - state: config.directory, - config: config.directory, - worktree: config.directory, - directory: config.directory, - home: "C:/OpenCode", - }) - if (path === "/api/permission/request") - return json(route, { - location: location(config), - data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map( - currentPermission, - ), - }) - if (path === "/api/question/request") - return json(route, { - location: location(config), - data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []), - }) - if (path === "/api/vcs") - return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } }) - if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] }) - if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] }) - if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] }) - if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path)) - return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }) if (emptyObject.has(path)) return json(route, {}) if (emptyList.has(path)) return json(route, []) - if (path === "/api/session") { - const directory = url.searchParams.get("directory") - const parentID = url.searchParams.get("parentID") - const limit = Number(url.searchParams.get("limit") ?? 50) - const offset = Number(url.searchParams.get("cursor") ?? 0) - const sessions = config.sessions - .filter((session) => !directory || session.directory === directory) - .filter((session) => parentID !== "null" || session.parentID === undefined) - .filter((session) => { - const search = url.searchParams.get("search")?.toLowerCase() - return ( - !search || - String(session.title ?? "") - .toLowerCase() - .includes(search) - ) - }) - const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions - const data = ordered.slice(offset, offset + limit) - const next = offset + limit < ordered.length ? String(offset + limit) : undefined - return json(route, { - data: data.map((session) => currentSession(session, config.directory)), - cursor: { next }, - }) - } - if (path === "/api/session/active") { - const statuses = (config.sessionStatus ?? {}) as Record - return json(route, { - data: Object.fromEntries( - Object.entries(statuses).flatMap(([id, status]) => - status.type === "idle" ? [] : [[id, { type: "running" }]], - ), - ), - }) - } - if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") { - return json(route, true) - } - if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") { - return json(route, true) - } - if ( - /^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) && - route.request().method() === "POST" - ) { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } - if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") { - return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) - } if (path in staticRoutes) return json(route, staticRoutes[path]) - const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/) - if (currentSessionMatch) { - const session = config.sessions.find((item) => item.id === currentSessionMatch[1]) - if (!session) return json(route, { error: "Session not found" }, undefined, 404) - return json(route, { - data: currentSession(session, config.directory), - }) - } - const sessionMatch = path.match(/^\/session\/([^/]+)$/) if (sessionMatch) { const session = config.sessions.find((s) => s.id === sessionMatch[1]) return json(route, session ?? {}) } - const projectMatch = path.match(/^\/project\/([^/]+)$/) - if (projectMatch) return json(route, config.project) - - const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/) - if (messageMatch) { - config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! }) - if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) - const message = config.message?.(messageMatch[1]!, messageMatch[2]!) - if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404) - return json(route, message) - } - const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/) if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) - const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/) - if (currentMessagesMatch) { - const token = url.searchParams.get("cursor") ?? undefined - const before = token ? cursors.get(token) : undefined - if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) - config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" }) - await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before }) - if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) - const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before) - config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" }) - const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined - if (cursor) cursors.set(cursor, pageData.cursor!) - return json(route, { - data: pageData.items.map(currentMessage).reverse(), - cursor: { next: cursor }, - }) - } - const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) if (messagesMatch) { const token = url.searchParams.get("before") ?? undefined const before = token ? cursors.get(token) : undefined if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" }) - await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before }) - if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) + if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) const limit = Number(url.searchParams.get("limit") ?? 80) const pageData = config.pageMessages(messagesMatch[1], limit, before) config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" }) @@ -314,115 +89,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) } -function location(config: MockServerConfig) { - return { - directory: config.directory, - project: { id: (config.project as { id?: string }).id, directory: config.directory }, - } -} - -function currentPermission(value: unknown) { - const permission = value as Record - if (permission.action) return permission - const tool = permission.tool as { messageID?: string; callID?: string } | undefined - return { - id: permission.id, - sessionID: permission.sessionID, - action: permission.permission, - resources: permission.patterns ?? [], - save: permission.always, - metadata: permission.metadata, - source: - tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined, - } -} - -export function currentSession(session: { id: string } & Record, fallbackDirectory?: string) { - const time = session.time && typeof session.time === "object" ? session.time : {} - return { - id: session.id, - parentID: session.parentID, - projectID: session.projectID ?? "project", - agent: session.agent ?? "build", - model: session.model ?? { id: "mock-model", providerID: "mock-provider" }, - cost: session.cost ?? 0, - tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { - created: "created" in time && typeof time.created === "number" ? time.created : 0, - updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0, - ...(session.time && typeof session.time === "object" && "archived" in session.time - ? { archived: session.time.archived } - : {}), - }, - title: session.title ?? session.id, - location: { - directory: typeof session.directory === "string" ? session.directory : fallbackDirectory, - ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}), - }, - subpath: session.path, - revert: session.revert, - } -} - -function currentMessage(value: unknown) { - const item = value as { - info: Record & { id: string; role: "user" | "assistant"; time: { created: number } } - parts: Array & { type: string }> - } - if (item.info.role === "user") { - return { - id: item.info.id, - type: "user", - time: item.info.time, - text: item.parts - .flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : [])) - .join("\n"), - } - } - return { - id: item.info.id, - type: "assistant", - time: item.info.time, - agent: item.info.agent ?? "build", - model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" }, - cost: item.info.cost, - tokens: item.info.tokens, - error: item.info.error, - content: item.parts.flatMap((part) => { - if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }] - if (part.type !== "tool") return [] - const state = part.state as Record - return [ - { - type: "tool", - id: part.id, - name: part.tool, - time: state.time ?? { created: item.info.time.created }, - state: - state.status === "pending" - ? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) } - : state.status === "completed" - ? { - status: "completed", - input: state.input ?? {}, - structured: state.metadata ?? {}, - content: [{ type: "text", text: state.output ?? "" }], - } - : state.status === "error" - ? { - status: "error", - input: state.input ?? {}, - structured: state.metadata ?? {}, - content: [], - error: { type: "ToolError", message: state.error ?? "Tool failed" }, - } - : { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] }, - }, - ] - }), - } -} - function json(route: Route, body: unknown, headers?: Record, status = 200) { return route.fulfill({ status, @@ -443,18 +109,3 @@ function sse(route: Route, events?: unknown[], retry?: number) { body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`, }) } - -function currentEvent(input: unknown) { - if (!input || typeof input !== "object" || !("payload" in input)) return input - const envelope = input as { directory?: string; payload?: unknown } - if (!envelope.payload || typeof envelope.payload !== "object") return input - const payload = envelope.payload as { id?: string; type?: string; properties?: unknown } - if (!payload.type) return input - return { - id: payload.id ?? `evt_mock_${Date.now()}`, - created: Date.now(), - type: payload.type, - data: payload.properties ?? {}, - location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, - } -} diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts deleted file mode 100644 index b0e3b74c6d..0000000000 --- a/packages/app/e2e/utils/sse-transport.ts +++ /dev/null @@ -1,312 +0,0 @@ -import type { Page } from "@playwright/test" - -export type SseConnectionRecord = { - id: number - url: string - path: "/global/event" | "/event" | "/api/event" - headers: Record - openedAt: number - endedAt?: number - endedBy?: "close" | "disconnect" | "error" | "abort" - error?: string -} - -export type SseDeliveryAcknowledgement = { - deliveryID: number - connectionID: number - bytes: number - chunkCount: number - deliveredAt: number - eventID?: string -} - -export type SseEventOptions = { - id?: string - event?: string - retry?: number - marker?: string -} - -export type SseTransport = { - server: string - waitForConnection(options?: { after?: number; timeout?: number }): Promise - send(payload: T, options?: SseEventOptions): Promise - burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise - split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise - heartbeat(options?: SseEventOptions): Promise - writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise - close(): Promise - disconnect(message?: string): Promise - error(message?: string): Promise - connections(): Promise - acknowledgements(): Promise -} - -type BrowserCommand = - | { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] } - | { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string } - | { type: "end"; mode: "close" | "disconnect" | "error"; message?: string } - | { type: "connections" } - | { type: "acknowledgements" } - -type BrowserTransport = Window & { - __testSseTransport?: { - command: (command: BrowserCommand) => unknown - } -} - -export async function installSseTransport( - page: Page, - options: { server: string; retry?: number }, -): Promise> { - const server = new URL(options.server).origin - await page.addInitScript( - ({ server, retry }) => { - type Connection = SseConnectionRecord & { controller: ReadableStreamDefaultController } - type ProbeWindow = Window & { - __visualStabilityProbe?: { startedAt: number; markers: { at: number; label: string }[] } - } - const originalFetch = window.fetch.bind(window) - const connections: Connection[] = [] - const acknowledgements: SseDeliveryAcknowledgement[] = [] - const encoder = new TextEncoder() - let nextConnectionID = 0 - let nextDeliveryID = 0 - - const current = () => connections.findLast((connection) => connection.endedAt === undefined) - const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => { - const boundaries = [...new Set(cuts ?? [])] - .filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength) - .sort((a, b) => a - b) - return [0, ...boundaries].map((start, index) => bytes.slice(start, boundaries[index] ?? bytes.byteLength)) - } - const marker = (label?: string) => { - if (!label) return - const probe = (window as ProbeWindow).__visualStabilityProbe - if (!probe) return - probe.markers.push({ at: performance.now() - probe.startedAt, label }) - } - const frame = (payload: unknown, eventOptions: SseEventOptions = {}) => - [ - eventOptions.event === undefined ? "" : `event: ${eventOptions.event}\n`, - eventOptions.id === undefined ? "" : `id: ${eventOptions.id}\n`, - eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`, - `data: ${JSON.stringify(payload)}\n\n`, - ].join("") - const currentEvent = (input: unknown) => { - if (!input || typeof input !== "object" || !("payload" in input)) return input - const envelope = input as { directory?: string; payload?: unknown } - if (!envelope.payload || typeof envelope.payload !== "object") return input - const payload = envelope.payload as { id?: string; type?: string; properties?: unknown } - if (!payload.type) return input - return { - id: payload.id ?? `evt_mock_${Date.now()}`, - created: Date.now(), - type: payload.type, - data: payload.properties ?? {}, - location: - envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, - } - } - const acknowledge = ( - connection: Connection, - bytes: number, - chunkCount: number, - eventID?: string, - ): SseDeliveryAcknowledgement => { - const acknowledgement = { - deliveryID: ++nextDeliveryID, - connectionID: connection.id, - bytes, - chunkCount, - deliveredAt: performance.now(), - ...(eventID === undefined ? {} : { eventID }), - } - acknowledgements.push(acknowledgement) - return acknowledgement - } - const end = (mode: "close" | "disconnect" | "error", message?: string) => { - const connection = current() - if (!connection) throw new Error("SSE transport has no active connection") - connection.endedAt = performance.now() - connection.endedBy = mode - if (message) connection.error = message - if (mode === "close") { - connection.controller.close() - return - } - const error = new DOMException( - message ?? "SSE connection disconnected", - mode === "error" ? "Error" : "NetworkError", - ) - connection.controller.error(error) - } - - const command = (input: BrowserCommand) => { - if (input.type === "connections") - return connections.map(({ controller: _controller, ...connection }) => connection) - if (input.type === "acknowledgements") return acknowledgements - if (input.type === "end") return end(input.mode, input.message) - const connection = current() - if (!connection) throw new Error("SSE transport has no active connection") - if (input.type === "raw") { - marker(input.marker) - const output = chunks(new Uint8Array(input.bytes), input.cuts) - output.forEach((chunk) => connection.controller.enqueue(chunk)) - return acknowledge(connection, input.bytes.length, output.length) - } - const encoded = input.deliveries.map((delivery) => { - const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload - return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) } - }) - encoded.forEach((item) => marker(item.delivery.options?.marker)) - if (input.burst) { - const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join("")) - connection.controller.enqueue(bytes) - return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id)) - } - const output = chunks(encoded[0]!.bytes, input.cuts) - output.forEach((chunk) => connection.controller.enqueue(chunk)) - return acknowledge(connection, encoded[0]!.bytes.byteLength, output.length, encoded[0]!.delivery.options?.id) - } - - ;(window as BrowserTransport).__testSseTransport = { command } - const fetch = (input: RequestInfo | URL, init?: RequestInit) => { - const request = new Request(input, init) - const url = new URL(request.url) - if ( - url.origin !== server || - (url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event") - ) - return originalFetch(request) - - const id = ++nextConnectionID - const record = { - id, - url: url.href, - path: url.pathname, - headers: Object.fromEntries(request.headers.entries()), - openedAt: performance.now(), - } as Connection - const stream = new ReadableStream({ - start(controller) { - record.controller = controller - connections.push(record) - if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) - if (url.pathname === "/api/event") - controller.enqueue( - encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), - ) - if (url.pathname === "/global/event") - controller.enqueue( - encoder.encode( - frame({ - payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} }, - }), - ), - ) - request.signal.addEventListener( - "abort", - () => { - if (record.endedAt !== undefined) return - record.endedAt = performance.now() - record.endedBy = "abort" - controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError")) - }, - { once: true }, - ) - }, - cancel() { - if (record.endedAt !== undefined) return - record.endedAt = performance.now() - record.endedBy = "disconnect" - }, - }) - return Promise.resolve( - new Response(stream, { - status: 200, - headers: { - "cache-control": "no-cache", - "content-type": "text/event-stream", - }, - }), - ) - } - Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch }) - }, - { server, retry: options.retry }, - ) - - const command = (input: BrowserCommand) => - page.evaluate((input) => { - const transport = (window as BrowserTransport).__testSseTransport - if (!transport) throw new Error("SSE transport was not installed before page load") - return transport.command(input as BrowserCommand) - }, input) as Promise - - return { - server, - async waitForConnection(input = {}) { - await page.waitForFunction( - (after) => { - const transport = (window as BrowserTransport).__testSseTransport - const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined - return connections?.some((connection) => connection.id > after) - }, - input.after ?? 0, - { timeout: input.timeout }, - ) - return (await command({ type: "connections" })).findLast( - (connection) => connection.id > (input.after ?? 0), - )! - }, - send(payload, eventOptions) { - return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false }) - }, - burst(payloads, eventOptions = []) { - return command({ - type: "send", - deliveries: payloads.map((payload, index) => ({ payload, options: eventOptions[index] })), - burst: true, - }) - }, - split(payload, cuts, eventOptions) { - return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, cuts: [...cuts] }) - }, - heartbeat(eventOptions) { - return command({ - type: "send", - deliveries: [ - { - payload: { directory: "global", payload: { type: "server.heartbeat", properties: {} } } as T, - options: eventOptions, - }, - ], - burst: false, - }) - }, - writeRaw(value, cuts, marker) { - return command({ - type: "raw", - bytes: Array.from(typeof value === "string" ? new TextEncoder().encode(value) : value), - cuts: cuts ? [...cuts] : undefined, - marker, - }) - }, - close() { - return command({ type: "end", mode: "close" }) - }, - disconnect(message) { - return command({ type: "end", mode: "disconnect", message }) - }, - error(message) { - return command({ type: "end", mode: "error", message }) - }, - connections() { - return command({ type: "connections" }) - }, - acknowledgements() { - return command({ type: "acknowledgements" }) - }, - } -} diff --git a/packages/app/e2e/utils/visual-stability.ts b/packages/app/e2e/utils/visual-stability.ts deleted file mode 100644 index 2c56864ed8..0000000000 --- a/packages/app/e2e/utils/visual-stability.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { Page, TestInfo } from "@playwright/test" -import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./visual-stability/analyzer" -import { legacyVisualPlan, type LegacyVisualStabilityOptions } from "./visual-stability/invariant" -import type { CapturedFrame, VisualStabilityTrace } from "./visual-stability/model" -import { markVisualProbe, startVisualProbe, stopVisualProbe } from "./visual-stability/probe" -import type { VisualRegionDefinition } from "./visual-stability/regions" -import { reportVisualStability } from "./visual-stability/reporter" - -export * from "./visual-stability/index" - -const capturedFrames = Symbol("capturedFrames") - -export async function startVisualStabilityProbe(page: Page, regions: Record) { - await startVisualProbe(page, regions) -} - -export async function stopVisualStabilityProbe(page: Page) { - const result = await stopVisualProbe(page) - const trace: VisualStabilityTrace = { markers: result.markers, samples: result.samples } - Object.defineProperty(trace, capturedFrames, { value: result.frames }) - return trace -} - -export async function markVisualStability(page: Page, label: string) { - await markVisualProbe(page, label) -} - -export function analyzeVisualStability(trace: VisualStabilityTrace, options: LegacyVisualStabilityOptions = {}) { - return analyzeVisualObservations(trace.samples, legacyVisualPlan(options)) -} - -export function analyzeVisualStabilityByMarker( - trace: VisualStabilityTrace, - options: LegacyVisualStabilityOptions = {}, -) { - return analyzeVisualTraceByMarker(trace, legacyVisualPlan(options)) -} - -export async function expectVisualStability( - testInfo: TestInfo, - name: string, - trace: VisualStabilityTrace, - options: LegacyVisualStabilityOptions = {}, -) { - await reportVisualStability( - testInfo, - name, - { - ...trace, - frames: (trace as VisualStabilityTrace & { [capturedFrames]?: CapturedFrame[] })[capturedFrames] ?? [], - }, - legacyVisualPlan(options), - ) -} diff --git a/packages/app/e2e/utils/visual-stability/analyzer.ts b/packages/app/e2e/utils/visual-stability/analyzer.ts deleted file mode 100644 index 902d222e1d..0000000000 --- a/packages/app/e2e/utils/visual-stability/analyzer.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { VisualInvariant, VisualPlan } from "./invariant" -import type { VisualObservation, VisualStabilityTrace } from "./model" - -export function analyzeVisualObservations( - observations: readonly VisualObservation[], - plan: VisualPlan, -) { - const issues: string[] = [] - const invariants = plan.invariants - const names = [...new Set(observations.flatMap((sample) => Object.keys(sample.regions) as RegionName[]))] - const required = regions(invariants, "required") - const continuousAny = invariants.filter( - (invariant): invariant is Extract, { type: "continuous-any" }> => - invariant.type === "continuous-any", - ) - const unique = new Set(regions(invariants, "unique")) - const stable = new Set(regions(invariants, "stable")) - const fixed = invariants.filter( - (invariant): invariant is Extract, { type: "fixed" }> => invariant.type === "fixed", - ) - const opacity = invariants.filter( - (invariant): invariant is Extract, { type: "opacity" }> => invariant.type === "opacity", - ) - const continuity = invariants.filter( - (invariant): invariant is Extract, { type: "continuity" }> => - invariant.type === "continuity", - ) - const motion = invariants.filter( - (invariant): invariant is Extract, { type: "motion" }> => invariant.type === "motion", - ) - const labelStability = invariants.filter( - (invariant): invariant is Extract, { type: "label-stability" }> => - invariant.type === "label-stability", - ) - - for (const name of new Set(required)) { - if (!observations.some((sample) => sample.regions[name]?.visible)) issues.push(`${name} never rendered`) - } - for (const invariant of continuousAny) { - if (!invariant.regions.some((name) => observations.some((sample) => sample.regions[name]?.visible))) - issues.push(`${invariant.regions.join(" | ")} never rendered`) - } - - for (const name of names) { - const samples = observations.flatMap((observation) => { - const region = observation.regions[name] - if (!region) return [] - const clipped = - observation.viewport && (region.bottom <= observation.viewport.top || region.top >= observation.viewport.bottom) - return [{ at: observation.at, ...region, visible: region.visible && !clipped }] - }) - const visible = samples.filter((sample) => sample.visible) - if (visible.length === 0) continue - if (unique.has(name)) { - const duplicate = samples.find((sample) => sample.count > 1) - if (duplicate) issues.push(`${name} appeared ${duplicate.count} times at ${Math.round(duplicate.at)}ms`) - } - if (stable.has(name)) { - const identities = [...new Set(visible.map((sample) => sample.node).filter((node) => node > 0))] - if (identities.length > 1) issues.push(`${name} remounted ${identities.length - 1} times`) - } - for (const invariant of fixed.filter((invariant) => includes(invariant.regions, name))) { - const origin = visible[0] - const movement = origin ? Math.max(0, ...visible.map((sample) => Math.abs(sample.top - origin.top))) : 0 - if (movement > (invariant.tolerance ?? 1)) - issues.push(`${name} moved ${Math.round(movement * 10) / 10}px in the viewport`) - } - for (const invariant of opacity.filter((invariant) => includes(invariant.regions, name))) { - for (const sample of visible) { - if (sample.opacity < (invariant.floor ?? 0.65)) - issues.push(`${name} opacity fell to ${sample.opacity} at ${Math.round(sample.at)}ms`) - } - } - if (continuity.some((invariant) => includes(invariant.regions, name))) { - const firstPresent = samples.findIndex((sample) => sample.present) - const lastPresent = samples.findLastIndex((sample) => sample.present) - if (samples.slice(firstPresent, lastPresent + 1).some((sample) => !sample.present)) - issues.push(`${name} disappeared between present frames`) - const firstVisible = samples.findIndex((sample) => sample.visible) - const lastVisible = samples.findLastIndex((sample) => sample.visible) - if ( - firstVisible >= 0 && - samples.slice(firstVisible, lastVisible + 1).some((sample) => !sample.visible && sample.inViewport) - ) - issues.push(`${name} blanked between visible frames`) - } - for (const invariant of motion.filter((invariant) => includes(invariant.regions, name))) { - for (const metric of ["top", "bottom", "width", "height"] as const) { - const directions = visible - .slice(1) - .map((sample, index) => sample[metric] - visible[index]![metric]) - .filter((delta) => Math.abs(delta) > (invariant.tolerance ?? 1)) - .map(Math.sign) - const reversals = directions.slice(1).filter((direction, index) => direction !== directions[index]).length - const allowed = - metric === "top" || metric === "bottom" - ? (invariant.maxPositionReversals ?? invariant.maxReversals ?? 1) - : (invariant.maxReversals ?? 1) - if (reversals > allowed) issues.push(`${name} ${metric} reversed ${reversals} times`) - } - } - if (labelStability.some((invariant) => includes(invariant.regions, name))) { - const labels = samples - .map((sample) => sample.label) - .filter((label) => label.length > 0) - .filter((label, index, all) => label !== all[index - 1]) - if (labels.some((label, index) => labels.indexOf(label) !== index)) - issues.push(`${name} label reverted: ${labels.join(" -> ")}`) - } - } - - if (invariants.some((invariant) => invariant.type === "preserve-bottom-anchor")) { - const viewports = observations.flatMap((sample) => (sample.viewport ? [sample.viewport] : [])) - if (viewports[0] && viewports[0].distanceFromBottom <= 4) { - const lost = viewports.find((viewport) => viewport.distanceFromBottom > 4) - if (lost) issues.push(`bottom anchor moved to ${lost.distanceFromBottom}px`) - } - } - if (invariants.some((invariant) => invariant.type === "acquire-bottom-anchor")) { - const final = observations.findLast((sample) => sample.viewport)?.viewport - if (!final || final.distanceFromBottom > 4) - issues.push(`did not acquire bottom anchor${final ? ` (${final.distanceFromBottom}px away)` : ""}`) - } - - for (const invariant of continuousAny) { - const active = observations.map((sample) => invariant.regions.some((name) => sample.regions[name]?.visible)) - const first = active.indexOf(true) - const last = active.lastIndexOf(true) - if (first >= 0 && active.slice(first, last + 1).some((value) => !value)) - issues.push(`${invariant.regions.join(" | ")} blanked between visible frames`) - } - - for (const invariant of invariants.filter( - (item): item is Extract, { type: "flow" }> => item.type === "flow", - )) { - for (const [before, after] of invariant.regions - .slice(1) - .map((after, index) => [invariant.regions[index]!, after])) { - let maximum: { overlap: number; at: number } | undefined - let inverted: { at: number } | undefined - for (const sample of observations) { - const first = sample.regions[before] - const second = sample.regions[after] - if (!first?.visible || !second?.visible) continue - if ( - sample.viewport && - (first.bottom <= sample.viewport.top || - first.top >= sample.viewport.bottom || - second.bottom <= sample.viewport.top || - second.top >= sample.viewport.bottom) - ) - continue - const overlap = first.bottom - second.top - if (first.top > second.top && !inverted) inverted = { at: sample.at } - if (overlap > (invariant.overlapTolerance ?? 0.5) && (!maximum || overlap > maximum.overlap)) - maximum = { overlap, at: sample.at } - } - if (inverted) issues.push(`${before} rendered after ${after} at ${Math.round(inverted.at)}ms`) - if (maximum) - issues.push( - `${before} overlapped ${after} by ${Math.round(maximum.overlap * 10) / 10}px at ${Math.round(maximum.at)}ms`, - ) - } - } - return [...new Set(issues)] -} - -export function analyzeVisualTraceByMarker( - trace: VisualStabilityTrace, - plan: VisualPlan, -) { - if (trace.markers.length === 0) return analyzeVisualObservations(trace.samples, plan) - const required = [...new Set(plan.markerRequired ?? regions(plan.invariants, "required"))].flatMap((name) => - trace.samples.some((sample) => sample.regions[name]?.visible) ? [] : [`${name} never rendered`], - ) - const withoutRequired = plan.invariants.filter((invariant) => invariant.type !== "required") - const windows = trace.markers.flatMap((marker, index) => { - const end = trace.markers[index + 1]?.at ?? Infinity - const before = trace.samples.findLast((sample) => sample.at < marker.at) - const samples = [ - ...(before ? [before] : []), - ...trace.samples.filter((sample) => sample.at >= marker.at && sample.at < end), - ] - if (samples.length < 2) return [] - return analyzeVisualObservations(samples, { ...plan, perMarker: false, invariants: withoutRequired }).map( - (issue) => `${marker.label}: ${issue}`, - ) - }) - const aggregateMotion = - plan.aggregateMotion === false - ? [] - : analyzeVisualObservations(trace.samples, { - invariants: plan.invariants.filter((invariant) => invariant.type === "motion"), - }).filter((issue) => / (?:top|bottom|width|height) reversed \d+ times$/.test(issue)) - return [...new Set([...required, ...aggregateMotion, ...windows])] -} - -function regions["type"]>( - invariants: readonly VisualInvariant[], - type: Type, -) { - return invariants.flatMap((invariant) => - invariant.type === type && "regions" in invariant && invariant.regions !== "all" ? [...invariant.regions] : [], - ) as RegionName[] -} - -function includes(regions: readonly RegionName[] | "all", name: RegionName) { - return regions === "all" || regions.includes(name) -} diff --git a/packages/app/e2e/utils/visual-stability/capture.ts b/packages/app/e2e/utils/visual-stability/capture.ts deleted file mode 100644 index 3bb583a9d5..0000000000 --- a/packages/app/e2e/utils/visual-stability/capture.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { CDPSession, Page } from "@playwright/test" -import type { CapturedFrame } from "./model" - -export type VisualCapture = { - session: CDPSession - frames: CapturedFrame[] - startedAtEpoch: number - running: boolean - capture: Promise -} - -export async function startVisualCapture(page: Page, startedAtEpoch: number) { - if (process.env.OPENCODE_STABILITY_CAPTURE !== "1") return - const session = await page.context().newCDPSession(page) - await session.send("Page.enable") - const recording: VisualCapture = { - session, - frames: [], - startedAtEpoch, - running: true, - capture: Promise.resolve(), - } - recording.capture = (async () => { - try { - while (recording.running && recording.frames.length < 900) { - const frame = await session.send("Page.captureScreenshot", { - format: "jpeg", - quality: 80, - captureBeyondViewport: false, - optimizeForSpeed: true, - }) - recording.frames.push({ at: Date.now() - recording.startedAtEpoch, data: frame.data }) - await new Promise((resolve) => setTimeout(resolve, 50)) - } - } catch { - recording.running = false - } - })() - return recording -} - -export async function stopVisualCapture(recording: VisualCapture | undefined) { - if (!recording) return [] - recording.running = false - try { - await recording.capture - } finally { - await recording.session.detach().catch(() => undefined) - } - return recording.frames -} diff --git a/packages/app/e2e/utils/visual-stability/index.ts b/packages/app/e2e/utils/visual-stability/index.ts deleted file mode 100644 index 6fdbf4acfc..0000000000 --- a/packages/app/e2e/utils/visual-stability/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export * from "./analyzer" -export * from "./capture" -export * from "./invariant" -export * from "./model" -export * from "./probe" -export * from "./regions" -export * from "./reporter" -export * from "./scenario" diff --git a/packages/app/e2e/utils/visual-stability/invariant.ts b/packages/app/e2e/utils/visual-stability/invariant.ts deleted file mode 100644 index 17f6ef1913..0000000000 --- a/packages/app/e2e/utils/visual-stability/invariant.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type { VisualRegionDefinition } from "./regions" - -type RegionSet = readonly RegionName[] | "all" - -export type VisualInvariant = - | { type: "required"; regions: readonly RegionName[] } - | { type: "continuous-any"; regions: readonly RegionName[] } - | { type: "unique"; regions: readonly RegionName[] } - | { type: "stable"; regions: readonly RegionName[] } - | { type: "fixed"; regions: readonly RegionName[]; tolerance?: number } - | { type: "opacity"; regions: RegionSet; floor?: number } - | { - type: "motion" - regions: RegionSet - tolerance?: number - maxReversals?: number - maxPositionReversals?: number - } - | { type: "continuity"; regions: RegionSet } - | { type: "label-stability"; regions: RegionSet } - | { type: "flow"; regions: readonly RegionName[]; overlapTolerance?: number } - | { type: "preserve-bottom-anchor" } - | { type: "acquire-bottom-anchor" } - -export type VisualPlan = { - regionNames?: readonly RegionName[] - invariants: readonly VisualInvariant[] - markerRequired?: readonly RegionName[] - perMarker?: boolean - aggregateMotion?: boolean -} - -export type LegacyVisualStabilityOptions = { - flow?: RegionName[] - motionTolerance?: number - opacityFloor?: number - overlapTolerance?: number - maxReversals?: number - maxPositionReversals?: number - stable?: RegionName[] - fixed?: RegionName[] - motion?: RegionName[] - unique?: RegionName[] - preserveBottomAnchor?: boolean - acquireBottomAnchor?: boolean - perMarker?: boolean - continuousAny?: RegionName[][] - required?: RegionName[] - aggregateMotion?: boolean - inferRequired?: boolean -} - -export function visualPlan>( - regions: Regions, - invariants: readonly VisualInvariant>[], - options: Omit>, "regionNames" | "invariants"> = {}, -): VisualPlan> { - return { ...options, regionNames: Object.keys(regions) as Extract[], invariants } -} - -export function legacyVisualPlan( - options: LegacyVisualStabilityOptions = {}, -): VisualPlan { - const inferred = - options.inferRequired === false - ? [] - : [ - ...(options.stable ?? []), - ...(options.fixed ?? []), - ...(options.unique ?? []), - ...(options.motion ?? []), - ...(options.flow ?? []), - ] - return { - perMarker: options.perMarker, - aggregateMotion: options.aggregateMotion, - markerRequired: [ - ...(options.required ?? []), - ...(options.stable ?? []), - ...(options.fixed ?? []), - ...(options.unique ?? []), - ...(options.motion ?? []), - ...(options.flow ?? []), - ], - invariants: [ - { type: "required", regions: [...(options.required ?? []), ...inferred] }, - ...(options.continuousAny ?? []).map( - (regions): VisualInvariant => ({ type: "continuous-any", regions }), - ), - ...(options.unique ? [{ type: "unique" as const, regions: options.unique }] : []), - ...(options.stable ? [{ type: "stable" as const, regions: options.stable }] : []), - ...(options.fixed - ? [{ type: "fixed" as const, regions: options.fixed, tolerance: options.motionTolerance }] - : []), - { type: "opacity", regions: "all", floor: options.opacityFloor }, - { type: "continuity", regions: "all" }, - { - type: "motion", - regions: options.motion ?? "all", - tolerance: options.motionTolerance, - maxReversals: options.maxReversals, - maxPositionReversals: options.maxPositionReversals, - }, - { type: "label-stability", regions: "all" }, - ...(options.preserveBottomAnchor ? [{ type: "preserve-bottom-anchor" as const }] : []), - ...(options.acquireBottomAnchor ? [{ type: "acquire-bottom-anchor" as const }] : []), - ...(options.flow - ? [{ type: "flow" as const, regions: options.flow, overlapTolerance: options.overlapTolerance }] - : []), - ], - } -} diff --git a/packages/app/e2e/utils/visual-stability/model.ts b/packages/app/e2e/utils/visual-stability/model.ts deleted file mode 100644 index 1b966638c9..0000000000 --- a/packages/app/e2e/utils/visual-stability/model.ts +++ /dev/null @@ -1,47 +0,0 @@ -export type VisualRegionSample = { - present: boolean - visible: boolean - inViewport: boolean - cssHidden?: boolean - top: number - bottom: number - layoutTop?: number - layoutBottom?: number - width: number - height: number - opacity: number - count: number - node: number - label: string - text: string -} - -export type VisualViewportSample = { - top: number - bottom: number - scrollTop: number - scrollHeight: number - clientHeight: number - distanceFromBottom: number -} - -export type VisualObservation = { - at: number - regions: string extends RegionName - ? Record - : Partial> - viewport?: VisualViewportSample -} - -export type VisualMarker = { at: number; label: string } - -export type VisualStabilityTrace = { - markers: VisualMarker[] - samples: VisualObservation[] -} - -export type CapturedFrame = { at: number; data: string } - -export type VisualProbeResult = VisualStabilityTrace & { - frames: CapturedFrame[] -} diff --git a/packages/app/e2e/utils/visual-stability/probe.ts b/packages/app/e2e/utils/visual-stability/probe.ts deleted file mode 100644 index abf274d359..0000000000 --- a/packages/app/e2e/utils/visual-stability/probe.ts +++ /dev/null @@ -1,226 +0,0 @@ -import type { Page } from "@playwright/test" -import { startVisualCapture, stopVisualCapture, type VisualCapture } from "./capture" -import type { VisualMarker, VisualObservation, VisualProbeResult } from "./model" -import type { VisualRegionDefinition } from "./regions" - -type ProbeWindow = Window & { - __visualStabilityProbe?: { - startedAt: number - markers: VisualMarker[] - samples: VisualObservation[] - stop: () => void - } -} - -const captures = new WeakMap() - -export async function startVisualProbe>( - page: Page, - regions: Regions, -) { - await stopCapture(page) - await page.evaluate(() => { - ;(window as ProbeWindow).__visualStabilityProbe?.stop() - }) - const startedAtEpoch = await page.evaluate((regions) => { - const samples: VisualObservation[] = [] - const markers: VisualMarker[] = [] - const startedAt = performance.now() - const nodes = new WeakMap() - const lastBounds = new Map() - let nextNode = 1 - let running = true - const round = (value: number) => Math.round(value * 10) / 10 - const opacity = (element: Element) => Number(getComputedStyle(element).opacity) - const sample = () => { - if (!running) return - setTimeout(() => { - if (!running) return - const viewport = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => - element.querySelector("[data-timeline-row]"), - ) - const viewportRect = viewport?.getBoundingClientRect() - samples.push({ - at: performance.now() - startedAt, - viewport: viewport - ? { - top: round(viewportRect!.top), - bottom: round(viewportRect!.bottom), - scrollTop: round(viewport.scrollTop), - scrollHeight: round(viewport.scrollHeight), - clientHeight: round(viewport.clientHeight), - distanceFromBottom: round(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop), - } - : undefined, - regions: Object.fromEntries( - Object.entries(regions).map(([name, config]) => { - const found = document.querySelector(config.selector) - const count = document.querySelectorAll(config.selector).length - const element = config.closest ? found?.closest(config.closest) : found - if (!element) - return [ - name, - { - present: false, - visible: false, - inViewport: false, - top: 0, - bottom: 0, - width: 0, - height: 0, - opacity: 0, - count, - node: 0, - label: "", - text: "", - }, - ] - const rect = element.getBoundingClientRect() - const style = getComputedStyle(element) - if (rect.height > 0) lastBounds.set(name, { top: rect.top, bottom: rect.bottom }) - const known = rect.height > 0 ? rect : lastBounds.get(name) - const painted = (() => { - const result = { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right } - let parent = element.parentElement - while (parent) { - const parentStyle = getComputedStyle(parent) - if (["hidden", "clip", "scroll", "auto"].includes(parentStyle.overflowY)) { - const parentRect = parent.getBoundingClientRect() - result.top = Math.max(result.top, parentRect.top) - result.bottom = Math.min(result.bottom, parentRect.bottom) - } - if (["hidden", "clip", "scroll", "auto"].includes(parentStyle.overflowX)) { - const parentRect = parent.getBoundingClientRect() - result.left = Math.max(result.left, parentRect.left) - result.right = Math.min(result.right, parentRect.right) - } - if (parent === viewport) break - parent = parent.parentElement - } - if (viewportRect) { - result.top = Math.max(result.top, viewportRect.top) - result.bottom = Math.min(result.bottom, viewportRect.bottom) - result.left = Math.max(result.left, viewportRect.left) - result.right = Math.min(result.right, viewportRect.right) - } - return result - })() - const contentOpacity = config.opacitySelectors?.length - ? Math.max( - 0, - ...config.opacitySelectors.flatMap((selector) => - [...element.querySelectorAll(selector)].map((node) => { - let value = 1 - let current: Element | null = node - while (current) { - value *= opacity(current) - if (current === element) break - current = current.parentElement - } - return value - }), - ), - ) - : opacity(element) - let visibleOpacity = contentOpacity - let ancestor = element.parentElement - let ancestorHidden = false - while (ancestor) { - const ancestorStyle = getComputedStyle(ancestor) - visibleOpacity *= Number(ancestorStyle.opacity) - if (ancestorStyle.display === "none" || ancestorStyle.visibility === "hidden") ancestorHidden = true - if (ancestor === viewport) break - ancestor = ancestor.parentElement - } - const cssHidden = - ancestorHidden || style.display === "none" || style.visibility === "hidden" || visibleOpacity === 0 - return [ - name, - { - present: true, - visible: - style.display !== "none" && - style.visibility !== "hidden" && - visibleOpacity > 0 && - painted.right > painted.left && - painted.bottom > painted.top, - inViewport: - !viewportRect || (!!known && known.bottom > viewportRect.top && known.top < viewportRect.bottom), - cssHidden, - top: round(painted.top), - bottom: round(painted.bottom), - layoutTop: round(rect.top), - layoutBottom: round(rect.bottom), - width: round(painted.right - painted.left), - height: round(painted.bottom - painted.top), - opacity: round(visibleOpacity), - count, - node: (() => { - const current = nodes.get(element) - if (current) return current - nodes.set(element, nextNode) - return nextNode++ - })(), - label: element.getAttribute("aria-label") ?? "", - text: (element.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 500), - }, - ] - }), - ), - }) - requestAnimationFrame(sample) - }, 0) - } - ;(window as ProbeWindow).__visualStabilityProbe = { - startedAt, - markers, - samples, - stop: () => { - running = false - }, - } - requestAnimationFrame(sample) - return new Promise((resolve) => { - const ready = () => { - if (samples.length > 0) return resolve(performance.timeOrigin + startedAt) - requestAnimationFrame(ready) - } - ready() - }) - }, regions) - const capture = await startVisualCapture(page, startedAtEpoch) - if (capture) captures.set(page, capture) -} - -export async function stopVisualProbe( - page: Page, -): Promise> { - return page - .evaluate(() => { - const probe = (window as ProbeWindow).__visualStabilityProbe - if (!probe) throw new Error("Visual stability probe is not running") - probe.stop() - return { markers: probe.markers, samples: probe.samples } - }) - .then( - async (trace) => ({ ...trace, frames: await stopCapture(page) }) as unknown as VisualProbeResult, - async (error: unknown) => { - await stopCapture(page) - throw error - }, - ) -} - -export async function markVisualProbe(page: Page, label: string) { - await page.evaluate((label) => { - const probe = (window as ProbeWindow).__visualStabilityProbe - if (!probe) return - probe.markers.push({ at: performance.now() - probe.startedAt, label }) - }, label) -} - -async function stopCapture(page: Page) { - const capture = captures.get(page) - if (capture) captures.delete(page) - return stopVisualCapture(capture) -} diff --git a/packages/app/e2e/utils/visual-stability/regions.ts b/packages/app/e2e/utils/visual-stability/regions.ts deleted file mode 100644 index 4c0b34e78a..0000000000 --- a/packages/app/e2e/utils/visual-stability/regions.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type VisualRegionDefinition = { - selector: string - closest?: string - opacitySelectors?: readonly string[] -} - -export function defineVisualRegions>(regions: Regions) { - return regions -} - -export function mapVisualRegions, Result>( - regions: Regions, - map: (region: Regions[keyof Regions], name: keyof Regions) => Result, -) { - return Object.fromEntries( - Object.entries(regions).map(([name, region]) => [name, map(region as Regions[keyof Regions], name)]), - ) as { [Name in keyof Regions]: Result } -} diff --git a/packages/app/e2e/utils/visual-stability/reporter.ts b/packages/app/e2e/utils/visual-stability/reporter.ts deleted file mode 100644 index db283072a8..0000000000 --- a/packages/app/e2e/utils/visual-stability/reporter.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { expect, type TestInfo } from "@playwright/test" -import { writeFile } from "node:fs/promises" -import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./analyzer" -import type { VisualPlan } from "./invariant" -import type { VisualProbeResult } from "./model" - -export async function reportVisualStability( - testInfo: TestInfo, - name: string, - result: VisualProbeResult, - plan: VisualPlan, -) { - const trace = { markers: result.markers, samples: result.samples } - const issues = plan.perMarker - ? analyzeVisualTraceByMarker(trace, plan) - : analyzeVisualObservations(result.samples, plan) - const tracePath = testInfo.outputPath(`${name}-visual-trace.json`) - const issuesPath = testInfo.outputPath(`${name}-visual-issues.json`) - await writeFile(tracePath, JSON.stringify(trace, null, 2)) - await writeFile( - issuesPath, - JSON.stringify({ issues, markers: result.markers, capturedFrameCount: result.frames.length }, null, 2), - ) - await testInfo.attach(`${name}-visual-trace`, { path: tracePath, contentType: "application/json" }) - await testInfo.attach(`${name}-visual-issues`, { path: issuesPath, contentType: "application/json" }) - if (issues.length) await attachViolationFrames(testInfo, name, result, issues) - expect(issues, `${name}: ${issues.join("\n")}`).toEqual([]) -} - -async function attachViolationFrames( - testInfo: TestInfo, - name: string, - result: VisualProbeResult, - issues: string[], -) { - if (result.frames.length === 0) return - const targets = [ - ...new Set( - issues.flatMap((issue) => { - const match = issue.match(/ at (\d+)ms/) - if (match) return [Number(match[1])] - const marker = result.markers.find((item) => issue.startsWith(`${item.label}:`)) - return marker ? [marker.at] : [] - }), - ), - ].slice(0, 6) - for (const [violation, target] of targets.entries()) { - const nearest = result.frames.reduce( - (best, frame, index) => (Math.abs(frame.at - target) < Math.abs(result.frames[best]!.at - target) ? index : best), - 0, - ) - for (const [label, index] of [ - ["before", Math.max(0, nearest - 1)], - ["violation", nearest], - ["after", Math.min(result.frames.length - 1, nearest + 1)], - ] as const) { - await testInfo.attach(`${name}-${violation + 1}-${label}-${Math.round(result.frames[index]!.at)}ms`, { - body: Buffer.from(result.frames[index]!.data, "base64"), - contentType: "image/jpeg", - }) - } - } -} diff --git a/packages/app/e2e/utils/visual-stability/scenario.ts b/packages/app/e2e/utils/visual-stability/scenario.ts deleted file mode 100644 index 2136302071..0000000000 --- a/packages/app/e2e/utils/visual-stability/scenario.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Page, TestInfo } from "@playwright/test" -import type { VisualPlan } from "./invariant" -import { startVisualProbe, stopVisualProbe } from "./probe" -import type { VisualRegionDefinition } from "./regions" -import { reportVisualStability } from "./reporter" - -export async function runVisualStabilityScenario>(input: { - page: Page - testInfo: TestInfo - name: string - regions: Regions - plan: VisualPlan> - run: () => Promise -}) { - await startVisualProbe(input.page, input.regions) - await input.run() - const result = await stopVisualProbe>(input.page) - await reportVisualStability(input.testInfo, input.name, result, input.plan) - return result -} diff --git a/packages/app/package.json b/packages/app/package.json index 2dd4a05d86..5749e754a8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.18.11", + "version": "1.17.13", "description": "", "type": "module", "exports": { @@ -13,20 +13,18 @@ }, "scripts": { "typecheck": "tsgo -b", - "typecheck:e2e": "tsgo -p e2e/tsconfig.json", "start": "vite", "dev": "vite", "build": "vite build", "serve": "vite preview", "test": "bun run test:unit && bun run test:browser", - "test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src", + "test:unit": "bun test --only-failures --preload ./happydom.ts ./src", "test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser", - "test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src", + "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:e2e": "playwright test", "test:e2e:local": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:report": "playwright show-report e2e/playwright-report", - "test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts", "test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts" }, "license": "MIT", @@ -47,15 +45,12 @@ "vite-plugin-solid": "catalog:" }, "dependencies": { - "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", "@dnd-kit/dom": "0.5.0", "@dnd-kit/helpers": "0.5.0", "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", - "@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz", "@opencode-ai/core": "workspace:*", - "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -82,7 +77,7 @@ "diff": "catalog:", "effect": "catalog:", "fuzzysort": "catalog:", - "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", + "ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553", "luxon": "catalog:", "marked": "catalog:", "marked-shiki": "catalog:", @@ -90,7 +85,6 @@ "shiki": "catalog:", "solid-js": "catalog:", "solid-list": "catalog:", - "solid-presence": "0.2.0", "tailwindcss": "catalog:" } } diff --git a/packages/app/src/addons/serialize.test.ts b/packages/app/src/addons/serialize.test.ts index d45af34c43..6828e60f84 100644 --- a/packages/app/src/addons/serialize.test.ts +++ b/packages/app/src/addons/serialize.test.ts @@ -37,14 +37,6 @@ function writeAndWait(term: Terminal, data: string): Promise { } describe("SerializeAddon", () => { - test("preserves color scheme reporting mode", async () => { - const { term, addon } = createTerminal() - await writeAndWait(term, "\x1b[?2031h") - - expect(addon.serialize().startsWith("\x1b[?2031h")).toBe(true) - expect(addon.serialize({ excludeModes: true }).startsWith("\x1b[?2031h")).toBe(false) - }) - 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 515153488c..3823fb443a 100644 --- a/packages/app/src/addons/serialize.ts +++ b/packages/app/src/addons/serialize.ts @@ -89,13 +89,6 @@ const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined = return { active, normal, alternate } } -const getTerminalMode = (value: ITerminalCore, mode: number) => { - if (!isRecord(value)) return false - const terminal = value.wasmTerm - if (!isRecord(terminal) || typeof terminal.getMode !== "function") return false - return terminal.getMode(mode) === true -} - // ============================================================================ // Types // ============================================================================ @@ -551,8 +544,7 @@ export class SerializeAddon implements ITerminalAddon { return "" } - let content = !options?.excludeModes && getTerminalMode(this._terminal, 2031) ? "\u001b[?2031h" : "" - content += options?.range + let content = options?.range ? this._serializeBufferByRange(normalBuffer, options.range, true) : this._serializeBufferByScrollback(normalBuffer, options?.scrollback) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index f47c432e42..4ab0a95c23 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -9,19 +9,9 @@ import { Font } from "@opencode-ai/ui/font" import { Splash } from "@opencode-ai/ui/logo" import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" -import { - type BaseRouterProps, - Navigate, - Route, - Router, - useLocation, - useNavigate, - useParams, - useSearchParams, -} from "@solidjs/router" +import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { Effect } from "effect" -import { base64Encode } from "@opencode-ai/core/util/encode" import { type Component, createEffect, @@ -38,12 +28,11 @@ import { Show, } from "solid-js" import { Dynamic } from "solid-js/web" -import { makeEventListener } from "@solid-primitives/event-listener" import { CommandProvider, useCommand, type CommandOption } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file" import { ServerSDKProvider } from "@/context/server-sdk" -import { ServerSyncProvider, useServerSync } from "@/context/server-sync" +import { ServerSyncProvider } from "@/context/server-sync" import { GlobalProvider, useGlobal } from "@/context/global" import { HighlightsProvider } from "@/context/highlights" import { LanguageProvider, type Locale, useLanguage } from "@/context/language" @@ -63,12 +52,10 @@ import LegacyLayout from "@/pages/layout" import NewLayout from "@/pages/layout-new" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" -import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" -import { createSessionLineage } from "@/pages/session/session-lineage" +import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" -import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" -import { NewHome } from "@/pages/home" -import { LegacyHome } from "@/pages/home/legacy-home" +import { SessionPage, TargetSessionRoute as TargetSessionRouteContent } from "@/pages/session" +import { NewHome, LegacyHome } from "@/pages/home" const NewSession = lazy(() => import("@/pages/new-session")) @@ -101,14 +88,10 @@ const SessionRoute = () => { tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt) }) - return ( - - - - ) + return } -function TargetServerRoute(props: ParentProps) { +const TargetSessionRoute = () => { const params = useParams<{ serverKey: string; id: string }>() const global = useGlobal() const conn = createMemo(() => { @@ -117,54 +100,19 @@ function TargetServerRoute(props: ParentProps) { }) return ( - // Owns the server-identity remount. Session changes must NOT remount this - // subtree (SessionRouteErrorBoundary resets and createSessionLineage - // re-resolves reactively instead); both rely on this key for server changes. - {props.children} + + + ) } -const TargetSessionRoute = () => ( - - - -) - -function LegacyTargetSessionRoute() { - const params = useParams<{ serverKey: string; id: string }>() - return ( - - - - - - ) -} - -function LegacyTargetSessionRedirect() { - const params = useParams<{ id: string }>() - const navigate = useNavigate() - const sync = useServerSync() - const current = createSessionLineage( - () => params.id, - () => sync().session.lineage, - ) - - createEffect(() => { - const directory = current()?.session.directory - if (!directory) return - navigate(legacySessionHref(directory, params.id), { replace: true }) - }) - - return null -} - // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected -// server via ServerKey, then provide the server-scoped shell for that server. +// server via ServerKey, then provide the server-scoped shell (Permission/Layout/ +// Notification/Models + the visual Layout) for that server. function SelectedServerProviders(props: ParentProps) { return ( @@ -175,17 +123,16 @@ function SelectedServerProviders(props: ParentProps) { ) } -function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { +function LegacyServerLayout(props: ParentProps) { return ( - {props.children} + {props.children} ) } function DraftRoute() { const [search] = useSearchParams<{ draftId?: string }>() - const settings = useSettings() const tabs = useTabs() return ( @@ -194,14 +141,7 @@ function DraftRoute() { keyed fallback={} > - {(draft) => ( - } - > - - - )} + {(draft) => } ) @@ -217,7 +157,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) { - + @@ -225,7 +165,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) { - + @@ -237,37 +177,13 @@ function UiI18nBridge(props: ParentProps) { return {props.children} } -function LayoutCompatibility(props: ParentProps) { - const global = useGlobal() - const navigate = useNavigate() - const server = useServer() - const settings = useSettings() - - createEffect(() => { - if (settings.general.newLayoutDesigns()) return - const current = server.current - if (!current) return - const protocol = global.ensureServerCtx(current).sdk.protocolKind() - if (protocol !== "v2") return - const next = global.servers.list().find((s) => { - if (ServerConnection.key(s) === ServerConnection.key(current)) return false - return global.ensureServerCtx(s).sdk.protocolKind() !== "v2" - }) - if (!next) return - navigate("/") - queueMicrotask(() => server.setActive(ServerConnection.key(next))) - }) - - return <>{props.children} -} - declare global { interface Window { __OPENCODE__?: { deepLinks?: string[] } api?: { - setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise + setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise exportDebugLogs?: () => Promise } } @@ -343,36 +259,45 @@ function DesktopCommands() { // Server-scoped providers shared by the legacy shell and the top-level new shell. type ServerScopedShellProps = ParentProps<{ directory?: () => string | undefined - serverScoped?: JSX.Element + sessionID?: () => string | undefined }> function ServerScopedProviders(props: ServerScopedShellProps) { return ( - - {props.serverScoped} - {props.children} - + + + {props.children} + + ) } function LegacyServerScopedShell(props: ServerScopedShellProps) { return ( - + {props.children} ) } -function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) { +function NewAppLayout(props: ParentProps) { return ( - + {props.children} ) } +function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) { + return ( + + {props.children} + + ) +} + // The draft page only renders the prompt composer, so it drops TerminalProvider. // FileProvider and CommentsProvider stay because PromptInput uses file search and comment context. function DraftProviders(props: ParentProps) { @@ -390,8 +315,8 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { { - void window.api?.setTitlebar?.({ mode, scheme }) + onThemeApplied={(_, mode) => { + void window.api?.setTitlebar?.({ mode }) }} > @@ -419,7 +344,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { ) } -function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise }>) { +function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { const server = useServer() const checkServerHealth = useCheckServerHealth() @@ -448,45 +373,34 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; start const checking = createMemo( () => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state), ) - const [startup] = createResource(async () => { - if (!props.startup) return true - await props.startup.catch((error) => { - console.error("[startup] startup gate failed", error) - }) - return true - }) - const startupChecking = createMemo( - () => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state), - ) - const loading = createMemo(() => checking() || startupChecking()) return ( - <> - - { - if (checkMode() === "background") void healthCheckActions.refetch() - }} - onServerSelected={(key) => { - setCheckMode("blocking") - server.setActive(key) - void healthCheckActions.refetch() - }} - /> - } - > - {props.children} - - - -
+
+ } + > + { + if (checkMode() === "background") void healthCheckActions.refetch() + }} + onServerSelected={(key) => { + setCheckMode("blocking") + server.setActive(key) + void healthCheckActions.refetch() + }} + /> + } + > + {props.children} - +
) } @@ -553,8 +467,6 @@ export function AppInterface(props: { servers?: Array router?: Component disableHealthCheck?: boolean - startup?: Promise - serverScoped?: JSX.Element }) { // The visual new layout lives in the router root so it remains mounted across // route changes. Draft and session routes override only their server-bound data @@ -576,25 +488,23 @@ export function AppInterface(props: { > - + ( - - - - - {routerProps.children} - - - - + + + + {routerProps.children} + + + )} > - + @@ -604,24 +514,13 @@ export function AppInterface(props: { ) } -function Routes(props: { serverScoped?: JSX.Element }) { +function Routes() { const settings = useSettings() return ( <> - ( - {routeProps.children} - )} - > - - { - <> - - - - } - + + {} } /> @@ -629,15 +528,15 @@ function Routes(props: { serverScoped?: JSX.Element }) { - - + + ) } -function NewLayoutLegacySessionRedirect() { +function LegacyTargetSessionRoute() { const server = useServer() const tabs = useTabs() const params = useParams<{ id: string }>() diff --git a/packages/app/src/assets/help/home.png b/packages/app/src/assets/help/home.png deleted file mode 100644 index aeca6d977a..0000000000 Binary files a/packages/app/src/assets/help/home.png and /dev/null differ diff --git a/packages/app/src/assets/help/introducing-tabs.mp4 b/packages/app/src/assets/help/introducing-tabs.mp4 deleted file mode 100644 index fd46be1f46..0000000000 Binary files a/packages/app/src/assets/help/introducing-tabs.mp4 and /dev/null differ diff --git a/packages/app/src/assets/help/placeholder.png b/packages/app/src/assets/help/placeholder.png deleted file mode 100644 index c651d06cc9..0000000000 Binary files a/packages/app/src/assets/help/placeholder.png and /dev/null differ diff --git a/packages/app/src/assets/help/tabs.png b/packages/app/src/assets/help/tabs.png deleted file mode 100644 index d2c6e68a3d..0000000000 Binary files a/packages/app/src/assets/help/tabs.png and /dev/null differ diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts deleted file mode 100644 index 8014ea1c43..0000000000 --- a/packages/app/src/components/command-palette.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { getFilename } from "@opencode-ai/core/util/path" -import type { Project } from "@opencode-ai/sdk/v2/client" -import type { SessionInfo } from "@opencode-ai/client/promise" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { createMemo, onCleanup } from "solid-js" -import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command" -import { useFile } from "@/context/file" -import { useGlobal } from "@/context/global" -import { useLanguage } from "@/context/language" -import { useLayout, type LocalProject } from "@/context/layout" -import { ServerConnection } from "@/context/server" -import { useServerSDK } from "@/context/server-sdk" -import { useTabs } from "@/context/tabs" -import { displayName, projectForSession } from "@/pages/layout/helpers" -import { createSessionTabs } from "@/pages/session/helpers" -import { useSessionLayout } from "@/pages/session/session-layout" -import { normalizeSessionInfo } from "@/utils/session" - -export type CommandPaletteEntry = { - id: string - type: "command" | "file" | "session" - title: string - description?: string - keybind?: string - category: string - option?: CommandOption - path?: string - directory?: string - sessionID?: string - server?: ServerConnection.Key - project?: LocalProject - archived?: number - updated?: number -} - -const ENTRY_LIMIT = 5 -const COMMON_COMMAND_IDS = [ - "session.new", - "workspace.new", - "session.previous", - "session.next", - "terminal.toggle", - "review.toggle", -] as const - -export function uniqueCommandPaletteEntries(items: CommandPaletteEntry[]) { - const seen = new Set() - return items.filter((item) => { - if (seen.has(item.id)) return false - seen.add(item.id) - return true - }) -} - -export function createCommandPaletteFileEntry(path: string, category: string): CommandPaletteEntry { - return { - id: "file:" + path, - type: "file", - title: path, - category, - path, - } -} - -export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => void) { - const file = useFile() - const layout = useLayout() - const { tabs, view } = useSessionLayout() - - return (path: string) => { - const value = file.tab(path) - void tabs().open(value) - void file.load(path) - if (!view().reviewPanel.opened()) view().reviewPanel.open() - layout.fileTree.setTab("all") - onOpenFile?.(path) - tabs().setActive(value) - } -} - -export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) { - const command = useCommand() - const global = useGlobal() - const language = useLanguage() - const file = useFile() - const dialog = useDialog() - const serverSDK = useServerSDK()() - const serverCtx = global.ensureServerCtx(serverSDK.server) - const appTabs = useTabs() - const { tabs: sessionTabs } = useSessionLayout() - const openFile = createCommandPaletteFileOpener(props.onOpenFile) - const state = { cleanup: undefined as (() => void) | void, committed: false } - const filesOnly = () => props.filesOnly?.() ?? false - - const allowedCommands = createMemo(() => { - if (filesOnly()) return [] - return commandPaletteOptions(command.options) - }) - const commandEntries = createMemo(() => { - const category = language.t("palette.group.commands") - return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category)) - }) - const preferredCommandEntries = createMemo(() => { - const all = allowedCommands() - const order = new Map(COMMON_COMMAND_IDS.map((id, index) => [id, index])) - const picked = all.filter((option) => order.has(option.id)) - const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT) - const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base - const category = language.t("palette.group.commands") - return sorted.map((option) => createCommandPaletteCommandEntry(option, category)) - }) - - const tabState = createSessionTabs({ - tabs: sessionTabs, - pathFromTab: file.pathFromTab, - normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), - }) - const recentFileEntries = createMemo(() => { - const all = tabState.openedTabs() - const active = tabState.activeFileTab() - const order = active ? [active, ...all.filter((item) => item !== active)] : all - const seen = new Set() - const category = language.t("palette.group.files") - return order - .map((item) => file.pathFromTab(item)) - .filter((path): path is string => { - if (!path || seen.has(path)) return false - seen.add(path) - return true - }) - .slice(0, ENTRY_LIMIT) - .map((path) => createCommandPaletteFileEntry(path, category)) - }) - const rootFileEntries = createMemo(() => { - const category = language.t("palette.group.files") - return file.tree - .children("") - .filter((node) => node.type === "file") - .map((node) => node.path) - .sort((a, b) => a.localeCompare(b)) - .slice(0, ENTRY_LIMIT) - .map((path) => createCommandPaletteFileEntry(path, category)) - }) - - const sessions = createServerSessionEntries({ - server: ServerConnection.key(serverSDK.server), - opened: serverCtx.projects.list, - stored: () => serverCtx.sync.data.project, - load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }), - untitled: () => language.t("command.session.new"), - category: () => language.t("command.category.session"), - }) - - const highlight = (item: CommandPaletteEntry | undefined) => { - state.cleanup?.() - state.cleanup = undefined - if (item?.type !== "command") return - state.cleanup = item.option?.onHighlight?.() - } - - const select = (item: CommandPaletteEntry | undefined) => { - if (!item) return - state.committed = true - state.cleanup = undefined - dialog.close() - if (item.type === "command") { - item.option?.onSelect?.("palette") - return - } - if (item.type === "session") { - if (!item.sessionID || !item.server) return - const directory = item.project?.worktree ?? item.directory - if (directory) { - serverCtx.projects.open(directory) - serverCtx.projects.touch(directory) - } - const tab = appTabs.addSessionTab({ - server: item.server, - sessionId: item.sessionID, - }) - appTabs.select(tab) - return - } - if (!item.path) return - openFile(item.path) - } - - onCleanup(() => { - if (state.committed) return - state.cleanup?.() - }) - - return { - language, - file, - commandEntries, - preferredCommandEntries, - recentFileEntries, - rootFileEntries, - sessions, - highlight, - select, - close: () => dialog.close(), - } -} - -export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry { - return { - id: "command:" + option.id, - type: "command", - title: option.title, - description: option.description, - keybind: option.keybind, - category, - option, - } -} - -export function createServerSessionEntries(props: { - server: ServerConnection.Key - opened: () => LocalProject[] - stored: () => Project[] - load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }> - untitled: () => string - category: () => string -}) { - let abort: AbortController | undefined - - onCleanup(() => abort?.abort()) - - return async (text: string): Promise => { - const search = text.trim() - if (!search) { - abort?.abort() - return [] - } - abort?.abort() - const current = new AbortController() - abort = current - await new Promise((resolve) => { - const timer = setTimeout(resolve, 100) - current.signal.addEventListener( - "abort", - () => { - clearTimeout(timer) - resolve() - }, - { once: true }, - ) - }) - if (current.signal.aborted) return [] - const opened = props.opened() - const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : []))) - const stored = props.stored().map((project) => ({ ...project, expanded: false })) - const storedByID = new Map(stored.map((project) => [project.id, project] as const)) - return props - .load(search, current.signal) - .then((result) => - result.data - .map(normalizeSessionInfo) - .filter((session) => !session.time.archived) - .map((session) => { - const project = - projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID) - return { - id: `session:${props.server}:${session.id}`, - type: "session" as const, - title: session.title || props.untitled(), - description: project ? displayName(project) : getFilename(session.directory), - category: props.category(), - directory: session.directory, - sessionID: session.id, - server: props.server, - project, - updated: session.time.updated, - } - }), - ) - .catch(() => [] as CommandPaletteEntry[]) - } -} diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx index adadeda303..e55e128b82 100644 --- a/packages/app/src/components/debug-bar.tsx +++ b/packages/app/src/components/debug-bar.tsx @@ -5,7 +5,6 @@ import { makeEventListener } from "@solid-primitives/event-listener" import { Tooltip } from "@opencode-ai/ui/tooltip" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" type Mem = Performance & { memory?: { @@ -66,7 +65,7 @@ function Cell(props: {
-
- {props.label} -
-
- {props.value} -
+ {props.label} +
+
+ {props.value}
) @@ -116,55 +107,8 @@ function Cell(props: { ) } -function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) { - const content = () => ( - - ) - - if (props.inline) { - return ( - - {content()} - - ) - } - - return ( - - {content()} - - ) -} - export function DebugBar(props: { inline?: boolean } = {}) { const language = useLanguage() - const platform = usePlatform() const location = useLocation() const routing = useIsRouting() const [state, setState] = createStore({ @@ -172,7 +116,6 @@ export function DebugBar(props: { inline?: boolean } = {}) { delay: undefined as number | undefined, fps: undefined as number | undefined, gap: undefined as number | undefined, - focus: false, heap: { limit: undefined as number | undefined, used: undefined as number | undefined, @@ -199,16 +142,6 @@ export function DebugBar(props: { inline?: boolean } = {}) { } const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`) const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na())) - const toggleFocus = async () => { - if (!platform.setForceFocus) return - const enabled = !state.focus - await platform.setForceFocus(enabled) - setState("focus", enabled) - } - - onCleanup(() => { - if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined) - }) let prev = "" let start = 0 @@ -557,11 +490,8 @@ export function DebugBar(props: { inline?: boolean } = {}) { bad={bad(heap(), 0.8)} dim={state.heap.used === undefined} inline={props.inline} - wide={!platform.setForceFocus} + wide /> - {platform.setForceFocus && ( - void toggleFocus()} /> - )}
) diff --git a/packages/app/src/components/dialog-command-palette-v2.css b/packages/app/src/components/dialog-command-palette-v2.css deleted file mode 100644 index a4019928aa..0000000000 --- a/packages/app/src/components/dialog-command-palette-v2.css +++ /dev/null @@ -1,220 +0,0 @@ -.command-palette-v2 { - overflow: hidden; -} - -/* Anchor to the top edge of where a centered 480px-tall dialog would sit, so the - top stays put while the content-driven height grows and shrinks. */ -[data-component="dialog-v2"]:has(.command-palette-v2) { - align-items: flex-start; -} - -[data-component="dialog-v2"]:has(.command-palette-v2) [data-slot="dialog-container"] { - width: min(calc(100vw - 24px), 640px); - height: auto; - min-height: 280px; - max-height: min(calc(100vh - 96px), 480px); - margin-top: max(48px, calc((100vh - 480px) / 2)); - border-radius: 12px; - background: var(--v2-background-bg-base); - box-shadow: var(--v2-elevation-floating); -} - -.command-palette-v2-body { - display: flex; - min-height: 0; - flex: 1; - flex-direction: column; - gap: 0; - padding: 0; -} - -.command-palette-v2-search { - flex-shrink: 0; - padding: 6px; -} - -.command-palette-v2-search [data-component="text-input-v2"] { - width: 100%; - height: 36px; - border-radius: 6px; - outline: 0; - background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent); - box-shadow: none; - transition: - background-color 120ms ease-in-out, - box-shadow 120ms ease-in-out; -} - -.command-palette-v2-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]), -.command-palette-v2-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) { - background: var(--v2-background-bg-layer-02); - box-shadow: none; -} - -.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] { - border-radius: 6px; - background: transparent; - gap: 8px; -} - -.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] { - padding-left: 12px; -} - -.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] { - border-radius: 6px; - background: transparent; -} - -.command-palette-v2-scroll { - min-height: 0; - flex: 1; -} - -.command-palette-v2-results { - display: flex; - flex-direction: column; - gap: 16px; - padding: 6px 6px 8px; -} - -.command-palette-v2-group { - display: flex; - flex-direction: column; - gap: 1px; -} - -.command-palette-v2-group-title { - margin: 6px 0; - padding: 0 12px; - color: var(--v2-text-text-muted); - font-size: 13px; - font-weight: 440; - line-height: 16px; - letter-spacing: -0.04px; - user-select: none; -} - -.command-palette-v2-row { - display: flex; - width: 100%; - height: 36px; - flex-shrink: 0; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 0 12px; - border: 0; - border-radius: 6px; - background: transparent; - color: var(--v2-text-text-base); - text-align: left; - cursor: default; - scroll-margin: 6px 0; -} - -.command-palette-v2-row[data-active] { - background: var(--v2-overlay-simple-overlay-hover); -} - -.command-palette-v2-row:focus-visible { - background: var(--v2-overlay-simple-overlay-hover); - outline: none; -} - -.command-palette-v2-row-main { - display: flex; - min-width: 0; - flex: 1; - align-items: center; - gap: 8px; -} - -.command-palette-v2-row-icon { - flex-shrink: 0; - color: var(--v2-icon-icon-muted); -} - -.command-palette-v2-row-text { - display: flex; - min-width: 0; - align-items: center; - gap: 6px; -} - -.command-palette-v2-title { - min-width: 0; - overflow: hidden; - color: var(--v2-text-text-base); - font-size: 13px; - font-weight: 530; - line-height: 16px; - letter-spacing: -0.04px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.command-palette-v2-description, -.command-palette-v2-meta { - min-width: 0; - overflow: hidden; - color: var(--v2-text-text-muted); - font-size: 13px; - font-weight: 440; - line-height: 16px; - letter-spacing: -0.04px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.command-palette-v2-meta { - flex-shrink: 0; -} - -.command-palette-v2-file-path { - display: flex; - min-width: 0; - align-items: baseline; - font-size: 13px; - line-height: 16px; - letter-spacing: -0.04px; -} - -.command-palette-v2-file-dir { - min-width: 0; - overflow: hidden; - color: var(--v2-text-text-muted); - font-weight: 440; - text-overflow: ellipsis; - white-space: nowrap; -} - -.command-palette-v2-file-name { - flex-shrink: 0; - color: var(--v2-text-text-base); - font-weight: 530; - white-space: nowrap; -} - -.command-palette-v2-state { - display: grid; - min-height: 120px; - place-items: center; - color: var(--v2-text-text-muted); - font-size: 13px; - font-weight: 440; - line-height: 16px; - letter-spacing: -0.04px; -} - -@media (max-width: 640px) { - .command-palette-v2-row-text { - flex-direction: column; - align-items: flex-start; - gap: 1px; - } - - .command-palette-v2-description { - max-width: 100%; - } -} diff --git a/packages/app/src/components/dialog-command-palette-v2.tsx b/packages/app/src/components/dialog-command-palette-v2.tsx deleted file mode 100644 index e996fd0be7..0000000000 --- a/packages/app/src/components/dialog-command-palette-v2.tsx +++ /dev/null @@ -1,342 +0,0 @@ -import { getDirectory, getFilename } from "@opencode-ai/core/util/path" -import { FileIcon } from "@opencode-ai/ui/file-icon" -import { ScrollView } from "@opencode-ai/ui/scroll-view" -import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2" -import { Icon } from "@opencode-ai/ui/v2/icon" -import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" -import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js" -import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command" -import { useGlobal } from "@/context/global" -import { useLanguage } from "@/context/language" -import { ServerConnection } from "@/context/server" -import { useTabs } from "@/context/tabs" -import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" -import { getRelativeTime } from "@/utils/time" -import { - createCommandPaletteCommandEntry, - createCommandPaletteFileEntry, - createCommandPaletteModel, - createServerSessionEntries, - uniqueCommandPaletteEntries, - type CommandPaletteEntry, -} from "./command-palette" -import "./dialog-command-palette-v2.css" - -function groups(entries: CommandPaletteEntry[]) { - const map = new Map() - for (const entry of entries) map.set(entry.category, [...(map.get(entry.category) ?? []), entry]) - return Array.from(map.entries()).map(([category, entries]) => ({ category, entries })) -} - -function matchesEntry(entry: CommandPaletteEntry, query: string) { - const value = query.toLowerCase() - return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value)) -} - -export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) { - const palette = createCommandPaletteModel(props) - const loadItems = async (text: string) => { - const q = text.trim() - if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()] - - const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))]) - const category = palette.language.t("palette.group.files") - return [ - ...palette.commandEntries().filter((entry) => matchesEntry(entry, q)), - ...nextSessions, - ...files.map((path) => createCommandPaletteFileEntry(path, category)), - ] - } - - return ( - - ) -} - -export function DialogHomeCommandPaletteV2(props: { - server: ServerConnection.Any - onSelectSession: (entry: CommandPaletteEntry) => void -}) { - const command = useCommand() - const dialog = useDialog() - const global = useGlobal() - const language = useLanguage() - const serverCtx = global.ensureServerCtx(props.server) - const state = { cleanup: undefined as (() => void) | void, committed: false } - const commandEntries = createMemo(() => { - const category = language.t("palette.group.commands") - return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category)) - }) - const sessions = createServerSessionEntries({ - server: ServerConnection.key(props.server), - opened: serverCtx.projects.list, - stored: () => serverCtx.sync.data.project, - load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }), - untitled: () => language.t("command.session.new"), - category: () => language.t("command.category.session"), - }) - - const highlight = (item: CommandPaletteEntry | undefined) => { - state.cleanup?.() - state.cleanup = undefined - if (item?.type !== "command") return - state.cleanup = item.option?.onHighlight?.() - } - const select = (item: CommandPaletteEntry | undefined) => { - if (!item) return - state.committed = true - state.cleanup = undefined - dialog.close() - if (item.type === "command") { - item.option?.onSelect?.("palette") - return - } - if (item.type === "session") props.onSelectSession(item) - } - const loadItems = async (text: string) => { - const query = text.trim() - if (!query) return commandEntries().slice(0, 5) - return [...commandEntries().filter((entry) => matchesEntry(entry, query)), ...(await sessions(query))] - } - - onCleanup(() => { - if (state.committed) return - state.cleanup?.() - }) - - return ( - dialog.close()} - /> - ) -} - -function CommandPaletteView(props: { - placeholder: string - loadItems: (text: string) => CommandPaletteEntry[] | Promise - highlight: (item: CommandPaletteEntry | undefined) => void - select: (item: CommandPaletteEntry | undefined) => void - close: () => void -}) { - const language = useLanguage() - const tabs = useTabs() - const [query, setQuery] = createSignal("") - const [active, setActive] = createSignal(0) - - const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] }) - // Render stale results while a new query loads to avoid flashing "Loading" per keystroke. - const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? [])) - const groupedEntries = createMemo(() => groups(visibleEntries())) - const activeEntry = createMemo(() => visibleEntries()[active()]) - const openSessions = createMemo( - () => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))), - ) - - createEffect(() => { - query() - visibleEntries() - setActive(0) - }) - - createEffect(() => { - props.highlight(activeEntry()) - }) - - let resultsRef: HTMLDivElement | undefined - - const move = (delta: -1 | 1) => { - const count = visibleEntries().length - if (count === 0) return - setActive((index) => (index + delta + count) % count) - requestAnimationFrame(() => { - resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" }) - }) - } - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "ArrowDown") { - event.preventDefault() - move(1) - return - } - if (event.key === "ArrowUp") { - event.preventDefault() - move(-1) - return - } - if (event.key === "Enter") { - event.preventDefault() - props.select(activeEntry()) - return - } - if (event.key === "Escape") { - event.preventDefault() - props.close() - } - } - - return ( - - - - (resultsRef = el)}> -
- 0} - fallback={ -
- {entries.loading ? language.t("common.loading") : language.t("palette.empty")} -
- } - > - - {(group) => ( -
- -
{group.category}
-
- - {(item) => ( - setActive(visibleEntries().findIndex((entry) => entry.id === item.id))} - onSelect={() => props.select(item)} - /> - )} - -
- )} -
-
-
-
-
-
- ) -} - -function PaletteRow(props: { - item: CommandPaletteEntry - active: boolean - language: ReturnType - sessionOpen: boolean - onActive: () => void - onSelect: () => void -}) { - const session = () => - props.item.server && props.item.directory && props.item.sessionID - ? { server: props.item.server, directory: props.item.directory, sessionID: props.item.sessionID } - : undefined - - return ( -
- } - > - -
-
- {props.item.title} - - {props.item.description} - -
-
- - - -
- -
-
- - - - {(session) => ( - - )} - -
-
- - {props.item.title} - - - - {props.item.description} - - -
-
- - - {getRelativeTime(new Date(props.item.updated!).toISOString(), props.language.t)} - - -
- - - ) -} diff --git a/packages/app/src/components/dialog-connect-provider.stories.tsx b/packages/app/src/components/dialog-connect-provider.stories.tsx deleted file mode 100644 index 3aec6c9de3..0000000000 --- a/packages/app/src/components/dialog-connect-provider.stories.tsx +++ /dev/null @@ -1,73 +0,0 @@ -// @ts-nocheck -import { Button } from "@opencode-ai/ui/button" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" -import { mockProviderAuth } from "@/context/server-sync" -import { onCleanup, onMount } from "solid-js" -import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider" - -function ConnectProviderDialogStory() { - const dialog = useDialog() - const open = () => dialog.show(() => ) - - onMount(open) - - return ( - - ) -} - -function ProviderConnectionDialogStory(props) { - onCleanup(mockProviderAuth(props.provider, props.methods)) - const dialog = useDialog() - const controller = useProviderConnectController() - controller.select(props.provider) - const open = () => dialog.show(() => ) - - onMount(open) - - return ( - - ) -} - -function renderConnection(provider, methods) { - return () => ( - - - - ) -} - -export default { - title: "App/Dialogs/Connect Provider", - id: "app-dialog-connect-provider", -} - -export const V2 = { - render: () => ( - - - - ), -} - -export const ApiKey = { - render: renderConnection("openrouter", [{ type: "api", label: "API key" }]), -} - -export const OpenCodeZen = { - render: renderConnection("opencode", [{ type: "api", label: "API key" }]), -} - -export const LoginMethods = { - render: renderConnection("openai", [ - { type: "oauth", label: "ChatGPT Pro/Plus (browser)" }, - { type: "oauth", label: "ChatGPT Pro/Plus (headless)" }, - { type: "api", label: "API key" }, - ]), -} diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 0267b4b4ef..3a57769b5a 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -1,4 +1,4 @@ -import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise" +import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -7,392 +7,27 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { List, type ListRef } from "@opencode-ai/ui/list" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Spinner } from "@opencode-ai/ui/spinner" -import { Tag } from "@opencode-ai/ui/tag" import { TextField } from "@opencode-ai/ui/text-field" -import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" -import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" -import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { showToast } from "@/utils/toast" -import { - type Accessor, - type Component, - createEffect, - createMemo, - createResource, - createUniqueId, - For, - Match, - onCleanup, - onMount, - Show, - Switch, -} from "solid-js" +import { type Accessor, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" -import { useParams } from "@solidjs/router" -import { ExternalLink } from "@/components/external-link" +import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" -import { useSettings } from "@/context/settings" -import { popularProviders, useProviders } from "@/hooks/use-providers" -import { CustomProviderForm } from "./dialog-custom-provider" -import { decode64 } from "@/utils/base64" +import { useProviders } from "@/hooks/use-providers" -const CUSTOM_ID = "_custom" -type ConnectMethod = Extract - -export function useProviderConnectController(options: { onBack?: () => void } = {}) { - const [store, setStore] = createStore({ selected: undefined as string | undefined }) - const reset = () => setStore("selected", undefined) - - return { - selected: () => store.selected, - select: (provider?: string) => setStore("selected", provider), - back: options.onBack ?? reset, - } -} - -export const DialogConnectProvider: Component<{ - directory?: Accessor - controller?: ReturnType -}> = (props) => { - const fallback = useProviderConnectController() - const controller = props.controller ?? fallback - const language = useLanguage() - const settings = useSettings() - const newLayout = settings.general.newLayoutDesigns - const reset = controller.back - const back = { current: reset } - let focusHost: HTMLDivElement | undefined - const holdFocus = () => focusHost?.focus({ preventScroll: true }) - const select = (provider?: string) => { - back.current = reset - controller.select(provider) - } - - function Content() { - return ( - - - - - - {(provider) => ( - (back.current = handler)} - /> - )} - - - - - - ) - } - - return ( - - back.current()} - aria-label={language.t("common.goBack")} - /> - - } - > - - - } - > - - - {language.t("command.provider.connect")}} - > - - - - -
- -
-
-
- - ) -} - -function ProviderPicker(props: { - directory?: Accessor - onSelect: (provider: string) => void - onPrepare?: () => void -}) { - const settings = useSettings() - if (settings.general.newLayoutDesigns()) - return - const providers = useProviders(() => props.directory?.()) - const language = useLanguage() - const popularGroup = () => language.t("dialog.provider.group.popular") - const otherGroup = () => language.t("dialog.provider.group.other") - const customLabel = () => language.t("settings.providers.tag.custom") - const note = (id: string) => { - if (id === "anthropic") return language.t("dialog.provider.anthropic.note") - if (id === "openai") return language.t("dialog.provider.openai.note") - if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note") - if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline") - return undefined - } - - return ( - x?.id} - items={() => { - language.locale() - return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()] - }} - filterKeys={["id", "name"]} - groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} - sortBy={(a, b) => { - if (a.id === CUSTOM_ID) return -1 - if (b.id === CUSTOM_ID) return 1 - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - sortGroupsBy={(a, b) => { - const popular = popularGroup() - if (a.category === popular && b.category !== popular) return -1 - if (b.category === popular && a.category !== popular) return 1 - return 0 - }} - onSelect={(x) => { - if (!x) return - props.onSelect(x.id) - }} - > - {(i) => ( -
- - {i.name} - -
{language.t("dialog.provider.opencode.tagline")}
-
- - {language.t("settings.providers.tag.custom")} - - - {language.t("dialog.provider.tag.recommended")} - - {(value) =>
{value()}
}
- - {language.t("dialog.provider.tag.recommended")} - -
- )} -
- ) -} - -function ProviderPickerV2(props: { - directory?: Accessor - onSelect: (provider: string) => void - onPrepare?: () => void -}) { - const providers = useProviders(() => props.directory?.()) - const language = useLanguage() - const [store, setStore] = createStore({ - filter: "", - active: undefined as string | undefined, - connecting: undefined as string | undefined, - }) - const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"] - const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") }) - const all = createMemo(() => { - language.locale() - const query = store.filter.trim().toLowerCase() - const values = [custom(), ...providers.all().values()] - if (!query) return values - return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query)) - }) - const popular = createMemo(() => - all() - .filter((provider) => featured.includes(provider.id)) - .sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)), - ) - const other = createMemo(() => - all() - .filter((provider) => !featured.includes(provider.id)) - .sort((a, b) => { - if (a.id === CUSTOM_ID) return -1 - if (b.id === CUSTOM_ID) return 1 - return a.name.localeCompare(b.name) - }), - ) - const rows = createMemo(() => [...popular(), ...other()]) - let picker: HTMLDivElement | undefined - let search: HTMLInputElement | undefined - - onMount(() => search?.focus({ preventScroll: true })) - - const connect = (provider: string) => { - props.onPrepare?.() - props.onSelect(provider) - } - - const move = (event: KeyboardEvent, direction: number) => { - const items = rows() - if (items.length === 0) return - const index = items.findIndex((provider) => provider.id === store.active) - const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length - setStore("active", items[next].id) - picker - ?.querySelector(`[data-provider-id="${CSS.escape(items[next].id)}"]`) - ?.focus({ preventScroll: true }) - event.preventDefault() - } - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "ArrowDown") return move(event, 1) - if (event.key === "ArrowUp") return move(event, -1) - if (event.key !== "Enter" || !store.active) return - connect(store.active) - event.preventDefault() - } - - return ( -
-
- } - placeholder={language.t("dialog.provider.search.placeholder")} - value={store.filter} - onInput={(event) => { - setStore({ filter: event.currentTarget.value, active: undefined }) - }} - /> -
-
-
- - {(group) => ( - 0}> -
-
- {group.title} -
- - {(provider) => ( - - )} - -
-
- )} -
- -
- {language.t("dialog.provider.empty")} -
-
-
-
-
-
- ) -} - -function ProviderConnection(props: { - provider: string - directory?: Accessor - onBack: () => void - setBack: (handler: () => void) => void -}) { +export function DialogConnectProvider(props: { provider: string; directory?: Accessor }) { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() - const params = useParams() const language = useLanguage() - const settings = useSettings() - const newLayout = settings.general.newLayoutDesigns - const providers = useProviders(() => props.directory?.()) - const directory = () => props.directory?.() ?? decode64(params.dir) - const location = () => { - const value = directory() - return value ? { directory: value } : undefined + const providers = useProviders(props.directory) + + const all = () => { + void import("./dialog-select-provider").then((x) => { + dialog.show(() => ) + }) } const alive = { value: true } @@ -408,32 +43,28 @@ function ProviderConnection(props: { const provider = createMemo( () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, ) - const fallback = createMemo(() => [ + const fallback = createMemo(() => [ { - type: "key" as const, + type: "api" as const, label: language.t("provider.connect.method.apiKey"), }, ]) - const [integration] = createResource( - () => ({ provider: props.provider, directory: directory() }), - (input) => - serverSDK() - .api.integration.get({ - integrationID: input.provider, - location: input.directory ? { directory: input.directory } : undefined, - }) - .then((result) => result.data), + const [auth] = createResource( + () => props.provider, + async () => { + const cached = serverSync().data.provider_auth[props.provider] + if (cached) return cached + const res = await serverSDK().client.provider.auth() + if (!alive.value) return fallback() + serverSync().set("provider_auth", res.data ?? {}) + return res.data?.[props.provider] ?? fallback() + }, ) - const loading = createMemo(() => integration.loading) - const methods = createMemo(() => { - const values = integration.latest?.methods.filter( - (method): method is ConnectMethod => method.type === "key" || method.type === "oauth", - ) - return values?.length ? values : fallback() - }) + const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider]) + const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()) const [store, setStore] = createStore({ methodIndex: undefined as undefined | number, - authorization: undefined as undefined | IntegrationOauthConnectOutput["data"], + authorization: undefined as undefined | ProviderAuthAuthorization, promptInputs: undefined as undefined | Record, state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", error: undefined as string | undefined, @@ -445,7 +76,7 @@ function ProviderConnection(props: { | { type: "auth.prompt" } | { type: "auth.inputs"; inputs: Record } | { type: "auth.pending" } - | { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] } + | { type: "auth.complete"; authorization: ProviderAuthAuthorization } | { type: "auth.error"; error: string } function dispatch(action: Action) { @@ -499,20 +130,10 @@ function ProviderConnection(props: { const methodLabel = (value?: { type?: string; label?: string }) => { if (!value) return "" - if (value.type === "key") return language.t("provider.connect.method.apiKey") + if (value.type === "api") return language.t("provider.connect.method.apiKey") return value.label ?? "" } - const methodDetails = (value?: { type?: string; label?: string }) => { - const label = methodLabel(value) - const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i) - const hint = suffix?.[1] - return { - label: suffix ? label.slice(0, -suffix[0].length) : label, - hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined, - } - } - function formatError(value: unknown, fallback: string): string { if (value && typeof value === "object" && "data" in value) { const data = (value as { data?: { message?: unknown } }).data @@ -540,22 +161,46 @@ function ProviderConnection(props: { const method = methods()[index] dispatch({ type: "method.select", index }) + if (method.type === "api" && method.prompts?.length) { + if (!inputs) { + dispatch({ type: "auth.prompt" }) + return + } + dispatch({ type: "auth.inputs", inputs }) + return + } + if (method.type === "oauth") { if (method.prompts?.length && !inputs) { dispatch({ type: "auth.prompt" }) return } dispatch({ type: "auth.pending" }) + const start = Date.now() await serverSDK() - .api.integration.oauth.connect({ - integrationID: props.provider, - methodID: method.id, - inputs: inputs ?? {}, - location: location(), - }) + .client.provider.oauth.authorize( + { + providerID: props.provider, + method: index, + inputs, + }, + { throwOnError: true }, + ) .then((x) => { if (!alive.value) return - dispatch({ type: "auth.complete", authorization: x.data }) + const elapsed = Date.now() - start + const delay = 1000 - elapsed + + if (delay > 0) { + if (timer.current !== undefined) clearTimeout(timer.current) + timer.current = setTimeout(() => { + timer.current = undefined + if (!alive.value) return + dispatch({ type: "auth.complete", authorization: x.data! }) + }, delay) + return + } + dispatch({ type: "auth.complete", authorization: x.data! }) }) .catch((e) => { if (!alive.value) return @@ -570,9 +215,9 @@ function ProviderConnection(props: { index: 0, }) - const prompts = createMemo(() => { + const prompts = createMemo>(() => { const value = method() - return value?.type === "oauth" ? (value.prompts ?? []) : [] + return value?.prompts ?? [] }) const matches = (prompt: NonNullable[number]>, value: Record) => { if (!prompt.when) return true @@ -603,6 +248,10 @@ function ProviderConnection(props: { setFormStore("index", next) return } + if (method()?.type === "api") { + dispatch({ type: "auth.inputs", inputs: value }) + return + } await selectMethod(store.methodIndex, value) } @@ -704,9 +353,7 @@ function ProviderConnection(props: { }) async function complete() { - await serverSync() - .refreshProviders() - .catch(() => undefined) + await serverSDK().client.global.dispose() dialog.close() showToast({ variant: "success", @@ -717,47 +364,22 @@ function ProviderConnection(props: { } function goBack() { - if (methods().length > 1 && store.methodIndex !== undefined) { + if (methods().length === 1) { + all() + return + } + if (store.authorization) { dispatch({ type: "method.reset" }) return } - props.onBack() + if (store.methodIndex !== undefined) { + dispatch({ type: "method.reset" }) + return + } + all() } - props.setBack(goBack) - function MethodSelection() { - if (newLayout()) - return ( -
-
- {language.t("provider.connect.selectMethod", { provider: provider().name })} -
-
- - {(item, index) => { - const details = () => methodDetails(item) - return ( - - ) - }} - -
-
- ) - return ( <>
@@ -770,7 +392,7 @@ function ProviderConnection(props: { listRef = ref }} items={methods} - key={(m) => m?.label ?? m?.type} + key={(m) => m?.label} onSelect={async (selected, index) => { if (!selected) return void selectMethod(index) @@ -791,18 +413,11 @@ function ProviderConnection(props: { } function ApiAuthView() { - let apiKey: HTMLInputElement | undefined - const errorID = createUniqueId() const [formStore, setFormStore] = createStore({ value: "", error: undefined as string | undefined, }) - onMount(() => { - if (!newLayout()) return - apiKey?.focus({ preventScroll: true }) - }) - async function handleSubmit(e: SubmitEvent) { e.preventDefault() @@ -816,67 +431,17 @@ function ProviderConnection(props: { } setFormStore("error", undefined) - await serverSDK().api.integration.connect.key({ - integrationID: props.provider, - location: location(), - key: apiKey, + await serverSDK().client.auth.set({ + providerID: props.provider, + auth: { + type: "api", + key: apiKey, + ...(store.promptInputs ? { metadata: store.promptInputs } : {}), + }, }) await complete() } - if (newLayout()) - return ( -
- -
-
{language.t("provider.connect.opencodeZen.line1")}
-
{language.t("provider.connect.opencodeZen.line2")}
-
- {language.t("provider.connect.opencodeZen.visit.prefix")} - - {language.t("provider.connect.opencodeZen.visit.link")} - - {language.t("provider.connect.opencodeZen.visit.suffix")} -
-
-
-
- - - {(error) => ( - - )} - - - {language.t("common.continue")} - -
-
- ) - return (
@@ -886,9 +451,9 @@ function ProviderConnection(props: {
{language.t("provider.connect.opencodeZen.line2")}
{language.t("provider.connect.opencodeZen.visit.prefix")} - + {language.t("provider.connect.opencodeZen.visit.link")} - + {language.t("provider.connect.opencodeZen.visit.suffix")}
@@ -901,8 +466,7 @@ function ProviderConnection(props: {
{ - if (!newLayout()) return - codeInput?.focus({ preventScroll: true }) - }) - async function handleSubmit(e: SubmitEvent) { e.preventDefault() @@ -947,13 +504,12 @@ function ProviderConnection(props: { setFormStore("error", undefined) const result = await serverSDK() - .api.integration.oauth.complete({ - integrationID: props.provider, - attemptID: store.authorization!.attemptID, - location: location(), + .client.provider.oauth.callback({ + providerID: props.provider, + method: store.methodIndex, code, }) - .then(() => ({ ok: true as const })) + .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) .catch((error) => ({ ok: false as const, error })) if (result.ok) { await complete() @@ -962,59 +518,16 @@ function ProviderConnection(props: { setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid"))) } - if (newLayout()) - return ( -
-
- {language.t("provider.connect.oauth.code.visit.prefix")} - - {language.t("provider.connect.oauth.code.visit.link")} - - {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} -
- - - - {(error) => ( - - )} - - - {language.t("common.continue")} - - -
- ) - return (
{language.t("provider.connect.oauth.code.visit.prefix")} - - {language.t("provider.connect.oauth.code.visit.link")} - + {language.t("provider.connect.oauth.code.visit.link")} {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
{ - const poll = async () => { - const authorization = store.authorization - if (!authorization || !alive.value) return + void (async () => { const result = await serverSDK() - .api.integration.oauth.status({ - integrationID: props.provider, - attemptID: authorization.attemptID, - location: location(), + .client.provider.oauth.callback({ + providerID: props.provider, + method: store.methodIndex, }) - .then((value) => ({ ok: true as const, status: value.data })) + .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) .catch((error) => ({ ok: false as const, error })) + if (!alive.value) return + if (!result.ok) { - dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) }) + const message = formatError(result.error, language.t("common.requestFailed")) + dispatch({ type: "auth.error", error: message }) return } - if (result.status.status === "complete") { - await complete() - return - } - if (result.status.status === "failed") { - dispatch({ type: "auth.error", error: result.status.message }) - return - } - if (result.status.status === "expired") { - dispatch({ type: "auth.error", error: language.t("common.requestFailed") }) - return - } - timer.current = setTimeout(poll, 1_000) - } - void poll() + + await complete() + })() }) return (
{language.t("provider.connect.oauth.auto.visit.prefix")} - - {language.t("provider.connect.oauth.auto.visit.link")} - + {language.t("provider.connect.oauth.auto.visit.link")} {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
-
- -
- - - {language.t("provider.connect.title.anthropicProMax")} - - {language.t("provider.connect.title", { provider: provider().name })} - + } + > +
+
+ +
+ + + {language.t("provider.connect.title.anthropicProMax")} + + {language.t("provider.connect.title", { provider: provider().name })} + +
+
+
+
+ + +
+
+ + {language.t("provider.connect.status.inProgress")} +
+
+
+ + + + +
+
+ + {language.t("provider.connect.status.inProgress")} +
+
+
+ + + + +
+
+ + {language.t("provider.connect.status.failed", { error: store.error ?? "" })} +
+
+
+ + + + + + + + + + + + + +
+
-
-
- - -
-
- - {language.t("provider.connect.status.inProgress")} -
-
-
- - - - -
-
- - {language.t("provider.connect.status.inProgress")} -
-
-
- - - - -
-
- - {language.t("provider.connect.status.failed", { error: store.error ?? "" })} -
-
-
- - - - - - - - - - - - - -
-
-
-
+ ) } diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index e34e4c39b8..647e5002a2 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -6,41 +6,21 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { useMutation } from "@tanstack/solid-query" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@/utils/toast" -import { batch, For } from "solid-js" +import { type Accessor, batch, For } from "solid-js" import { createStore, produce } from "solid-js/store" -import { ExternalLink } from "@/components/external-link" +import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form" +import { DialogSelectProvider } from "./dialog-select-provider" type Props = { - onBack: () => void + back?: "providers" | "close" + directory?: Accessor } export function DialogCustomProvider(props: Props) { - const language = useLanguage() - - return ( - - } - transition - > - - - ) -} - -export function CustomProviderForm(props: { autofocus?: boolean } = {}) { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() @@ -56,6 +36,14 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) { err: {}, }) + const goBack = () => { + if (props.back === "close") { + dialog.close() + return + } + dialog.show(() => ) + } + const addModel = () => { setForm( "models", @@ -131,7 +119,6 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) { const saveMutation = useMutation(() => ({ mutationFn: async (result: NonNullable>) => { - if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server") const disabledProviders = serverSync().data.config.disabled_providers ?? [] const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) @@ -176,155 +163,168 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) { } return ( -
-
- -
{language.t("provider.custom.title")}
+ + } + transition + > +
+
+ +
{language.t("provider.custom.title")}
+
+ + +

+ {language.t("provider.custom.description.prefix")} + + {language.t("provider.custom.description.link")} + + {language.t("provider.custom.description.suffix")} +

+ +
+ setField("providerID", v)} + validationState={form.err.providerID ? "invalid" : undefined} + error={form.err.providerID} + /> + setField("name", v)} + validationState={form.err.name ? "invalid" : undefined} + error={form.err.name} + /> + setField("baseURL", v)} + validationState={form.err.baseURL ? "invalid" : undefined} + error={form.err.baseURL} + /> + setField("apiKey", v)} + /> +
+ +
+ + + {(m, i) => ( +
+
+ setModel(i(), "id", v)} + validationState={m.err.id ? "invalid" : undefined} + error={m.err.id} + /> +
+
+ setModel(i(), "name", v)} + validationState={m.err.name ? "invalid" : undefined} + error={m.err.name} + /> +
+ removeModel(i())} + disabled={form.models.length <= 1} + aria-label={language.t("provider.custom.models.remove")} + /> +
+ )} +
+ +
+ +
+ + + {(h, i) => ( +
+
+ setHeader(i(), "key", v)} + validationState={h.err.key ? "invalid" : undefined} + error={h.err.key} + /> +
+
+ setHeader(i(), "value", v)} + validationState={h.err.value ? "invalid" : undefined} + error={h.err.value} + /> +
+ removeHeader(i())} + disabled={form.headers.length <= 1} + aria-label={language.t("provider.custom.headers.remove")} + /> +
+ )} +
+ +
+ + +
- -
-

- {language.t("provider.custom.description.prefix")} - - {language.t("provider.custom.description.link")} - - {language.t("provider.custom.description.suffix")} -

- -
- setField("providerID", v)} - validationState={form.err.providerID ? "invalid" : undefined} - error={form.err.providerID} - /> - setField("name", v)} - validationState={form.err.name ? "invalid" : undefined} - error={form.err.name} - /> - setField("baseURL", v)} - validationState={form.err.baseURL ? "invalid" : undefined} - error={form.err.baseURL} - /> - setField("apiKey", v)} - /> -
- -
- - - {(m, i) => ( -
-
- setModel(i(), "id", v)} - validationState={m.err.id ? "invalid" : undefined} - error={m.err.id} - /> -
-
- setModel(i(), "name", v)} - validationState={m.err.name ? "invalid" : undefined} - error={m.err.name} - /> -
- removeModel(i())} - disabled={form.models.length <= 1} - aria-label={language.t("provider.custom.models.remove")} - /> -
- )} -
- -
- -
- - - {(h, i) => ( -
-
- setHeader(i(), "key", v)} - validationState={h.err.key ? "invalid" : undefined} - error={h.err.key} - /> -
-
- setHeader(i(), "value", v)} - validationState={h.err.value ? "invalid" : undefined} - error={h.err.value} - /> -
- removeHeader(i())} - disabled={form.headers.length <= 1} - aria-label={language.t("provider.custom.headers.remove")} - /> -
- )} -
- -
- - -
-
+ ) } diff --git a/packages/app/src/components/dialog-edit-project-v2.tsx b/packages/app/src/components/dialog-edit-project-v2.tsx deleted file mode 100644 index dc9714aa06..0000000000 --- a/packages/app/src/components/dialog-edit-project-v2.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" -import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2" -import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" -import { Field } from "@opencode-ai/ui/v2/field-v2" -import { Icon } from "@opencode-ai/ui/v2/icon" -import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2" -import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2" -import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" -import { For, Show } from "solid-js" -import { useLanguage } from "@/context/language" -import { getProjectAvatarVariant, type LocalProject } from "@/context/layout" -import { ServerConnection } from "@/context/server" -import { getProjectAvatarSource } from "@/pages/layout/helpers" -import { createEditProjectModel } from "./edit-project" - -export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) { - const language = useLanguage() - const model = createEditProjectModel(props) - - return ( - -
- - {language.t("dialog.project.edit.title")} - - - - - {language.t("dialog.project.edit.name")} - model.setStore("name", event.currentTarget.value)} - /> - - -
-
- {language.t("dialog.project.edit.icon")} -
-
- - { - model.setIconInput(element) - }} - type="file" - accept="image/*" - class="hidden" - onChange={model.inputChange} - /> -
- {language.t("dialog.project.edit.icon.hint")} - {language.t("dialog.project.edit.icon.recommended")} -
-
-
- - -
-
- {language.t("dialog.project.edit.color")} -
-
- - {(color) => ( - - )} - -
-
-
- - - {language.t("dialog.project.edit.worktree.startup")} - {language.t("dialog.project.edit.worktree.startup.description")} - model.setStore("startup", event.currentTarget.value)} - /> - -
- - - {language.t("common.cancel")} - - - {model.save.isPending ? language.t("common.saving") : language.t("common.save")} - - - -
- ) -} diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 86a9630359..b861492af5 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -1,32 +1,123 @@ import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { TextField } from "@opencode-ai/ui/text-field" +import { useMutation } from "@tanstack/solid-query" import { Icon } from "@opencode-ai/ui/icon" -import { For, Show } from "solid-js" +import { createMemo, For, Show } from "solid-js" +import { createStore } from "solid-js/store" import { type LocalProject, getAvatarColors } from "@/context/layout" +import { getFilename } from "@opencode-ai/core/util/path" import { Avatar } from "@opencode-ai/ui/avatar" import { useLanguage } from "@/context/language" import { getProjectAvatarSource } from "@/pages/layout/helpers" import { ServerConnection } from "@/context/server" -import { createEditProjectModel } from "./edit-project" +import { useGlobal } from "@/context/global" const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) { + const dialog = useDialog() + const global = useGlobal() const language = useLanguage() - const model = createEditProjectModel(props) + const serverCtx = createMemo(() => global.ensureServerCtx(props.server)) + const serverSDK = () => serverCtx().sdk + const serverSync = () => serverCtx().sync + + const folderName = createMemo(() => getFilename(props.project.worktree)) + const defaultName = createMemo(() => props.project.name || folderName()) + + const [store, setStore] = createStore({ + name: defaultName(), + color: props.project.icon?.color, + iconOverride: props.project.icon?.override, + startup: props.project.commands?.start ?? "", + dragOver: false, + iconHover: false, + }) + + let iconInput: HTMLInputElement | undefined + + function handleFileSelect(file: File) { + if (!file.type.startsWith("image/")) return + const reader = new FileReader() + reader.onload = (e) => { + setStore("iconOverride", e.target?.result as string) + setStore("iconHover", false) + } + reader.readAsDataURL(file) + } + + function handleDrop(e: DragEvent) { + e.preventDefault() + setStore("dragOver", false) + const file = e.dataTransfer?.files[0] + if (file) handleFileSelect(file) + } + + function handleDragOver(e: DragEvent) { + e.preventDefault() + setStore("dragOver", true) + } + + function handleDragLeave() { + setStore("dragOver", false) + } + + function handleInputChange(e: Event) { + const input = e.target as HTMLInputElement + const file = input.files?.[0] + if (file) handleFileSelect(file) + } + + function clearIcon() { + setStore("iconOverride", "") + } + + const saveMutation = useMutation(() => ({ + mutationFn: async () => { + const name = store.name.trim() === folderName() ? "" : store.name.trim() + const start = store.startup.trim() + + if (props.project.id && props.project.id !== "global") { + await serverSDK().client.project.update({ + projectID: props.project.id, + directory: props.project.worktree, + name, + icon: { color: store.color || "", override: store.iconOverride || "" }, + commands: { start }, + }) + serverSync().project.icon(props.project.worktree, store.iconOverride || undefined) + dialog.close() + return + } + + serverSync().project.meta(props.project.worktree, { + name, + icon: { color: store.color || undefined, override: store.iconOverride || undefined }, + commands: { start: start || undefined }, + }) + dialog.close() + }, + })) + + function handleSubmit(e: SubmitEvent) { + e.preventDefault() + if (saveMutation.isPending) return + saveMutation.mutate() + } return ( -
+
model.setStore("name", v)} + placeholder={folderName()} + value={store.name} + onChange={(v) => setStore("name", v)} />
@@ -34,32 +125,38 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
model.setStore("iconHover", true)} - onMouseLeave={() => model.setStore("iconHover", false)} + onMouseEnter={() => setStore("iconHover", true)} + onMouseLeave={() => setStore("iconHover", false)} >
{ + if (store.iconOverride && store.iconHover) { + clearIcon() + } else { + iconInput?.click() + } }} - onDrop={model.drop} - onDragOver={model.dragOver} - onDragLeave={model.dragLeave} - onClick={model.iconClick} >
@@ -77,8 +174,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
@@ -86,8 +183,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
@@ -96,12 +193,12 @@ export function DialogEditProject(props: { project: LocalProject; server: Server { - model.setIconInput(el) + iconInput = el }} type="file" accept="image/*" class="hidden" - onChange={model.inputChange} + onChange={handleInputChange} />
{language.t("dialog.project.edit.icon.hint")} @@ -110,7 +207,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
- +
@@ -119,21 +216,21 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
- -
diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 5187d980ea..601f03084c 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -69,11 +69,15 @@ export const DialogFork: Component = () => { const dir = base64Encode(sdk().directory) sdk() - .api.session.fork({ sessionID, messageID: item.id }) + .client.session.fork({ sessionID, messageID: item.id }) .then((forked) => { + if (!forked.data) { + showToast({ title: language.t("common.requestFailed") }) + return + } dialog.close() - prompt.set(restored, undefined, { dir, id: forked.id }) - navigate(`/${dir}/session/${forked.id}`) + prompt.set(restored, undefined, { dir, id: forked.data.id }) + navigate(`/${dir}/session/${forked.data.id}`) }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index d6c5e99186..0238f543c4 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -16,7 +16,7 @@ import { useLocal } from "@/context/local" import { popularProviders } from "@/hooks/use-providers" import { useLanguage } from "@/context/language" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { DialogConnectProvider } from "./dialog-connect-provider" +import { DialogSelectProvider } from "./dialog-select-provider" import { decode64 } from "@/utils/base64" import { SettingsListV2 } from "./settings-v2/parts/list" import { SettingsRowV2 } from "./settings-v2/parts/row" @@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => { const directory = () => decode64(local.slug()) const handleConnectProvider = () => { - void dialog.show(() => ) + dialog.show(() => ) } const providerRank = (id: string) => popularProviders.indexOf(id) const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) @@ -123,7 +123,7 @@ export const DialogManageModelsV2: Component = () => { const directory = () => decode64(local.slug()) const handleConnectProvider = () => { - void dialog.show(() => ) + dialog.show(() => ) } const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) const providerVisible = (providerID: string) => diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx index f3376ad35f..69d46ddcb6 100644 --- a/packages/app/src/components/dialog-select-directory-v2.tsx +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -8,7 +8,6 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup, import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" import { ServerConnection } from "@/context/server" -import type { Path } from "@opencode-ai/sdk/v2/client" import { absoluteTreePath, activeTreeNavigation, @@ -29,7 +28,6 @@ import { } from "./directory-picker-domain" import "./dialog-select-directory-v2.css" import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" -import { getFilename } from "@opencode-ai/core/util/path" interface DialogSelectDirectoryV2Props { title?: string @@ -69,13 +67,11 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) const [fallbackPath] = createResource( () => (missingBase() ? true : undefined), - async (): Promise => { - if ((await sdk.protocol) !== "v1") return - return sdk.client.path + () => + sdk.client.path .get() .then((result) => result.data) - .catch(() => undefined) - }, + .catch(() => undefined), { initialValue: undefined }, ) const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") @@ -89,26 +85,18 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { ) const search = createDirectorySearch({ sdk, home, base: () => root() || start() }) const [suggestions] = createResource(input, async (value) => { - const cleaned = cleanPickerInput(value) - const typed = cleaned.replace(/\/+$/, "") + const typed = cleanPickerInput(value).replace(/\/+$/, "") const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "") - if (!cleaned || (root() && typed === current)) return { query: value, items: [] } + if (!typed || typed === current) return { query: value, items: [] } const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const })) if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) } - const base = pickerRoot(cleaned) || root() || start() - if (!base) return { query: value, items: directories.slice(0, 5) } - const files = await sdk.api.file - .find({ - location: { directory: base }, - query: pickerFileSearchQuery(base, value, home()), - type: "file", - limit: 20, - }) - .then((result) => result.data) + const files = await sdk.client.find + .files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 }) + .then((result) => result.data ?? []) .catch(() => []) const results = [ ...directories, - ...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })), + ...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })), ] return { query: value, @@ -127,14 +115,9 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { existing ?? loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => { if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined) - return sdk.api.file - .list({ location: { directory: absolute } }) - .then((result) => - result.data.map((entry) => ({ - name: getFilename(entry.path.replace(/[\\/]+$/, "")), - type: entry.type, - })), - ) + return sdk.client.file + .list({ directory: absolute, path: "" }) + .then((result) => result.data ?? []) .catch(() => undefined) }) listings.set(key, request) @@ -329,7 +312,6 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { {(suggestion, index) => ( - ) -} - -export default { - title: "App/Dialogs/Select Model", - id: "app-dialog-select-model", -} - -export const WithoutProviders = { - render: () => , -} diff --git a/packages/app/src/components/dialog-select-model-unpaid-v2.tsx b/packages/app/src/components/dialog-select-model-unpaid-v2.tsx deleted file mode 100644 index 2d0c3a7305..0000000000 --- a/packages/app/src/components/dialog-select-model-unpaid-v2.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" -import { Icon } from "@opencode-ai/ui/v2/icon" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { Tag } from "@opencode-ai/ui/v2/badge-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useTheme } from "@opencode-ai/ui/theme" -import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js" -import { useLocal } from "@/context/local" -import { useProviders } from "@/hooks/use-providers" -import { decode64 } from "@/utils/base64" -import { useLanguage } from "@/context/language" -import { ModelTooltip } from "./model-tooltip" - -type ModelState = ReturnType["model"] -const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"] -const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "") - -export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => { - const local = useLocal() - const model = props.model ?? local.model - const dialog = useDialog() - const theme = useTheme() - const directory = () => decode64(local.slug()) - const providers = useProviders(directory) - const language = useLanguage() - const modelKey = (item: ReturnType[number]) => `${item.provider.id}:${item.id}` - const currentKey = createMemo(() => { - const c = model.current() - return c ? `${c.provider.id}:${c.id}` : undefined - }) - const isFree = (item: ReturnType[number]) => - item.provider.id === "opencode" && (!item.cost || item.cost.input === 0) - const freeModels = createMemo(() => model.list().filter(isFree)) - - const openProviders = (provider?: string) => { - void import("./dialog-connect-provider").then((x) => { - const controller = x.useProviderConnectController() - controller.select(provider) - void dialog.show(() => ) - }) - } - - const selectModel = (item: ReturnType[number]) => { - model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true }) - dialog.close() - } - - // Focus starts on the dialog's close button, outside the list, so listen at the - // document level while the dialog is mounted instead of on the list container. - let listEl: HTMLDivElement | undefined - onMount(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return - if (!listEl) return - const buttons = Array.from(listEl.querySelectorAll("button")) - if (buttons.length === 0) return - const index = buttons.indexOf(document.activeElement as HTMLButtonElement) - const next = - index < 0 ? (e.key === "ArrowDown" ? 0 : buttons.length - 1) : index + (e.key === "ArrowDown" ? 1 : -1) - buttons[(next + buttons.length) % buttons.length]?.focus() - e.preventDefault() - } - document.addEventListener("keydown", handleKeyDown) - onCleanup(() => document.removeEventListener("keydown", handleKeyDown)) - }) - - return ( - - - {language.t("dialog.model.select.title")} - - -
-
-
-
- {language.t("dialog.model.unpaid.freeModels.title")} -
-
- - {(item) => ( - - } - > - - - )} - -
- -
-
-
-
- {language.t("dialog.model.unpaid.addMore.title")} -
-
-
- featuredProviders.includes(provider.id)) - .sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))} - > - {(provider) => ( - - )} - - -
-
-
-
-
-
- ) -} diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index 4611a36c95..bcb4a2fbcb 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -22,16 +22,17 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props const providers = useProviders(directory) const language = useLanguage() - const openProviders = (provider?: string) => { + const connect = (provider: string) => { void import("./dialog-connect-provider").then((x) => { - const controller = x.useProviderConnectController() - controller.select(provider) - void dialog.show(() => ) + dialog.show(() => ) }) } - const connect = (provider: string) => openProviders(provider) - const all = () => openProviders() + const all = () => { + void import("./dialog-select-provider").then((x) => { + dialog.show(() => ) + }) + } let listRef: ListRef | undefined const handleKeyDown = (e: KeyboardEvent) => { diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 9066f72434..f3dd3ce20d 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -1,5 +1,5 @@ import { Popover as Kobalte } from "@kobalte/core/popover" -import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js" +import { Component, ComponentProps, createMemo, For, JSX, Show, ValidComponent } from "solid-js" import { createStore } from "solid-js/store" import { useLocal } from "@/context/local" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -14,14 +14,9 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { Icon } from "@opencode-ai/ui/v2/icon" import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { ModelTooltip } from "./model-tooltip" import { useLanguage } from "@/context/language" import { decode64 } from "@/utils/base64" -import { handleDocumentSearchKeydown } from "@/utils/search-keydown" -import { createMenuDismissController } from "@/utils/menu-dismiss-controller" -import { createEventListener } from "@solid-primitives/event-listener" -import { matchesModelSearch } from "./dialog-select-model-search" const isFree = (provider: string, cost: { input: number } | undefined) => provider === "opencode" && (!cost || cost.input === 0) @@ -113,13 +108,14 @@ const ModelList: Component<{ } type ModelSelectorTriggerProps = Omit, "as" | "ref"> -type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element type Dismiss = "escape" | "outside" | "select" | "manage" | "provider" export function ModelSelectorPopover(props: { provider?: string model?: ModelState - trigger: ModelSelectorTrigger + children?: JSX.Element + triggerAs?: ValidComponent + triggerProps?: ModelSelectorTriggerProps onClose?: (cause: "escape" | "select") => void }) { const [store, setStore] = createStore<{ @@ -147,8 +143,8 @@ export function ModelSelectorPopover(props: { const handleConnectProvider = () => { close("provider") - void import("./dialog-connect-provider").then((x) => { - void dialog.show(() => ) + void import("./dialog-select-provider").then((x) => { + dialog.show(() => ) }) } const language = useLanguage() @@ -164,7 +160,9 @@ export function ModelSelectorPopover(props: { placement="top-start" gutter={4} > - + + {props.children} + void }) { + const model = props.model ?? useLocal().model + const language = useLanguage() const dialog = useDialog() - const controller = createModelSelectorController({ - model: props.model, - provider: () => props.provider, - onSelect: () => props.onClose?.(), - }) + const [store, setStore] = createStore({ open: false, search: "", active: "" }) + let searchRef: HTMLInputElement | undefined + let contentRef: HTMLDivElement | undefined + let restoreTrigger = true - return ( - { - void import("./dialog-manage-models").then((module) => { - void dialog.show(() => ) - }) - }} - onClose={() => props.onClose?.()} - /> - ) -} - -function createModelSelectorController(input: { - provider: () => string | undefined - model?: ModelState - onSelect: () => void -}) { - const model = input.model ?? useLocal().model const allModels = createMemo(() => model .list() .filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id })) - .filter((item) => (input.provider() ? item.provider.id === input.provider() : true)), + .filter((item) => (props.provider ? item.provider.id === props.provider : true)), ) + const models = createMemo(() => { + const search = store.search.trim().toLowerCase() + const filtered = search + ? allModels().filter( + (item) => + item.name.toLowerCase().includes(search) || + item.id.toLowerCase().includes(search) || + item.provider.name.toLowerCase().includes(search), + ) + : allModels() - return { - models: (search: string) => { - const query = search.trim() - const filtered = query - ? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name])) - : allModels() - return [...filtered].sort((a, b) => a.name.localeCompare(b.name)) - }, - groups: (models: ModelItem[]) => { - const byProvider = new Map() - for (const item of models) { - byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item]) - } - return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups) - }, - current: () => { - const value = model.current() - return value ? modelKey(value) : undefined - }, - select: (item: ModelItem) => { - model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true }) - input.onSelect() - }, - } -} - -function ModelSelectorPopoverV2View(props: { - trigger: ModelSelectorTrigger - models: (search: string) => ModelItem[] - groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[] - current: () => string | undefined - select: (item: ModelItem) => void - onManage: () => void - onClose: () => void -}) { - const language = useLanguage() - const [store, setStore] = createStore({ open: false, search: "", active: "" }) - let searchRef: HTMLInputElement | undefined - let contentRef: HTMLDivElement | undefined - const dismiss = createMenuDismissController(() => contentRef) - - const models = createMemo(() => props.models(store.search)) - const groups = createMemo(() => props.groups(models())) + return [...filtered].sort((a, b) => a.name.localeCompare(b.name)) + }) + const groups = createMemo(() => { + const byProvider = new Map() + for (const item of models()) { + byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item]) + } + return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups) + }) const keys = () => [...models().map(modelKey), manageKey] + const current = () => { + const value = model.current() + return value ? `${value.provider.id}:${value.id}` : undefined + } const initialActive = () => { - const selected = props.current() + const selected = current() const options = keys() if (selected && options.includes(selected)) return selected return options[0] ?? "" } const activeItem = () => store.active ? contentRef?.querySelector(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined + const afterClose = (callback: () => void) => { + const complete = () => { + if (contentRef?.isConnected) { + requestAnimationFrame(complete) + return + } + requestAnimationFrame(() => requestAnimationFrame(callback)) + } + requestAnimationFrame(complete) + } const setOpen = (open: boolean) => { if (open) { - dismiss.allowTriggerRestore() + restoreTrigger = true setStore({ open: true, active: initialActive() }) setTimeout(() => requestAnimationFrame(() => { @@ -331,15 +299,23 @@ function ModelSelectorPopoverV2View(props: { } setStore({ open: false, search: "", active: "" }) } + const select = (item: ModelItem) => { + model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true }) + props.onClose?.() + } const selectModel = (item: ModelItem) => { - dismiss.preventTriggerRestore() + restoreTrigger = false setOpen(false) - dismiss.afterClose(() => props.select(item)) + afterClose(() => select(item)) } const manage = () => { - dismiss.preventTriggerRestore() + restoreTrigger = false setOpen(false) - dismiss.afterClose(props.onManage) + afterClose(() => { + void import("./dialog-manage-models").then((x) => { + dialog.show(() => ) + }) + }) } const selectActive = () => { const item = models().find((item) => modelKey(item) === store.active) @@ -358,30 +334,33 @@ function ModelSelectorPopoverV2View(props: { queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" })) } const setSearch = (value: string) => { - const first = props.models(value)[0] + const search = value.trim().toLowerCase() + const first = [...allModels()] + .sort((a, b) => a.name.localeCompare(b.name)) + .find( + (item) => + !search || + item.name.toLowerCase().includes(search) || + item.id.toLowerCase().includes(search) || + item.provider.name.toLowerCase().includes(search), + ) setStore({ search: value, active: first ? modelKey(first) : manageKey }) } - createEffect(() => { - if (!store.open) return - createEventListener( - document, - "keydown", - (event: KeyboardEvent) => handleDocumentSearchKeydown(searchRef, event, store.search, setSearch), - true, - ) - }) - return ( - + + {props.children} + (contentRef = element)} + ref={(el: HTMLDivElement) => (contentRef = el)} class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none" - onPointerDownOutside={dismiss.preventTriggerRestore} - onFocusOutside={dismiss.preventTriggerRestore} - onCloseAutoFocus={dismiss.onCloseAutoFocus} + onPointerDownOutside={() => (restoreTrigger = false)} + onFocusOutside={() => (restoreTrigger = false)} + onCloseAutoFocus={(event) => { + if (!restoreTrigger) event.preventDefault() + }} >
@@ -401,9 +380,9 @@ function ModelSelectorPopoverV2View(props: { event.stopPropagation() if (event.key === "Escape") { event.preventDefault() - dismiss.preventTriggerRestore() + restoreTrigger = false setOpen(false) - dismiss.afterClose(props.onClose) + afterClose(() => props.onClose?.()) return } if (event.altKey || event.metaKey) return @@ -453,44 +432,29 @@ function ModelSelectorPopoverV2View(props: { {group.items[0].provider.name} - + {(item) => ( - - } + { + setStore("active", modelKey(item)) + setTimeout(() => searchRef?.focus()) + }} + onSelect={() => selectModel(item)} > - { - setStore("active", modelKey(item)) - setTimeout(() => searchRef?.focus()) - }} - onSelect={() => selectModel(item)} - > - {item.name} - - {language.t("model.tag.free")} - - - {language.t("model.tag.latest")} - - - + {item.name} + + {language.t("model.tag.free")} + + + {language.t("model.tag.latest")} + + )} @@ -528,8 +492,8 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat const directory = () => decode64(local.slug()) const provider = () => { - void import("./dialog-connect-provider").then((x) => { - void dialog.show(() => ) + void import("./dialog-select-provider").then((x) => { + dialog.show(() => ) }) } diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx new file mode 100644 index 0000000000..ff0ab5d2bf --- /dev/null +++ b/packages/app/src/components/dialog-select-provider.tsx @@ -0,0 +1,87 @@ +import { type Accessor, Component, Show } from "solid-js" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { Tag } from "@opencode-ai/ui/tag" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { DialogConnectProvider } from "./dialog-connect-provider" +import { useLanguage } from "@/context/language" +import { DialogCustomProvider } from "./dialog-custom-provider" + +const CUSTOM_ID = "_custom" + +export const DialogSelectProvider: Component<{ directory?: Accessor }> = (props) => { + const dialog = useDialog() + const providers = useProviders(props.directory) + const language = useLanguage() + + const popularGroup = () => language.t("dialog.provider.group.popular") + const otherGroup = () => language.t("dialog.provider.group.other") + const customLabel = () => language.t("settings.providers.tag.custom") + const note = (id: string) => { + if (id === "anthropic") return language.t("dialog.provider.anthropic.note") + if (id === "openai") return language.t("dialog.provider.openai.note") + if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note") + if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline") + } + + return ( + + x?.id} + items={() => { + language.locale() + return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()] + }} + filterKeys={["id", "name"]} + groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} + sortBy={(a, b) => { + if (a.id === CUSTOM_ID) return -1 + if (b.id === CUSTOM_ID) return 1 + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + sortGroupsBy={(a, b) => { + const popular = popularGroup() + if (a.category === popular && b.category !== popular) return -1 + if (b.category === popular && a.category !== popular) return 1 + return 0 + }} + onSelect={(x) => { + if (!x) return + if (x.id === CUSTOM_ID) { + dialog.show(() => ) + return + } + dialog.show(() => ) + }} + > + {(i) => ( +
+ + {i.name} + +
{language.t("dialog.provider.opencode.tagline")}
+
+ + {language.t("settings.providers.tag.custom")} + + + {language.t("dialog.provider.tag.recommended")} + + {(value) =>
{value()}
}
+ + {language.t("dialog.provider.tag.recommended")} + +
+ )} +
+
+ ) +} diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index aa16976228..0876906890 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -16,7 +16,6 @@ import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" -import { detectServerProtocol } from "@/utils/server-protocol" import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" import { useSettings } from "@/context/settings" import { useTabs } from "@/context/tabs" @@ -264,13 +263,6 @@ export function useServerManagementController(options: { onSelect?: () => void; setStore("addServer", { error: language.t("dialog.server.add.error") }) return } - if ( - !settings.general.newLayoutDesigns() && - (await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2" - ) { - setStore("addServer", { error: language.t("dialog.server.add.error") }) - return - } resetAdd() if (options.navigateOnAdd === false) { @@ -315,13 +307,6 @@ export function useServerManagementController(options: { onSelect?: () => void; setStore("editServer", { error: language.t("dialog.server.add.error") }) return } - if ( - !settings.general.newLayoutDesigns() && - (await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2" - ) { - setStore("editServer", { error: language.t("dialog.server.add.error") }) - return - } if (normalized === input.original.http.url) { server.add(conn) } else { @@ -359,10 +344,7 @@ export function useServerManagementController(options: { onSelect?: () => void; ) const sortedItems = createMemo(() => { - const raw = items() - const list = settings.general.newLayoutDesigns() - ? raw - : raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2") + const list = items() if (!list.length) return list const active = current() const order = new Map(list.map((url, index) => [url, index] as const)) diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index b554fa79a8..20d71f4bfd 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -1,35 +1,22 @@ -import { Component, createSignal, startTransition } from "solid-js" +import { Component } from "solid-js" import { Dialog } from "@opencode-ai/ui/dialog" import { Tabs } from "@opencode-ai/ui/tabs" import { Icon } from "@opencode-ai/ui/icon" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" -import { useDialog } from "@opencode-ai/ui/context/dialog" import { SettingsGeneral } from "./settings-general" import { SettingsKeybinds } from "./settings-keybinds" import { SettingsProviders } from "./settings-providers" import { SettingsModels } from "./settings-models" import { SettingsServers } from "./settings-servers" -export const DialogSettings: Component<{ defaultValue?: string }> = (props) => { +export const DialogSettings: Component = () => { const language = useLanguage() const platform = usePlatform() - const dialog = useDialog() - const [tab, setTab] = createSignal(props.defaultValue ?? "general") - - const showProviders = () => { - void dialog.show(() => ) - } return ( - void startTransition(() => setTab(value))} - class="h-full settings-dialog" - > +
@@ -83,7 +70,7 @@ export const DialogSettings: Component<{ defaultValue?: string }> = (props) => { - + diff --git a/packages/app/src/components/dialog-usage-exceeded.tsx b/packages/app/src/components/dialog-usage-exceeded.tsx index bf5da751e2..e428d4c2bb 100644 --- a/packages/app/src/components/dialog-usage-exceeded.tsx +++ b/packages/app/src/components/dialog-usage-exceeded.tsx @@ -17,7 +17,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) { const platform = usePlatform() const runAction = () => { - if (props.link) platform.openExternal(props.link) + if (props.link) platform.openLink(props.link) props.onClose?.() dialog.close() } diff --git a/packages/app/src/components/directory-picker-domain.test.ts b/packages/app/src/components/directory-picker-domain.test.ts index 1bc9af0833..5746410610 100644 --- a/packages/app/src/components/directory-picker-domain.test.ts +++ b/packages/app/src/components/directory-picker-domain.test.ts @@ -133,10 +133,10 @@ test("scopes file autocomplete to the current browser root", () => { test("resolves directory autocomplete from the current browser root", async () => { const directories: string[] = [] const sdk = { - api: { - file: { - find: (input: { location?: { directory?: string } }) => { - directories.push(input.location?.directory ?? "") + client: { + find: { + files: (input: { directory: string }) => { + directories.push(input.directory) return Promise.resolve({ data: [] }) }, }, @@ -152,29 +152,6 @@ test("resolves directory autocomplete from the current browser root", async () = expect(directories).toEqual(["/repo", "/repo/src"]) }) -test("searches from an absolute root without a default base", async () => { - const directories: string[] = [] - const sdk = { - api: { - file: { - list: (input: { location?: { directory?: string } }) => { - directories.push(input.location?.directory ?? "") - return Promise.resolve({ - data: [ - { path: "Users/", type: "directory" }, - { path: "tmp/", type: "directory" }, - ], - }) - }, - }, - }, - } as unknown as Parameters[0]["sdk"] - const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined }) - - expect(await search("/")).toEqual(["/Users", "/tmp"]) - expect(directories).toEqual(["/"]) -}) - test("identifies the next directory level to preload", () => { expect( preloadTreeDirectories("src/", [ diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts index 9539ae1d01..9900265962 100644 --- a/packages/app/src/components/directory-picker-domain.ts +++ b/packages/app/src/components/directory-picker-domain.ts @@ -326,15 +326,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string let current = 0 const scoped = (value: string) => { - const raw = normalizePickerDrive(value) - const root = pickerRoot(raw) - if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) } const base = args.base() if (!base) return + const raw = normalizePickerDrive(value) if (!raw) return { directory: trimPickerPath(base), path: "" } const home = args.home() if (raw === "~") return { directory: trimPickerPath(home || base), path: "" } if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) } + const root = pickerRoot(raw) + if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) } return { directory: trimPickerPath(base), path: raw } } @@ -342,17 +342,14 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string const key = trimPickerPath(directory) const existing = cache.get(key) if (existing) return existing - const request = args.sdk.api.file - .list({ location: { directory: key } }) - .then((result) => result.data) + const request = args.sdk.client.file + .list({ directory: key, path: "" }) + .then((result) => result.data ?? []) .catch(() => []) .then((nodes) => nodes .filter((node) => node.type === "directory") - .map((node) => { - const relative = trimPickerPath(normalizePickerDrive(node.path)) - return { name: getFilename(relative), absolute: joinPickerPath(key, relative) } - }), + .map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })), ) cache.set(key, request) return request @@ -374,9 +371,9 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/") const query = normalizePickerDrive(input.path) if (!pathInput) { - const results = await args.sdk.api.file - .find({ location: { directory: input.directory }, query, type: "directory", limit: 50 }) - .then((result) => result.data.map((entry) => entry.path)) + const results = await args.sdk.client.find + .files({ directory: input.directory, query, type: "directory", limit: 50 }) + .then((result) => result.data ?? []) .catch(() => []) if (!active()) return [] return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) diff --git a/packages/app/src/components/edit-project.ts b/packages/app/src/components/edit-project.ts deleted file mode 100644 index 0c5d576e93..0000000000 --- a/packages/app/src/components/edit-project.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { getFilename } from "@opencode-ai/core/util/path" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useMutation } from "@tanstack/solid-query" -import { normalizeProjectInfo } from "@/context/global-sync/utils" -import { createMemo } from "solid-js" -import { createStore } from "solid-js/store" -import { useGlobal } from "@/context/global" -import { type LocalProject } from "@/context/layout" -import { ServerConnection } from "@/context/server" - -export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) { - const dialog = useDialog() - const global = useGlobal() - const serverCtx = createMemo(() => global.ensureServerCtx(props.server)) - const folderName = createMemo(() => getFilename(props.project.worktree)) - const defaultName = createMemo(() => props.project.name || folderName()) - const [store, setStore] = createStore({ - name: defaultName(), - color: props.project.icon?.color, - iconOverride: props.project.icon?.override, - startup: props.project.commands?.start ?? "", - dragOver: false, - iconHover: false, - }) - let iconInput: HTMLInputElement | undefined - - function selectFile(file: File) { - if (!file.type.startsWith("image/")) return - const reader = new FileReader() - reader.onload = (event) => { - const result = event.target?.result - if (typeof result !== "string") return - setStore("iconOverride", result) - setStore("iconHover", false) - } - reader.readAsDataURL(file) - } - - function drop(event: DragEvent) { - event.preventDefault() - setStore("dragOver", false) - const file = event.dataTransfer?.files[0] - if (file) selectFile(file) - } - - function dragOver(event: DragEvent) { - event.preventDefault() - setStore("dragOver", true) - } - - function dragLeave() { - setStore("dragOver", false) - } - - function inputChange(event: Event) { - const file = (event.currentTarget as HTMLInputElement).files?.[0] - if (file) selectFile(file) - } - - function iconClick() { - if (store.iconOverride && store.iconHover) { - setStore("iconOverride", "") - return - } - iconInput?.click() - } - - const save = useMutation(() => ({ - mutationFn: async () => { - const name = store.name.trim() === folderName() ? "" : store.name.trim() - const start = store.startup.trim() - - if (props.project.id && props.project.id !== "global") { - if ((await serverCtx().sdk.protocol) !== "v1") return - const project = await serverCtx() - .sdk.client.project.update({ - projectID: props.project.id, - directory: props.project.worktree, - name, - icon: { color: store.color || "", override: store.iconOverride || "" }, - commands: { start }, - }) - .then((result) => result.data) - if (!project) return - // const project = await serverCtx().sdk.api.project.update({ - // projectID: props.project.id, - // name, - // icon: { color: store.color || "", override: store.iconOverride || "" }, - // commands: { start }, - // }) - serverCtx().sync.set("project", (items) => - items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)), - ) - serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined) - dialog.close() - return - } - - serverCtx().sync.project.meta(props.project.worktree, { - name, - icon: { color: store.color || undefined, override: store.iconOverride || undefined }, - commands: { start: start || undefined }, - }) - dialog.close() - }, - })) - - function submit(event: SubmitEvent) { - event.preventDefault() - if (save.isPending) return - save.mutate() - } - - return { - store, - setStore, - folderName, - defaultName, - save, - submit, - drop, - dragOver, - dragLeave, - inputChange, - iconClick, - close() { - dialog.close() - }, - setIconInput(input: HTMLInputElement) { - iconInput = input - }, - } -} diff --git a/packages/app/src/components/external-link.tsx b/packages/app/src/components/external-link.tsx deleted file mode 100644 index 133e752eab..0000000000 --- a/packages/app/src/components/external-link.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { ComponentProps, splitProps } from "solid-js" - -export interface ExternalLinkProps extends Omit, "href"> { - href: string -} - -export function ExternalLink(props: ExternalLinkProps) { - const [local, rest] = splitProps(props, ["href", "children", "class", "target", "rel"]) - - return ( - - {local.children} - - ) -} diff --git a/packages/app/src/components/file-tree-v2-model.test.ts b/packages/app/src/components/file-tree-v2-model.test.ts deleted file mode 100644 index f2a63fe86d..0000000000 --- a/packages/app/src/components/file-tree-v2-model.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model" -import type { FileNode } from "@opencode-ai/sdk/v2" - -describe("buildFileTreeV2Model", () => { - test("builds a sorted tree and flattens expanded directories", () => { - const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"]) - - expect(model.total).toBe(8) - expect(flattenFileTreeV2(model, () => true).map((row) => [row.node.path, row.node.type, row.level])).toEqual([ - ["docs", "directory", 0], - ["docs/guide.md", "file", 1], - ["src", "directory", 0], - ["src/lib", "directory", 1], - ["src/lib/a.ts", "file", 2], - ["src/lib/b.ts", "file", 2], - ["src/z.ts", "file", 1], - ["README.md", "file", 0], - ]) - }) - - test("skips children of collapsed directories", () => { - const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"]) - - expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([ - "src", - "src/lib", - "src/z.ts", - ]) - }) - - test("normalizes duplicate and messy paths", () => { - const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"]) - const rows = flattenFileTreeV2(model, () => true) - - expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"]) - expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts") - }) - - test("handles deeply nested paths", () => { - const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts" - const model = buildFileTreeV2Model([file]) - - expect(flattenFileTreeV2(model, () => true)).toHaveLength(131) - }) -}) - -describe("flattenLiveFileTreeV2", () => { - test("flattens live children using original paths for nested lookups", () => { - const nodes: Record = { - "": [ - { name: "src", path: "src", absolute: "/repo/src", type: "directory", ignored: false }, - { name: "README.md", path: "README.md", absolute: "/repo/README.md", type: "file", ignored: false }, - ], - src: [ - { name: "a.ts", path: "src/a.ts", absolute: "/repo/src/a.ts", type: "file", ignored: false }, - { name: "lib", path: "src/lib", absolute: "/repo/src/lib", type: "directory", ignored: false }, - ], - "src/lib": [{ name: "b.ts", path: "src/lib/b.ts", absolute: "/repo/src/lib/b.ts", type: "file", ignored: false }], - } - - expect( - flattenLiveFileTreeV2( - (path) => nodes[path] ?? [], - (path) => path === "src", - ).map((row) => [row.node.path, row.node.originalPath, row.level]), - ).toEqual([ - ["src", "src", 0], - ["src/a.ts", "src/a.ts", 1], - ["src/lib", "src/lib", 1], - ["README.md", "README.md", 0], - ]) - }) -}) diff --git a/packages/app/src/components/file-tree-v2-model.ts b/packages/app/src/components/file-tree-v2-model.ts deleted file mode 100644 index 27783a9bb1..0000000000 --- a/packages/app/src/components/file-tree-v2-model.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { FileNode } from "@opencode-ai/sdk/v2" - -export type FileTreeV2Model = { - children: ReadonlyMap - total: number -} - -export type FileTreeV2Node = FileNode & { originalPath: string } - -export type FileTreeV2Row = { - node: FileTreeV2Node - level: number -} - -export function normalizeFileTreeV2Path(value: string) { - return value - .replaceAll("\\", "/") - .replace(/^\/+|\/+$/g, "") - .replace(/\/{2,}/g, "/") -} - -export function buildFileTreeV2Model(paths: readonly string[]): FileTreeV2Model { - const nodes = new Map() - - paths.forEach((value) => { - const file = normalizeFileTreeV2Path(value) - if (!file) return - - const parts = file.split("/") - parts.forEach((name, index) => { - const path = parts.slice(0, index + 1).join("/") - if (nodes.has(path)) return - nodes.set(path, { - name, - path, - absolute: path, - type: index === parts.length - 1 ? "file" : "directory", - ignored: false, - originalPath: index === parts.length - 1 ? value : path, - }) - }) - }) - - const children = new Map() - nodes.forEach((node) => { - const index = node.path.lastIndexOf("/") - const parent = index === -1 ? "" : node.path.slice(0, index) - const list = children.get(parent) - if (list) list.push(node) - else children.set(parent, [node]) - }) - children.forEach((nodes) => - nodes.sort((a, b) => { - if (a.type !== b.type) return a.type === "directory" ? -1 : 1 - return a.name.localeCompare(b.name) - }), - ) - - return { children, total: nodes.size } -} - -export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: string) => boolean) { - const rows: FileTreeV2Row[] = [] - const stack = (model.children.get("") ?? []).toReversed().map((node) => ({ node, level: 0 })) - - while (stack.length > 0) { - const row = stack.pop()! - rows.push(row) - if (row.node.type !== "directory" || !expanded(row.node.path)) continue - const children = model.children.get(row.node.path) ?? [] - for (let index = children.length - 1; index >= 0; index--) { - stack.push({ node: children[index]!, level: row.level + 1 }) - } - } - - return rows -} - -export function flattenLiveFileTreeV2( - children: (path: string) => readonly FileNode[], - expanded: (path: string) => boolean, -) { - const rows: FileTreeV2Row[] = [] - const stack = children("") - .toReversed() - .map((node) => ({ node: toLiveNode(node), level: 0 })) - - while (stack.length > 0) { - const row = stack.pop()! - rows.push(row) - if (row.node.type !== "directory" || !expanded(row.node.path)) continue - const nested = children(row.node.originalPath) - for (let index = nested.length - 1; index >= 0; index--) { - stack.push({ node: toLiveNode(nested[index]!), level: row.level + 1 }) - } - } - - return rows -} - -function toLiveNode(node: FileNode): FileTreeV2Node { - return { - ...node, - path: normalizeFileTreeV2Path(node.path), - originalPath: node.path, - } -} diff --git a/packages/app/src/components/file-tree-v2.tsx b/packages/app/src/components/file-tree-v2.tsx deleted file mode 100644 index 26cf49d411..0000000000 --- a/packages/app/src/components/file-tree-v2.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import { useFile } from "@/context/file" -import { FileIcon } from "@opencode-ai/ui/file-icon" -import "@opencode-ai/ui/v2/file-tree-v2.css" -import { - createEffect, - createMemo, - createSignal, - For, - Show, - splitProps, - type ComponentProps, - type ParentProps, -} from "solid-js" -import { Dynamic } from "solid-js/web" -import type { FileNode } from "@opencode-ai/sdk/v2" -import { Icon } from "@opencode-ai/ui/v2/icon" -import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree" -import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" -import { - buildFileTreeV2Model, - flattenFileTreeV2, - flattenLiveFileTreeV2, - normalizeFileTreeV2Path, - type FileTreeV2Node, -} from "@/components/file-tree-v2-model" -import { virtualScrollElement } from "@/components/virtual-scroll-element" - -export type { Kind } from "@/components/file-tree" - -const INDENT_STEP = 16 - -function rowPaddingLeft(level: number, type: FileNode["type"]) { - if (type === "directory") return 8 + level * INDENT_STEP - if (level === 0) return 8 - return 8 + level * INDENT_STEP - INDENT_STEP -} - -function guideLineLeft(level: number) { - return rowPaddingLeft(level, "directory") + 8 -} - -export const kindLabel = (kind: Kind) => { - if (kind === "add") return "A" - if (kind === "del") return "D" - return "M" -} - -export const kindChange = (kind: Kind) => { - if (kind === "add") return "added" - if (kind === "del") return "deleted" - return "modified" -} - -const FileTreeNodeV2 = ( - p: ParentProps & - ComponentProps<"div"> & - ComponentProps<"button"> & { - node: FileNode - level: number - active?: string - draggable: boolean - kinds?: ReadonlyMap - as?: "div" | "button" - }, -) => { - const [local, rest] = splitProps(p, [ - "node", - "level", - "active", - "draggable", - "kinds", - "as", - "children", - "class", - "classList", - ]) - const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path)) - - return ( - { - if (!local.draggable) return - event.dataTransfer?.setData("text/plain", `file:${local.node.path}`) - event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) - if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy" - withFileDragImage(event) - }} - {...rest} - > - {local.children} - {local.node.name} - {(() => { - const value = kind() - if (!value || local.node.type !== "file") return null - return ( - - {kindLabel(value)} - - ) - })()} - - ) -} - -function GuideLines(props: { level: number }) { - return ( - - {(_, index) =>
} - - ) -} - -export default function FileTreeV2(props: { - active?: string - allowed?: readonly string[] - kinds?: ReadonlyMap - draggable?: boolean - onFileClick?: (file: FileNode) => void - onFileDoubleClick?: (file: FileNode) => void -}) { - const file = useFile() - const live = () => props.allowed === undefined - const draggable = () => props.draggable ?? true - const active = () => normalizeFileTreeV2Path(props.active ?? "") - const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? []))) - const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live() - const rows = createMemo(() => { - if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded) - return flattenFileTreeV2(model()!, expanded) - }) - const [root, setRoot] = createSignal() - const [focused, setFocused] = createSignal() - const virtualizer = createVirtualizer({ - get count() { - return rows().length - }, - getScrollElement: () => virtualScrollElement(root()), - initialRect: { width: 0, height: 600 }, - estimateSize: () => 28, - gap: 2, - overscan: 10, - get getItemKey() { - const current = rows() - return (index: number) => current[index]?.node.path ?? index - }, - rangeExtractor: (range) => { - const indexes = defaultRangeExtractor(range) - const path = focused() - const index = path ? rows().findIndex((row) => row.node.path === path) : -1 - if (index < 0 || indexes.includes(index)) return indexes - return [...indexes, index].sort((a, b) => a - b) - }, - }) - - createEffect(() => { - if (!live()) return - void file.tree.list("") - }) - - // Only scroll when the active path changes (or first appears in the tree). - // Do not re-scroll when expand/collapse reshuffles `rows()`. - let scrolledActive: string | undefined - createEffect(() => { - const path = active() - if (!path) { - scrolledActive = undefined - return - } - const index = rows().findIndex((row) => row.node.path === path) - if (index < 0) return - if (scrolledActive === path) return - scrolledActive = path - queueMicrotask(() => { - const next = rows().findIndex((row) => row.node.path === path) - if (next < 0) return - if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return - virtualizer.scrollToIndex(next, { align: "auto" }) - }) - }) - - const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => { - action?.({ - ...node, - path: node.originalPath, - absolute: node.originalPath, - }) - } - - const toggleDirectory = (path: string, originalPath: string) => { - if (expanded(path)) { - file.tree.collapse(originalPath) - return - } - file.tree.expand(originalPath, live() ? undefined : { list: false }) - } - - const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const))) - const virtualItemByKey = createMemo( - () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), - ) - const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key)) - - return ( -
- - {(key) => ( - - {(item) => ( -
- - {(row) => ( - setFocused(row().node.path)} - onBlur={() => setFocused(undefined)} - onClick={() => selectFile(row().node, props.onFileClick)} - onDblClick={() => selectFile(row().node, props.onFileDoubleClick)} - > - - 0}> -
- - - - - - - } - > - setFocused(row().node.path)} - onBlur={() => setFocused(undefined)} - aria-expanded={expanded(row().node.path)} - onClick={() => toggleDirectory(row().node.path, row().node.originalPath)} - > - -
- -
-
- - )} - -
- )} -
- )} - -
- ) -} diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 718e2f3493..211ce05ef0 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -21,13 +21,13 @@ import type { FileNode } from "@opencode-ai/sdk/v2" const MAX_DEPTH = 128 -export function pathToFileUrl(filepath: string): string { +function pathToFileUrl(filepath: string): string { return `file://${encodeFilePath(filepath)}` } -export type Kind = "add" | "del" | "mix" +type Kind = "add" | "del" | "mix" -export type Filter = { +type Filter = { files: Set dirs: Set } @@ -78,7 +78,7 @@ const kindDotColor = (kind: Kind) => { return "background-color: var(--icon-diff-modified-base)" } -export const visibleKind = (node: FileNode, kinds?: ReadonlyMap, marks?: Set) => { +const visibleKind = (node: FileNode, kinds?: ReadonlyMap, marks?: Set) => { const kind = kinds?.get(node.path) if (!kind) return if (!marks?.has(node.path)) return @@ -99,7 +99,7 @@ const buildDragImage = (target: HTMLElement) => { return image } -export const withFileDragImage = (event: DragEvent) => { +const withFileDragImage = (event: DragEvent) => { const image = buildDragImage(event.currentTarget as HTMLElement) if (!image) return document.body.appendChild(image) @@ -201,7 +201,6 @@ export default function FileTree(props: { kinds?: ReadonlyMap draggable?: boolean onFileClick?: (file: FileNode) => void - onFileDoubleClick?: (file: FileNode) => void _filter?: Filter _marks?: Set @@ -441,7 +440,6 @@ export default function FileTree(props: { active={props.active} draggable={props.draggable} onFileClick={props.onFileClick} - onFileDoubleClick={props.onFileDoubleClick} _filter={filter()} _marks={marks()} _deeps={deeps()} @@ -464,7 +462,6 @@ export default function FileTree(props: { as="button" type="button" onClick={() => props.onFileClick?.(node)} - onDblClick={() => props.onFileDoubleClick?.(node)} >
diff --git a/packages/app/src/components/help-button.tsx b/packages/app/src/components/help-button.tsx index 18dd727084..9109418fcf 100644 --- a/packages/app/src/components/help-button.tsx +++ b/packages/app/src/components/help-button.tsx @@ -1,148 +1,54 @@ -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { Popover } from "@opencode-ai/ui/popover" import { createSignal, Show } from "solid-js" -import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer" -import { usePlatform } from "@/context/platform" -import { useSettings } from "@/context/settings" -import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4" -import homeImage from "@/assets/help/home.png" -import tabsImage from "@/assets/help/tabs.png" +import { createStore } from "solid-js/store" -// TODO: wire to changelog / seen-state when available -const showPopover = () => true +export function HelpButton() { + if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null -// can remove this after the tabs rollout has been out for a while -export function TabsInfoPopup() { - const settings = useSettings() - const platform = usePlatform() - const [drawerOpen, setDrawerOpen] = createSignal(false) - const windows = () => platform.platform === "desktop" && platform.os === "windows" + const [state, setState] = /* persisted(Persist.global("help-button"), */ createStore({ dismissed: false }) /* ) */ + const [shown, setShown] = createSignal(false) return ( - - -
+ - -
-
- - - } - class="absolute top-[10px] left-[-36px]" - /> - -
-

- July 14 -

- - } - /> -
-
-

- Introducing Tabs -

-
-

OpenCode Desktop is now built around tabs.

- -

- Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when - you're starting something new, and close it when you're done. -

-

- Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something - memorable if you plan to keep them around. -

-

- You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab. -

- -

When you reopen the app, your tabs are still open.

-

- The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using - the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout - will become permanent in a few weeks. -

-
-
-
-
+ +
+
) } diff --git a/packages/app/src/components/link.tsx b/packages/app/src/components/link.tsx new file mode 100644 index 0000000000..85f7efc539 --- /dev/null +++ b/packages/app/src/components/link.tsx @@ -0,0 +1,26 @@ +import { ComponentProps, splitProps } from "solid-js" +import { usePlatform } from "@/context/platform" + +export interface LinkProps extends Omit, "href"> { + href: string +} + +export function Link(props: LinkProps) { + const platform = usePlatform() + const [local, rest] = splitProps(props, ["href", "children", "class"]) + + return ( + { + if (!local.href) return + event.preventDefault() + platform.openLink(local.href) + }} + {...rest} + > + {local.children} + + ) +} diff --git a/packages/app/src/components/model-tooltip.tsx b/packages/app/src/components/model-tooltip.tsx index 7253c06917..53164dae85 100644 --- a/packages/app/src/components/model-tooltip.tsx +++ b/packages/app/src/components/model-tooltip.tsx @@ -1,4 +1,4 @@ -import { Show, type Component, type JSX } from "solid-js" +import { Show, type Component } from "solid-js" import { useLanguage } from "@/context/language" type InputKey = "text" | "image" | "audio" | "video" | "pdf" @@ -23,18 +23,7 @@ type ModelInfo = { } } -function ModelTooltipRow(props: { name: JSX.Element; value: JSX.Element }) { - return ( -
- {props.name} - {props.value} -
- ) -} - -export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean; v2?: boolean }> = ( - props, -) => { +export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean }> = (props) => { const language = useLanguage() const sourceName = (model: ModelInfo) => { const value = `${model.id} ${model.name}`.toLowerCase() @@ -62,13 +51,6 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free? const suffix = tags.length ? ` (${tags.join(", ")})` : "" return `${sourceName(props.model)} ${props.model.name}${suffix}` } - const name = () => { - const tags: Array = [] - if (props.latest) tags.push(language.t("model.tag.latest")) - if (props.free) tags.push(language.t("model.tag.free")) - const suffix = tags.length ? ` (${tags.join(", ")})` : "" - return `${props.model.name}${suffix}` - } const inputs = () => { if (props.model.capabilities) { const input = props.model.capabilities.input @@ -91,21 +73,6 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free? : language.t("model.tooltip.reasoning.none") } const context = () => language.t("model.tooltip.context", { limit: props.model.limit.context.toLocaleString() }) - const contextLimit = () => props.model.limit.context.toLocaleString(language.intl()) - - if (props.v2) { - return ( -
- - - - {(value) => } - - - -
- ) - } return (
diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx deleted file mode 100644 index 96b99aaf4a..0000000000 --- a/packages/app/src/components/prompt-input-v2.tsx +++ /dev/null @@ -1,586 +0,0 @@ -import { ImagePreview } from "@opencode-ai/ui/image-preview" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" -import { Icon } from "@opencode-ai/ui/v2/icon" -import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" -import { createEffect, createMemo, on, Show } from "solid-js" -import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" -import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" -import type { PromptInputProps } from "@/components/prompt-input/contracts" -import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history" -import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store" -import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder" -import { createPromptSubmit } from "@/components/prompt-input/submit" -import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" -import { useComments } from "@/context/comments" -import { useCommand } from "@/context/command" -import { useLanguage } from "@/context/language" -import { useLayout } from "@/context/layout" -import { usePermission } from "@/context/permission" -import { type ImageAttachmentPart, usePrompt } from "@/context/prompt" -import { usePlatform } from "@/context/platform" -import { useSDK } from "@/context/sdk" -import { useSync } from "@/context/sync" -import { createSessionTabs } from "@/pages/session/helpers" -import { showToast } from "@/utils/toast" -import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input" -import { - createPromptInputV2Controller, - createPromptInputV2State, - type PromptInputV2Interaction, -} from "@opencode-ai/session-ui/v2/prompt-input/interaction" - -export type PromptInputV2ComposerProps = { - class?: string - controller: PromptInputV2ComposerController - borderUnderlay?: boolean -} - -export type PromptInputV2ControllerProps = Omit -export type PromptInputV2ComposerController = PromptInputV2Interaction & { - readonly model: PromptInputProps["controls"]["model"] -} - -export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { - const dialog = useDialog() - const command = useCommand() - const language = useLanguage() - - return ( -
- - dialog.show(() => ) - } - /> - } - /> -
- ) -} - -export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController { - const sdk = useSDK() - const sync = useSync() - const files = useFile() - const layout = useLayout() - const comments = useComments() - const dialog = useDialog() - const command = useCommand() - const permission = usePermission() - const language = useLanguage() - const platform = usePlatform() - const prompt = props.state ?? usePrompt() - let editor: HTMLDivElement | undefined - - const interaction = createPromptInputV2State() - const mode = () => interaction[0].mode - const history = props.history ?? createPersistedPromptInputHistory() - const tabs = () => props.controls.session.tabs - const activeFileTab = createSessionTabs({ - tabs, - pathFromTab: files.pathFromTab, - normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab), - }).activeFileTab - const recent = createMemo(() => { - const all = tabs().all() - const active = activeFileTab() - const order = active ? [active, ...all.filter((tab) => tab !== active)] : all - return order.reduce((result, tab) => { - const path = files.pathFromTab(tab) - if (!path || result.includes(path)) return result - return [...result, path] - }, []) - }) - const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) - const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) - const attachments = createMemo(() => - prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), - ) - const commentCount = createMemo(() => { - if (mode() === "shell") return 0 - return prompt.context.items().filter((item) => !!item.comment?.trim()).length - }) - const blank = createMemo(() => { - const text = prompt - .current() - .map((part) => ("content" in part ? part.content : "")) - .join("") - return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0 - }) - const stopping = createMemo(() => working() && blank()) - const placeholder = createMemo(() => - promptPlaceholder({ - mode: mode(), - commentCount: commentCount(), - example: mode() === "shell" ? "git status" : "", - suggest: false, - t: (key, params) => language.t(key as Parameters[0], params as never), - }), - ) - const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder()) - - const historyComments = () => { - const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) - return prompt.context.items().flatMap((item) => { - const comment = item.comment?.trim() - if (!comment) return [] - const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined - const nextSelection = - selection ?? - (item.selection - ? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange) - : undefined) - if (!nextSelection) return [] - return [ - { - id: item.commentID ?? item.key, - path: item.path, - selection: { ...nextSelection }, - comment, - time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(), - origin: item.commentOrigin, - preview: item.preview, - } satisfies PromptHistoryComment, - ] - }) - } - const restoreHistoryComments = (items: PromptHistoryComment[]) => { - comments.replace( - items.map((item) => ({ - id: item.id, - file: item.path, - selection: { ...item.selection }, - comment: item.comment, - time: item.time, - })), - ) - prompt.context.replaceComments( - items.map((item) => ({ - type: "file", - path: item.path, - selection: selectionFromLines(item.selection), - comment: item.comment, - commentID: item.id, - commentOrigin: item.origin, - preview: item.preview, - })), - ) - } - - const accepting = createMemo(() => { - const id = props.controls.session.id - if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) - return permission.isAutoAccepting(id, sdk().directory) - }) - const submission = createPromptSubmit({ - prompt, - info, - imageAttachments: attachments, - commentCount, - autoAccept: accepting, - mode, - working, - editor: () => editor, - queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })), - promptLength, - addToHistory: (value, mode) => controller.addHistory(value, mode), - resetHistoryNavigation: () => controller.resetHistory(), - setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }), - setPopover: (popover) => { - if (!popover) controller.dispatch({ type: "popover.close" }) - }, - newSessionWorktree: () => props.newSessionWorktree, - onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, - shouldQueue: props.shouldQueue, - onQueue: props.onQueue, - onAbort: props.onAbort, - onSubmit: props.onSubmit, - model: props.controls.model.selection, - }) - - const referenceDescription = (reference: ReferenceInfo) => - reference.source.type === "git" ? reference.source.repository : reference.source.path - const references = createMemo(() => - sync() - .data.reference.filter((reference) => !reference.hidden) - .map((reference) => ({ - id: `reference:${reference.name}`, - kind: "reference" as const, - label: `@${reference.name}`, - path: reference.path, - description: reference.description ?? referenceDescription(reference), - mention: { - type: "file" as const, - path: reference.path, - content: `@${reference.name}`, - start: 0, - end: 0, - mime: "application/x-directory", - filename: reference.name, - }, - })), - ) - const resources = createMemo(() => - Object.values(sync().data.mcp_resource).map((resource) => ({ - id: `resource:${resource.server}:${resource.uri}`, - kind: "resource" as const, - label: `@${resource.name}`, - path: resource.uri, - description: resource.description, - mention: { - type: "file" as const, - path: resource.uri, - content: `@${resource.name}`, - start: 0, - end: 0, - mime: resource.mimeType ?? "text/plain", - filename: resource.name, - url: resource.uri, - source: { - type: "resource" as const, - text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 }, - clientName: resource.server, - uri: resource.uri, - }, - }, - resource, - })), - ) - const context = createMemo(() => [ - ...references(), - ...props.controls.agents.available - .filter((agent) => !agent.hidden && agent.mode !== "primary") - .map((agent) => ({ - id: `agent:${agent.name}`, - kind: "agent" as const, - label: `@${agent.name}`, - mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 }, - })), - ...resources(), - ...recent().map((path) => ({ - id: `file:${path}`, - kind: "file" as const, - label: path, - path, - recent: true, - mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 }, - })), - ]) - const slashCommands = createMemo(() => [ - ...sync().data.command.map((item) => ({ - id: `custom.${item.name}`, - trigger: item.name, - title: item.name, - description: item.description, - type: "custom" as const, - })), - ...command.options - .filter((item) => !item.disabled && !item.id.startsWith("suggested.") && item.slash) - .map((item) => ({ - id: item.id, - trigger: item.slash!, - title: item.title, - description: item.description, - type: "builtin" as const, - })), - ]) - const commands = createMemo(() => - slashCommands().map((item) => ({ - id: item.id, - kind: "command", - label: `/${item.trigger}`, - trigger: item.trigger, - title: item.title, - description: item.description, - keybind: command.keybindParts(item.id), - })), - ) - const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()]) - const controller = createPromptInputV2Controller({ - store: () => prompt.capture().store, - state: interaction, - identity: () => prompt.capture(), - history: { - entries: (mode) => - history.entries(mode).map((value) => { - const entry = normalizePromptHistoryEntry(value) - return { prompt: entry.prompt, metadata: entry.comments } - }), - add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()), - capture: historyComments, - restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]), - }, - commands, - context, - searchContextFiles: async (query) => - (await files.searchFilesAndDirectories(query)).map((path) => ({ - id: `file:${path}`, - kind: "file", - label: path, - path, - mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 }, - })), - onContextRemove(item) { - if (item?.commentID) comments.remove(item.path, item.commentID) - }, - openAttachment: (attachment) => - dialog.show(() => ), - openContext(key) { - const item = controller.contextItem(key) - if (item) openComment(item, props, sync, layout, files, comments) - }, - onEditor(element) { - editor = element as HTMLDivElement - props.ref?.(editor) - }, - onSuggestionSelect(item) { - if (item.kind !== "command") return - const selected = slashCommands().find((entry) => entry.id === item.id) - if (!selected || selected.type === "custom") return - return () => command.trigger(selected.id, "slash") - }, - attachments: { - picker: platform.openAttachmentPickerDialog, - directory: () => sdk().directory, - isDialogActive: () => !!dialog.active, - warn: () => - showToast({ - title: language.t("prompt.toast.pasteUnsupported.title"), - description: language.t("prompt.toast.pasteUnsupported.description"), - }), - duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }), - onError: (error) => - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: error instanceof Error ? error.message : String(error), - }), - readClipboardImage: platform.readClipboardImage, - getPathForFile: platform.getPathForFile, - store: platform.draftStore?.putBlob, - }, - view: { - placeholder: designPlaceholder, - get agent() { - return props.controls.agents.visible && props.controls.agents.options.length > 0 - ? { - options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })), - current: () => props.controls.agents.current, - onSelect: (value: string) => props.controls.agents.select(value), - keybind: () => command.keybindParts("agent.cycle"), - } - : undefined - }, - variant: { - options: () => variants().map((value) => ({ id: value, label: value })), - current: () => props.controls.model.selection.variant.current() ?? "default", - onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value), - keybind: () => command.keybindParts("model.variant.cycle"), - }, - submit: { - stopping, - working, - onSubmit: () => void submission.handleSubmit(new Event("submit")), - onStop: () => void submission.abort(), - }, - }, - }) - Object.defineProperty(controller, "model", { get: () => props.controls.model }) - - command.register("prompt-input", () => [ - { - id: "file.attach", - title: language.t("prompt.action.attachFile"), - category: language.t("command.category.file"), - keybind: "mod+u", - disabled: controller.state.mode !== "normal", - onSelect: () => controller.attach(), - }, - { - id: "prompt.mode.shell", - title: language.t("command.prompt.mode.shell"), - category: language.t("command.category.session"), - keybind: "mod+shift+x", - disabled: controller.state.mode === "shell", - onSelect: () => controller.dispatch({ type: "mode.shell" }), - }, - { - id: "prompt.mode.normal", - title: language.t("command.prompt.mode.normal"), - category: language.t("command.category.session"), - keybind: "mod+shift+e", - disabled: controller.state.mode === "normal", - onSelect: () => controller.dispatch({ type: "mode.normal" }), - }, - ]) - - createEffect( - on( - () => props.edit?.id, - (id) => { - const edit = props.edit - if (!id || !edit) return - prompt.context.items().forEach((item) => prompt.context.remove(item.key)) - edit.context.forEach((item) => - prompt.context.add({ - type: item.type, - path: item.path, - selection: item.selection, - comment: item.comment, - commentID: item.commentID, - commentOrigin: item.commentOrigin, - preview: item.preview, - }), - ) - controller.dispatch({ type: "mode.normal" }) - controller.resetHistory() - prompt.set(edit.prompt, promptLength(edit.prompt)) - controller.restoreFocus() - props.onEditLoaded?.() - }, - { defer: true }, - ), - ) - - return controller as PromptInputV2ComposerController -} - -function PromptInputV2ModelControl(props: { - loading: boolean - paid: boolean - title: string - keybind: string[] - model: PromptInputV2ComposerController["model"]["selection"] - providerID?: string - modelName: string - onClose: () => void - onUnpaidClick: () => void -}) { - const shouldAnimate = createMemo((previous) => previous ?? props.loading) - const content = () => ( - <> - - {(providerID) => ( - - )} - - {props.modelName} - - - - - ) - return ( - - - {props.title} - - - } - > - - {content()} - - } - > - ( - - {content()} - - )} - onClose={props.onClose} - /> - - - - ) -} - -function openComment( - item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }, - props: PromptInputV2ControllerProps, - sync: ReturnType, - layout: ReturnType, - files: ReturnType, - comments: ReturnType, -) { - if (!item.commentID) return - const focus = { file: item.path, id: item.commentID } - comments.setActive(focus) - const queueFocus = (attempts = 6) => { - requestAnimationFrame(() => { - comments.setFocus({ ...focus }) - if (attempts <= 0) return - requestAnimationFrame(() => { - const current = comments.focus() - if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1) - }) - }) - } - const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined - const review = - item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path)) - if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() - if (review) { - layout.fileTree.setTab("changes") - props.controls.session.tabs.setActive("review") - queueFocus() - return - } - layout.fileTree.setTab("all") - const tab = files.tab(item.path) - void props.controls.session.tabs.open(tab) - props.controls.session.tabs.setActive(tab) - void Promise.resolve(files.load(item.path)).finally(() => queueFocus()) -} diff --git a/packages/app/src/components/prompt-input.stories.tsx b/packages/app/src/components/prompt-input.stories.tsx index 0b9c26ce41..362a741401 100644 --- a/packages/app/src/components/prompt-input.stories.tsx +++ b/packages/app/src/components/prompt-input.stories.tsx @@ -30,16 +30,8 @@ function PromptInputExample() { activeTab: undefined as string | undefined, reviewOpen: false, }) - const storyModel = { - id: "claude-3-7-sonnet", - name: "Claude 3.7 Sonnet", - provider: { id: "anthropic", name: "Anthropic" }, - } const model = { - current: () => storyModel, - list: () => [storyModel], - visible: () => true, - set: () => {}, + current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }), variant: { list: () => ["fast", "thinking"], current: () => controls.variant, @@ -75,6 +67,7 @@ function PromptInputExample() { open: () => setControls("reviewOpen", true), }, }, + newLayoutDesigns: true, } const addReviewComment = () => { const comment = controls.comments + 1 @@ -153,6 +146,7 @@ function PromptInputWithOpenDock() { }, reviewPanel: { opened: () => false, open: () => {} }, }, + newLayoutDesigns: true, } const state = { blocked: () => false, diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 08c1ee3489..c72df7e512 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -13,11 +13,12 @@ import { Match, type JSX, } from "solid-js" +import { createStore, type SetStoreFunction, type Store } from "solid-js/store" +import type { useLocal } from "@/context/local" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { ContentPart, DEFAULT_PROMPT, - isCommentItem, isPromptEqual, Prompt, usePrompt, @@ -35,8 +36,6 @@ import { Icon } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" @@ -44,9 +43,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" import { useDialog } from "@opencode-ai/ui/context/dialog" import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model" -import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" -import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" import { useCommand } from "@/context/command" +import { Persist, persisted } from "@/utils/persist" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" @@ -57,34 +55,121 @@ import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files" import { canNavigateHistoryAtCursor, navigatePromptHistory, + prependHistoryEntry, type PromptHistoryComment, type PromptHistoryEntry, + type PromptHistoryStoredEntry, promptLength, } from "./prompt-input/history" -import { - createPersistedPromptInputHistory, - createPromptInputHistory, - type PromptInputHistory, -} from "./prompt-input/history-store" -import { - type PromptInputControls, - type PromptInputProps, - type PromptInputState, - type PromptInputSubmission, -} from "./prompt-input/contracts" -import { createPromptSubmit } from "./prompt-input/submit" +import { createPromptSubmit, type FollowupDraft } 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 { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder" +import { promptPlaceholder } from "./prompt-input/placeholder" import { createPromptInputTransientState } from "./prompt-input/transient-state" import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" -export { createPromptInputHistory } -export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission } +export type PromptInputState = ReturnType + +export type PromptInputHistory = { + entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[] + add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void +} + +export type PromptInputSubmission = { + abort: () => Promise | void + handleSubmit: (event: Event) => Promise | void +} + +export type PromptInputControls = { + agents: { + available: { name: string; hidden?: boolean; mode: string }[] + options: string[] + current: string + loading: boolean + visible: boolean + select: (name: string | undefined) => void + } + model: { + selection: ReturnType["model"] + paid: boolean + loading: boolean + } + session: { + id?: string + tabs: { + active: () => string | undefined + all: () => string[] + open: (tab: string) => void | Promise + setActive: (tab: string) => void + } + reviewPanel: { + opened: () => boolean + open: () => void + } + } + newLayoutDesigns: boolean +} + +export function createPromptInputHistory(): PromptInputHistory { + const [normal, setNormal] = createStore({ entries: [] }) + const [shell, setShell] = createStore({ entries: [] }) + return createPromptInputHistoryStore(normal, setNormal, shell, setShell) +} + +type PromptHistoryState = { entries: PromptHistoryStoredEntry[] } + +function createPromptInputHistoryStore( + normal: Store, + setNormal: SetStoreFunction, + shell: Store, + setShell: SetStoreFunction, +): PromptInputHistory { + return { + entries: (mode) => (mode === "shell" ? shell.entries : normal.entries), + add(prompt, mode, comments) { + const current = mode === "shell" ? shell : normal + const setCurrent = mode === "shell" ? setShell : setNormal + const next = prependHistoryEntry(current.entries, prompt, comments) + if (next === current.entries) return + setCurrent("entries", next) + }, + } +} + +function createPersistedPromptInputHistory() { + const [normal, setNormal] = persisted( + Persist.global("prompt-history", ["prompt-history.v1"]), + createStore({ entries: [] }), + ) + const [shell, setShell] = persisted( + Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), + createStore({ entries: [] }), + ) + return createPromptInputHistoryStore(normal, setNormal, shell, setShell) +} + +export interface PromptInputProps { + class?: string + variant?: "dock" | "new-session" + state?: PromptInputState + history?: PromptInputHistory + submission?: PromptInputSubmission + controls: PromptInputControls + ref?: (el: HTMLDivElement) => void + newSessionWorktree?: string + onNewSessionWorktreeReset?: () => void + edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] } + onEditLoaded?: () => void + shouldQueue?: () => boolean + onQueue?: (draft: FollowupDraft) => void + onAbort?: () => void + onSubmit?: () => void + toolbar?: JSX.Element +} const EXAMPLES = [ "prompt.example.1", @@ -133,7 +218,6 @@ export const PromptInput: Component = (props) => { let scrollRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement let restoreEndOnFocus = true - let savedCursor: number | null = null const mirror = { input: false } const inset = 56 @@ -435,7 +519,7 @@ export const PromptInput: Component = (props) => { const setMode = (mode: "normal" | "shell") => { setStore("mode", mode) - setStore({ popover: null, slashMenu: false, slashMenuQuery: "" }) + setStore("popover", null) requestAnimationFrame(() => editorRef?.focus()) } @@ -469,7 +553,7 @@ export const PromptInput: Component = (props) => { }, ]) - const closePopover = () => setStore({ popover: null, slashMenu: false, slashMenuQuery: "" }) + const closePopover = () => setStore("popover", null) const resetHistoryNavigation = (force = false) => { if (!force && (store.historyIndex < 0 || store.applyingHistory)) return @@ -506,7 +590,7 @@ export const PromptInput: Component = (props) => { const restoreFocus = () => { requestAnimationFrame(() => { - const cursor = savedCursor ?? prompt.cursor() ?? promptLength(prompt.current()) + const cursor = prompt.cursor() ?? promptLength(prompt.current()) editorRef.focus() setCursorPosition(editorRef, cursor) queueScroll() @@ -543,9 +627,6 @@ export const PromptInput: Component = (props) => { const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 const handleBlur = () => { - const cursor = currentCursor() - savedCursor = cursor - if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor) closePopover() setComposing(false) } @@ -591,7 +672,7 @@ export const PromptInput: Component = (props) => { type: "resource", name: resource.name, uri: resource.uri, - client: resource.server, + client: resource.client, display: resource.name, description: resource.description, mime: resource.mimeType, @@ -709,7 +790,7 @@ export const PromptInput: Component = (props) => { title: cmd.name, description: cmd.description, type: "custom" as const, - // source: cmd.source, + source: cmd.source, })) return [...custom, ...builtin] @@ -717,30 +798,17 @@ export const PromptInput: Component = (props) => { const handleSlashSelect = (cmd: SlashCommand | undefined) => { if (!cmd) return - const menu = store.slashMenu closePopover() const images = imageAttachments() if (cmd.type === "custom") { const text = `/${cmd.trigger} ` - if (menu) { - editorRef.focus() - setCursorPosition(editorRef, 0) - addPart({ type: "text", content: text, start: 0, end: text.length }) - focusEditorEnd() - return - } setEditorText(text) prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length) focusEditorEnd() return } - if (menu) { - command.trigger(cmd.id, "slash") - return - } - clearEditor() prompt.set([...DEFAULT_PROMPT, ...images], 0) command.trigger(cmd.id, "slash") @@ -1002,10 +1070,10 @@ export const PromptInput: Component = (props) => { if (atMatch) { atOnInput(atMatch[1]) - setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" }) + setStore("popover", "at") } else if (slashMatch) { slashOnInput(slashMatch[1]) - setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" }) + setStore("popover", "slash") } else { closePopover() } @@ -1129,7 +1197,7 @@ export const PromptInput: Component = (props) => { } setStore("mode", "normal") - closePopover() + setStore("popover", null) setStore("historyIndex", -1) setStore("savedPrompt", null) prompt.set(edit.prompt, promptLength(edit.prompt)) @@ -1216,17 +1284,13 @@ export const PromptInput: Component = (props) => { resetHistoryNavigation(true) }, setMode: (mode) => setStore("mode", mode), - setPopover: (popover) => { - if (!popover) return closePopover() - setStore({ popover, slashMenu: false, slashMenuQuery: "" }) - }, + setPopover: (popover) => setStore("popover", popover), newSessionWorktree: () => props.newSessionWorktree, onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, shouldQueue: props.shouldQueue, onQueue: props.onQueue, onAbort: props.onAbort, onSubmit: props.onSubmit, - model: props.controls.model.selection, }) const handleKeyDown = (event: KeyboardEvent) => { @@ -1259,7 +1323,7 @@ export const PromptInput: Component = (props) => { const cursorPosition = getCursorPosition(editorRef) if (cursorPosition === 0) { setStore("mode", "shell") - closePopover() + setStore("popover", null) event.preventDefault() return } @@ -1394,29 +1458,6 @@ export const PromptInput: Component = (props) => { } } - const handleSlashMenuKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - closePopover() - requestAnimationFrame(() => editorRef.focus()) - event.preventDefault() - return - } - - if (event.key === "Tab") { - selectPopoverActive() - event.preventDefault() - return - } - - const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey - const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter" - const ctrlNav = ctrl && (event.key === "n" || event.key === "p") - if (!nav && !ctrlNav) return - slashOnKeyDown(event) - if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) scrollSlashActiveIntoView() - event.preventDefault() - } - const agentsLoading = () => props.controls.agents.loading const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading()) const providersLoading = () => props.controls.model.loading @@ -1427,11 +1468,48 @@ export const PromptInput: Component = (props) => { (p) => p, ) + const designPlaceholder = () => { + if (store.mode === "shell") return placeholder() + return "Ask anything, / for commands, @ for context..." + } + + const modelControlState = createMemo(() => ({ + loading: providersLoading(), + shouldAnimate: providersShouldFadeIn(), + paid: props.controls.model.paid, + title: language.t("command.model.choose"), + keybind: command.keybindParts("model.choose"), + model: props.controls.model.selection, + providerID: props.controls.model.selection.current()?.provider?.id, + modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"), + newLayoutDesigns: props.controls.newLayoutDesigns, + style: control(), + onClose: restoreFocus, + onUnpaidClick: () => { + void import("@/components/dialog-select-model-unpaid").then((x) => { + dialog.show(() => ) + }) + }, + })) + + const newSession = () => props.variant === "new-session" const bindEditorRef = (el: HTMLDivElement) => { editorRef = el restoreEndOnFocus = true props.ref?.(el) } + const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0) + const agentControlState = createMemo(() => ({ + title: language.t("command.agent.cycle"), + keybind: command.keybindParts("agent.cycle"), + options: props.controls.agents.options, + current: props.controls.agents.current, + style: control(), + onSelect: (value) => { + props.controls.agents.select(value) + restoreFocus() + }, + })) return (
{(promptReady(), null)} @@ -1447,290 +1525,488 @@ export const PromptInput: Component = (props) => { slashActive={slashActive() ?? undefined} setSlashActive={setSlashActive} onSlashSelect={handleSlashSelect} - slashMenu={store.slashMenu} - slashMenuQuery={store.slashMenuQuery} - onSlashMenuInput={(value) => { - setStore("slashMenuQuery", value) - slashOnInput(value) - }} - onSlashMenuKeyDown={handleSlashMenuKeyDown} commandKeybind={command.keybind} commandKeybindParts={command.keybindParts} - newLayoutDesigns={false} + newLayoutDesigns={props.controls.newLayoutDesigns} t={(key) => language.t(key as Parameters[0])} /> - - - { - 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) - }} - newLayoutDesigns={false} - t={(key) => language.t(key as Parameters[0])} - /> - - dialog.show(() => ) - } - onRemove={removeAttachment} - removeLabel={language.t("prompt.attachment.remove")} - newLayoutDesigns={false} - /> -
{ - const target = e.target - if (!(target instanceof HTMLElement)) return - if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) { - return - } - editorRef?.focus() - }} - > -
(scrollRef = el)} - style={{ "scroll-padding-bottom": space }} - > -
+ +
+ -
- {placeholder()} -
-
- - - - - -
-
+ + { + 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={removeAttachment} + removeLabel={language.t("prompt.attachment.remove")} + />
{ + const target = e.target + if (!(target instanceof HTMLElement)) return + if (target.closest('[data-action^="prompt-"]')) return + editorRef?.focus() }} > - - {language.t("prompt.mode.shell")} -
-
+ + + + + { + 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={removeAttachment} + removeLabel={language.t("prompt.attachment.remove")} + /> +
{ + const target = e.target + if (!(target instanceof HTMLElement)) return + if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) { + return + } + editorRef?.focus() + }} + > +
(scrollRef = el)} + style={{ "scroll-padding-bottom": space }} + > +
+
+ {placeholder()} +
+
+ + )} - -
-
-
- - {language.t("provider.custom.title")} - {language.t("settings.providers.tag.custom")} -
- - {language.t("settings.providers.custom.description")} - +
+
+
+ + {language.t("provider.custom.title")} + {language.t("settings.providers.tag.custom")}
- + + {language.t("settings.providers.custom.description")} +
- + +
diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index 4116f4a620..a93a10dcb2 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -1,4 +1,4 @@ -import { Component, createMemo, createSignal, startTransition } from "solid-js" +import { Component } from "solid-js" import { Dialog } from "@opencode-ai/ui/v2/dialog-v2" import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" import { Icon } from "@opencode-ai/ui/icon" @@ -10,46 +10,16 @@ import { SettingsProvidersV2 } from "./providers" import { SettingsModelsV2 } from "./models" import "./settings-v2.css" import { SettingsServersV2 } from "./servers" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useLayout } from "@/context/layout" -import { useTabs } from "@/context/tabs" -import { useServerSync } from "@/context/server-sync" export const DialogSettings: Component<{ sessionID?: string - defaultValue?: string }> = (props) => { const language = useLanguage() const platform = usePlatform() - const dialog = useDialog() - const layout = useLayout() - const tabs = useTabs() - const serverSync = useServerSync() - const [tab, setTab] = createSignal(props.defaultValue ?? "general") - const directory = createMemo(() => { - const route = layout.route() - if (route.type === "dir-new-sesssion") return route.dir - if (route.type === "draft") { - const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) - return draft?.type === "draft" ? draft.directory : undefined - } - if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory - return undefined - }) - - const showProviders = () => { - void dialog.show(() => ) - } return ( - void startTransition(() => setTab(value))} - class="settings-v2" - > +
@@ -103,7 +73,7 @@ export const DialogSettings: Component<{ - + diff --git a/packages/app/src/components/settings-v2/general-controller-behavior.ts b/packages/app/src/components/settings-v2/general-controller-behavior.ts deleted file mode 100644 index 64bd4c1187..0000000000 --- a/packages/app/src/components/settings-v2/general-controller-behavior.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { onCleanup } from "solid-js" - -export type ShellOption = { - path: string - name: string - acceptable: boolean -} - -export type ShellSelectOption = { - id: string - value: string - name: string - terminalOnly: boolean -} - -export function createShellOptions(input: { shells: ShellOption[]; current: string | undefined }) { - const counts = input.shells.reduce((result, shell) => { - result.set(shell.name, (result.get(shell.name) ?? 0) + 1) - return result - }, new Map()) - const options: ShellSelectOption[] = [ - { id: "auto", value: "", name: "", terminalOnly: false }, - ...input.shells.map((shell) => { - const ambiguous = (counts.get(shell.name) ?? 0) > 1 - const name = ambiguous ? shell.path : shell.name - return { - id: shell.path, - value: ambiguous ? shell.path : shell.name, - name, - terminalOnly: !shell.acceptable, - } - }), - ] - if (input.current && !options.some((option) => option.value === input.current)) { - options.push({ id: input.current, value: input.current, name: input.current, terminalOnly: false }) - } - return options -} - -export function createSoundPreviewController(player: (id: string | undefined) => Promise<(() => void) | undefined>) { - let cleanup: (() => void) | undefined - let timeout: ReturnType | undefined - let run = 0 - - const stop = () => { - run += 1 - cleanup?.() - clearTimeout(timeout) - cleanup = undefined - timeout = undefined - } - const play = (id: string | undefined) => { - stop() - if (!id) return - const current = ++run - timeout = setTimeout(() => { - timeout = undefined - void player(id).then((next) => { - if (run === current) { - cleanup = next - return - } - next?.() - }) - }, 100) - } - - onCleanup(stop) - return { play, stop } -} diff --git a/packages/app/src/components/settings-v2/general-controllers.test.ts b/packages/app/src/components/settings-v2/general-controllers.test.ts deleted file mode 100644 index 09c8d21bd5..0000000000 --- a/packages/app/src/components/settings-v2/general-controllers.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, test, vi } from "bun:test" -import { createRoot } from "solid-js" -import { createShellOptions, createSoundPreviewController } from "./general-controller-behavior" - -describe("settings v2 controllers", () => { - test("normalizes shell names and preserves an unavailable configured shell", () => { - expect( - createShellOptions({ - shells: [ - { path: "/bin/bash", name: "bash", acceptable: true }, - { path: "/opt/bash", name: "bash", acceptable: false }, - { path: "/bin/zsh", name: "zsh", acceptable: true }, - ], - current: "fish", - }), - ).toEqual([ - { id: "auto", value: "", name: "", terminalOnly: false }, - { id: "/bin/bash", value: "/bin/bash", name: "/bin/bash", terminalOnly: false }, - { id: "/opt/bash", value: "/opt/bash", name: "/opt/bash", terminalOnly: true }, - { id: "/bin/zsh", value: "zsh", name: "zsh", terminalOnly: false }, - { id: "fish", value: "fish", name: "fish", terminalOnly: false }, - ]) - }) - - test("debounces previews and stops owned audio on disposal", async () => { - vi.useFakeTimers() - try { - const played: string[] = [] - const stopped: string[] = [] - const owned = createRoot((dispose) => ({ - dispose, - preview: createSoundPreviewController(async (id) => { - played.push(id ?? "") - return () => stopped.push(id ?? "") - }), - })) - - owned.preview.play("first") - vi.advanceTimersByTime(99) - expect(played).toEqual([]) - - owned.preview.play("second") - vi.advanceTimersByTime(100) - await Promise.resolve() - expect(played).toEqual(["second"]) - - owned.dispose() - expect(stopped).toEqual(["second"]) - } finally { - vi.useRealTimers() - } - }) -}) diff --git a/packages/app/src/components/settings-v2/general-controllers.ts b/packages/app/src/components/settings-v2/general-controllers.ts deleted file mode 100644 index ae77fa332f..0000000000 --- a/packages/app/src/components/settings-v2/general-controllers.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { createMemo, createResource, onMount, type Accessor } from "solid-js" -import type { ColorScheme } from "@opencode-ai/ui/theme/context" -import { useTheme } from "@opencode-ai/ui/theme/context" -import { usePermission } from "@/context/permission" -import { useServerSDK } from "@/context/server-sdk" -import { useServerSync } from "@/context/server-sync" -import { - monoDefault, - monoFontFamily, - monoInput, - sansDefault, - sansFontFamily, - sansInput, - terminalDefault, - terminalFontFamily, - terminalInput, - useSettings, -} from "@/context/settings" -import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" -import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior" - -export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior" -export type { ShellOption, ShellSelectOption } from "./general-controller-behavior" - -export function createPermissionScopeController(sessionID: Accessor) { - const permission = usePermission() - const serverSync = useServerSync() - const directory = createMemo(() => { - const id = sessionID() - if (!id) return undefined - return serverSync().session.lineage.peek(id)?.session.directory - }) - - return { - accepting: createMemo(() => { - const id = sessionID() - const dir = directory() - if (!id || !dir) return false - return permission.isAutoAccepting(id, dir) - }), - enabled: createMemo(() => !!directory()), - set: (checked: boolean) => { - const id = sessionID() - const dir = directory() - if (!id || !dir) return - if (checked) return permission.enableAutoAccept(id, dir) - permission.disableAutoAccept(id, dir) - }, - } -} - -export function createShellSettingsController() { - const serverSdk = useServerSDK() - const serverSync = useServerSync() - const [shells] = createResource( - async () => { - const sdk = serverSdk() - if ((await sdk.protocol) === "v1") return (await sdk.client.pty.shells()).data ?? [] - return [] as ShellOption[] - }, - { initialValue: [] as ShellOption[] }, - ) - const current = createMemo(() => serverSync().data.config.shell ?? "") - - return { - shells: () => shells.latest, - current, - select: (value: string) => { - if (value === current()) return - void serverSync().updateConfig({ shell: value }) - }, - } -} - -export function createAppearanceSettingsController() { - const settings = useSettings() - const theme = useTheme() - const themes = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) - - onMount(() => void theme.loadThemes()) - - return { - scheme: { - current: theme.colorScheme, - select: (value: ColorScheme) => theme.setColorScheme(value), - }, - theme: { - options: themes, - current: createMemo(() => themes().find((option) => option.id === theme.themeId())), - select: (option: { id: string } | null) => option && theme.setTheme(option.id), - }, - fonts: { - ui: createMemo(() => ({ - value: sansInput(settings.appearance.uiFont()), - family: sansFontFamily(settings.appearance.uiFont()), - placeholder: sansDefault, - })), - code: createMemo(() => ({ - value: monoInput(settings.appearance.font()), - family: monoFontFamily(settings.appearance.font()), - placeholder: monoDefault, - })), - terminal: createMemo(() => ({ - value: terminalInput(settings.appearance.terminalFont()), - family: terminalFontFamily(settings.appearance.terminalFont()), - placeholder: terminalDefault, - })), - setUI: (value: string) => settings.appearance.setUIFont(value), - setCode: (value: string) => settings.appearance.setFont(value), - setTerminal: (value: string) => settings.appearance.setTerminalFont(value), - }, - } -} - -const noneSound = { id: "none", label: "sound.option.none" } as const -export const soundOptions = [noneSound, ...SOUND_OPTIONS] -export type SoundSelectOption = (typeof soundOptions)[number] - -export function createSoundSettingsController() { - const settings = useSettings() - const preview = createSoundPreviewController(playSoundById) - const channel = ( - enabled: Accessor, - current: Accessor, - setEnabled: (value: boolean) => void, - set: (id: string) => void, - ) => ({ - current: createMemo(() => - enabled() ? (soundOptions.find((option) => option.id === current()) ?? noneSound) : noneSound, - ), - highlight: (option: SoundSelectOption | undefined) => { - if (!option) return - preview.play(option.id === "none" ? undefined : option.id) - }, - select: (option: SoundSelectOption | null) => { - if (!option) return - if (option.id === "none") { - setEnabled(false) - preview.stop() - return - } - setEnabled(true) - set(option.id) - preview.play(option.id) - }, - }) - - return { - agent: channel( - settings.sounds.agentEnabled, - settings.sounds.agent, - (value) => settings.sounds.setAgentEnabled(value), - (id) => settings.sounds.setAgent(id), - ), - permissions: channel( - settings.sounds.permissionsEnabled, - settings.sounds.permissions, - (value) => settings.sounds.setPermissionsEnabled(value), - (id) => settings.sounds.setPermissions(id), - ), - errors: channel( - settings.sounds.errorsEnabled, - settings.sounds.errors, - (value) => settings.sounds.setErrorsEnabled(value), - (id) => settings.sounds.setErrors(id), - ), - } -} - -export type PermissionScopeController = ReturnType -export type ShellSettingsController = ReturnType -export type AppearanceSettingsController = ReturnType -export type SoundSettingsController = ReturnType diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index b41da107ed..0dd48a5523 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -1,297 +1,178 @@ -import { Component, Show, createMemo, createResource } from "solid-js" +import { Component, Show, createMemo, createResource, onMount } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" +import { usePermission } from "@/context/permission" import { usePlatform } from "@/context/platform" +import { useServerSync } from "@/context/server-sync" +import { useServerSDK } from "@/context/server-sdk" import { useUpdaterAction } from "../updater-action" -import { useSettings } from "@/context/settings" -import { ExternalLink } from "../external-link" +import { + monoDefault, + monoFontFamily, + monoInput, + sansDefault, + sansFontFamily, + sansInput, + terminalDefault, + terminalFontFamily, + terminalInput, + useSettings, +} from "@/context/settings" +import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" +import { Link } from "../link" import { SettingsListV2 } from "./parts/list" import { SettingsRowV2 } from "./parts/row" -import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition" -import { - createAppearanceSettingsController, - createPermissionScopeController, - createShellOptions, - createShellSettingsController, - createSoundSettingsController, - soundOptions, - type AppearanceSettingsController, - type PermissionScopeController, - type ShellSettingsController, - type SoundSettingsController, -} from "./general-controllers" import "./settings-v2.css" -const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"] -const fontSettings = { - ui: { - action: "settings-ui-font", - title: "settings.general.row.uiFont.title", - description: "settings.general.row.uiFont.description", - font: "ui", - input: "setUI", - }, - code: { - action: "settings-code-font", - title: "settings.general.row.font.title", - description: "settings.general.row.font.description", - font: "code", - input: "setCode", - }, - terminal: { - action: "settings-terminal-font", - title: "settings.general.row.terminalFont.title", - description: "settings.general.row.terminalFont.description", - font: "terminal", - input: "setTerminal", - }, -} as const -const soundSettings = { - agent: { - action: "settings-sounds-agent", - title: "settings.general.sounds.agent.title", - description: "settings.general.sounds.agent.description", - }, - permissions: { - action: "settings-sounds-permissions", - title: "settings.general.sounds.permissions.title", - description: "settings.general.sounds.permissions.description", - }, - errors: { - action: "settings-sounds-errors", - title: "settings.general.sounds.errors.title", - description: "settings.general.sounds.errors.description", - }, -} as const - -const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => { - const language = useLanguage() - return ( - -
- -
-
- ) +let demoSoundState = { + cleanup: undefined as (() => void) | undefined, + timeout: undefined as NodeJS.Timeout | undefined, + run: 0, } -const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => { - const language = useLanguage() - const options = createMemo(() => - createShellOptions({ - shells: props.controller.shells(), - current: props.controller.current(), - }), - ) - return ( - - option.value === props.controller.current()) ?? options()[0]} - placement="bottom-end" - gutter={6} - value={(option) => option.id} - label={(option) => { - if (option.id === "auto") return language.t("settings.general.row.shell.autoDefault") - if (!option.terminalOnly) return option.name - return `${option.name} (${language.t("settings.general.row.shell.terminalOnly")})` - }} - onSelect={(option) => option && props.controller.select(option.value)} - /> - - ) +type ThemeOption = { + id: string + name: string } -const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => { - const language = useLanguage() - return ( -
-

{language.t("settings.general.section.appearance")}

- - - option === props.controller.scheme.current())} - placement="bottom-end" - gutter={6} - label={(option) => { - if (option === "system") return language.t("theme.scheme.system") - if (option === "light") return language.t("theme.scheme.light") - return language.t("theme.scheme.dark") - }} - onSelect={(option) => option && props.controller.scheme.select(option)} - /> - - - - {language.t("settings.general.row.theme.description")}{" "} - - {language.t("common.learnMore")} - - - } - > - option.id} - label={(option) => option.name} - onSelect={props.controller.theme.select} - /> - - - - - - -
- ) +type ShellOption = { + path: string + name: string + acceptable: boolean } -const FontSetting: Component<{ - kind: "ui" | "code" | "terminal" - fonts: AppearanceSettingsController["fonts"] -}> = (props) => { - const language = useLanguage() - const config = () => fontSettings[props.kind] - return ( - -
- props.fonts[config().input](event.currentTarget.value)} - placeholder={props.fonts[config().font]().placeholder} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - aria-label={language.t(config().title)} - style={{ "font-family": props.fonts[config().font]().family }} - /> -
-
- ) +type ShellSelectOption = { + id: string + value: string + label: string } -const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => { - const language = useLanguage() - return ( -
-

{language.t("settings.general.section.sounds")}

- - - - - -
- ) +// To prevent audio from overlapping/playing very quickly when navigating the settings menus, +// delay the playback by 100ms during quick selection changes and pause existing sounds. +const stopDemoSound = () => { + demoSoundState.run += 1 + if (demoSoundState.cleanup) { + demoSoundState.cleanup() + } + clearTimeout(demoSoundState.timeout) + demoSoundState.cleanup = undefined } -const SoundSetting: Component<{ - kind: "agent" | "permissions" | "errors" - channel: SoundSettingsController["agent"] -}> = (props) => { - const language = useLanguage() - const config = () => soundSettings[props.kind] - return ( - - option.id} - label={(option) => language.t(option.label)} - onHighlight={props.channel.highlight} - onSelect={props.channel.select} - placement="bottom-end" - gutter={6} - /> - - ) -} +const playDemoSound = (id: string | undefined) => { + stopDemoSound() + if (!id) return -const LanguageSetting = () => { - const language = useLanguage() - const options = createMemo(() => - language.locales.map((locale) => ({ - value: locale, - label: language.label(locale), - })), - ) - return ( - - option.value === language.locale())} - value={(option) => option.value} - label={(option) => option.label} - onSelect={(option) => option && language.setLocale(option.value)} - /> - - ) + const run = ++demoSoundState.run + demoSoundState.timeout = setTimeout(() => { + void playSoundById(id).then((cleanup) => { + if (demoSoundState.run !== run) { + cleanup?.() + return + } + demoSoundState.cleanup = cleanup + }) + }, 100) } export const SettingsGeneralV2: Component<{ sessionID?: string }> = (props) => { + const theme = useTheme() const language = useLanguage() + const permission = usePermission() const platform = usePlatform() const dialog = useDialog() const settings = useSettings() + const serverSync = useServerSync() + const serverSdk = useServerSDK() const mobile = createMediaQuery("(max-width: 767px)") + const updater = useUpdaterAction() - const permissionScope = createPermissionScopeController(() => props.sessionID) - const shell = createShellSettingsController() - const appearance = createAppearanceSettingsController() - const sounds = createSoundSettingsController() + + const dir = createMemo(() => { + if (!props.sessionID) return undefined + return serverSync().session.lineage.peek(props.sessionID)?.session.directory + }) + const accepting = createMemo(() => { + const value = dir() + if (!value || !props.sessionID) return false + return permission.isAutoAccepting(props.sessionID, value) + }) + + const toggleAccept = (checked: boolean) => { + const value = dir() + if (!value || !props.sessionID) return + + if (checked) { + permission.enableAutoAccept(props.sessionID, value) + return + } + + permission.disableAutoAccept(props.sessionID, value) + } const desktop = createMemo(() => platform.platform === "desktop") + const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) + + const [shells] = createResource( + () => + serverSdk() + .client.pty.shells() + .then((res) => res.data ?? []) + .catch(() => [] as ShellOption[]), + { initialValue: [] as ShellOption[] }, + ) + const [pinchZoom, { mutate: setPinchZoom }] = createResource( - () => desktop() && "getPinchZoomEnabled" in platform, + () => (desktop() && platform.getPinchZoomEnabled ? true : false), () => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false), { initialValue: false }, ) + onMount(() => { + void theme.loadThemes() + }) + + const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") } + const currentShell = createMemo(() => serverSync().data.config.shell ?? "") + + const shellOptions = createMemo(() => { + const list = shells.latest + const current = serverSync().data.config.shell + + const nameCounts = new Map() + for (const s of list) { + nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1) + } + + const options = [ + autoOption, + ...list.map((s) => { + const ambiguousName = (nameCounts.get(s.name) || 0) > 1 + const text = ambiguousName ? s.path : s.name + const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})` + return { + id: s.path, + // Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH. + value: ambiguousName ? s.path : s.name, + label, + } + }), + ] + + if (current && !options.some((o) => o.value === current)) { + options.push({ id: current, value: current, label: current }) + } + + return options + }) + const onPinchZoomChange = (checked: boolean) => { setPinchZoom(checked) const update = platform.setPinchZoomEnabled?.(checked) @@ -299,39 +180,101 @@ export const SettingsGeneralV2: Component<{ void update.catch(() => setPinchZoom(!checked)) } - const InterfaceSection = () => ( - { - settings.general.setNewLayoutDesigns(checked) - if (checked) return - void import("@/components/dialog-settings").then((module) => { - void dialog.show(() => ) - }) - }} - /> + const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ + { value: "system", label: language.t("theme.scheme.system") }, + { value: "light", label: language.t("theme.scheme.light") }, + { value: "dark", label: language.t("theme.scheme.dark") }, + ]) + + const languageOptions = createMemo(() => + language.locales.map((locale) => ({ + value: locale, + label: language.label(locale), + })), ) - const InterfaceNoticeSection = () => ( - settings.general.dismissNewInterfaceNotice()} - /> - ) + const noneSound = { id: "none", label: "sound.option.none" } as const + const soundOptions = [noneSound, ...SOUND_OPTIONS] + const mono = () => monoInput(settings.appearance.font()) + const sans = () => sansInput(settings.appearance.uiFont()) + const terminal = () => terminalInput(settings.appearance.terminalFont()) + + const soundSelectProps = ( + enabled: () => boolean, + current: () => string, + setEnabled: (value: boolean) => void, + set: (id: string) => void, + ) => ({ + options: soundOptions, + current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound, + value: (o: (typeof soundOptions)[number]) => o.id, + label: (o: (typeof soundOptions)[number]) => language.t(o.label), + onHighlight: (option: (typeof soundOptions)[number] | undefined) => { + if (!option) return + playDemoSound(option.id === "none" ? undefined : option.id) + }, + onSelect: (option: (typeof soundOptions)[number] | null) => { + if (!option) return + if (option.id === "none") { + setEnabled(false) + stopDemoSound() + return + } + setEnabled(true) + set(option.id) + playDemoSound(option.id) + }, + }) const GeneralSection = () => (
- + + o.value === language.locale())} + value={(o) => o.value} + label={(o) => o.label} + onSelect={(option) => option && language.setLocale(option.value)} + /> + - + +
+ +
+
- + + o.value === currentShell()) ?? autoOption} + placement="bottom-end" + gutter={6} + value={(o) => o.id} + label={(o) => o.label} + onSelect={(option) => { + if (!option) return + if (option.value === currentShell()) return + serverSync().updateConfig({ shell: option.value }) + }} + /> + + +
+ { + settings.general.setNewLayoutDesigns(checked) + if (checked) return + void import("@/components/dialog-settings").then((module) => { + dialog.show(() => ) + }) + }} + /> +
+
+ ) + const AppearanceSection = () => ( +
+

{language.t("settings.general.section.appearance")}

+ + + + o.value === theme.colorScheme())} + placement="bottom-end" + gutter={6} + value={(o) => o.value} + label={(o) => o.label} + onSelect={(option) => option && theme.setColorScheme(option.value)} + onHighlight={(option) => { + if (!option) return + theme.previewColorScheme(option.value) + return () => theme.cancelPreview() + }} + /> + + + + {language.t("settings.general.row.theme.description")}{" "} + + {language.t("common.learnMore")} + + + } + > + o.id === theme.themeId())} + placement="bottom-end" + gutter={6} + value={(o) => o.id} + label={(o) => o.name} + onSelect={(option) => { + if (!option) return + theme.setTheme(option.id) + }} + onHighlight={(option) => { + if (!option) return + theme.previewTheme(option.id) + return () => theme.cancelPreview() + }} + /> + + + +
+ settings.appearance.setUIFont(event.currentTarget.value)} + placeholder={sansDefault} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + aria-label={language.t("settings.general.row.uiFont.title")} + style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }} + /> +
+
+ + +
+ settings.appearance.setFont(event.currentTarget.value)} + placeholder={monoDefault} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + aria-label={language.t("settings.general.row.font.title")} + style={{ "font-family": monoFontFamily(settings.appearance.font()) }} + /> +
+
+ + +
+ settings.appearance.setTerminalFont(event.currentTarget.value)} + placeholder={terminalDefault} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + aria-label={language.t("settings.general.row.terminalFont.title")} + style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }} + /> +
+
+
+
+ ) + const NotificationsSection = () => (

{language.t("settings.general.section.notifications")}

@@ -486,6 +575,68 @@ export const SettingsGeneralV2: Component<{
) + const SoundsSection = () => ( +
+

{language.t("settings.general.section.sounds")}

+ + + + settings.sounds.agentEnabled(), + () => settings.sounds.agent(), + (value) => settings.sounds.setAgentEnabled(value), + (id) => settings.sounds.setAgent(id), + )} + placement="bottom-end" + gutter={6} + /> + + + + settings.sounds.permissionsEnabled(), + () => settings.sounds.permissions(), + (value) => settings.sounds.setPermissionsEnabled(value), + (id) => settings.sounds.setPermissions(id), + )} + placement="bottom-end" + gutter={6} + /> + + + + settings.sounds.errorsEnabled(), + () => settings.sounds.errors(), + (value) => settings.sounds.setErrorsEnabled(value), + (id) => settings.sounds.setErrors(id), + )} + placement="bottom-end" + gutter={6} + /> + + +
+ ) + const UpdatesSection = () => (

{language.t("settings.general.section.updates")}

@@ -507,7 +658,7 @@ export const SettingsGeneralV2: Component<{ title={language.t("settings.updates.row.check.title")} description={language.t("settings.updates.row.check.description")} > - updater.run()}> + {language.t(updater.action().label)} @@ -542,21 +693,13 @@ export const SettingsGeneralV2: Component<{
- - - - - - - - - + - + diff --git a/packages/app/src/components/settings-v2/interface-transition.stories.tsx b/packages/app/src/components/settings-v2/interface-transition.stories.tsx deleted file mode 100644 index 0a48de9572..0000000000 --- a/packages/app/src/components/settings-v2/interface-transition.stories.tsx +++ /dev/null @@ -1,76 +0,0 @@ -// @ts-nocheck -import { Show } from "solid-js" -import { createStore } from "solid-js/store" -import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition" - -const copy = { - title: "New layout", - badge: "New", - description: "Use the new tabs and home layout. Switch between layouts for a limited time.", - noticeTitle: "You're now using new layout", - noticeDescription: "The previous layout is no longer available", - dismiss: "Dismiss", -} - -function Frame(props) { - return
{props.children}
-} - -function ToggleExample(props) { - const [state, setState] = createStore({ checked: props.checked }) - return ( - - setState("checked", checked)} - /> - - ) -} - -function NoticeExample() { - const [state, setState] = createStore({ dismissed: false }) - return ( - - Notice dismissed}> - setState("dismissed", true)} - /> - - - ) -} - -export default { - title: "App/Settings/Layout transition", - id: "app-settings-layout-transition", - component: LayoutTransitionToggle, -} - -export const NewLayoutEnabled = { - render: () => , -} - -export const PreviousLayoutEnabled = { - render: () => , -} - -export const PreviousLayoutRetired = { - render: () => , -} - -export const AllStates = { - render: () => ( -
- - - -
- ), -} diff --git a/packages/app/src/components/settings-v2/interface-transition.tsx b/packages/app/src/components/settings-v2/interface-transition.tsx deleted file mode 100644 index 53bd980a54..0000000000 --- a/packages/app/src/components/settings-v2/interface-transition.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Tag } from "@opencode-ai/ui/v2/badge-v2" -import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" -import { Switch } from "@opencode-ai/ui/v2/switch-v2" -import { SettingsListV2 } from "./parts/list" -import { SettingsRowV2 } from "./parts/row" - -export function LayoutTransitionToggle(props: { - title: string - badge: string - description: string - checked: boolean - onChange: (checked: boolean) => void -}) { - return ( -
-
- - - {props.title} - {props.badge} - - } - description={props.description} - > -
- -
-
-
-
-
- ) -} - -export function LayoutRetirementNotice(props: { - title: string - description: string - dismiss: string - onDismiss: () => void -}) { - return ( -
- - - - {props.dismiss} - - - -
- ) -} diff --git a/packages/app/src/components/settings-v2/models.tsx b/packages/app/src/components/settings-v2/models.tsx index 482e8cbffb..a3f058670e 100644 --- a/packages/app/src/components/settings-v2/models.tsx +++ b/packages/app/src/components/settings-v2/models.tsx @@ -5,12 +5,9 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { type Component, For, Show } from "solid-js" -import { createStore } from "solid-js/store" import { useLanguage } from "@/context/language" import { useModels } from "@/context/models" -import { useServerSDK } from "@/context/server-sdk" import { popularProviders } from "@/hooks/use-providers" -import { Persist, persisted } from "@/utils/persist" import { SettingsListV2 } from "./parts/list" import { SettingsRowV2 } from "./parts/row" import "./settings-v2.css" @@ -22,11 +19,6 @@ const PROVIDER_ICON_SIZE = 16 export const SettingsModelsV2: Component = () => { const language = useLanguage() const models = useModels() - const serverSdk = useServerSDK() - const [store, setStore] = persisted( - Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"), - createStore({ collapsed: {} as Record }), - ) const list = useFilteredList({ items: (_filter) => models.list(), @@ -102,82 +94,41 @@ export const SettingsModelsV2: Component = () => { } > - {(group) => { - const searching = () => list.filter().length > 0 - const expanded = () => searching() || !store.collapsed[group.category] - - return ( -
-

- -

- - - - {(item) => { - const key = { providerID: item.provider.id, modelID: item.id } - return ( - -
- { - models.setVisibility(key, checked) - }} - hideLabel - > - {item.name} - -
-
- ) - }} -
-
-
+ {(group) => ( +
+
+ +

{group.items[0].provider.name}

- ) - }} + + + {(item) => { + const key = { providerID: item.provider.id, modelID: item.id } + return ( + +
+ { + models.setVisibility(key, checked) + }} + hideLabel + > + {item.name} + +
+
+ ) + }} +
+
+
+ )} diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index acd73ddbe5..cd24bbd455 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -4,11 +4,12 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { showToast } from "@/utils/toast" import { popularProviders, useProviders } from "@/hooks/use-providers" -import { createMemo, type Accessor, type Component, For, Show } from "solid-js" +import { createMemo, type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" -import { useServerProtocol, useServerSDK } from "@/context/server-sdk" +import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" -import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider" +import { DialogConnectProvider } from "../dialog-connect-provider" +import { DialogSelectProvider } from "../dialog-select-provider" import { DialogCustomProvider } from "../dialog-custom-provider" import { SettingsListV2 } from "./parts/list" import "./settings-v2.css" @@ -29,22 +30,12 @@ const PROVIDER_NOTES = [ const PROVIDER_ICON_SIZE = 16 -export const SettingsProvidersV2: Component<{ - directory: Accessor - onBack?: () => void -}> = (props) => { +export const SettingsProvidersV2: Component = () => { const dialog = useDialog() const language = useLanguage() const serverSdk = useServerSDK() - const protocol = useServerProtocol() const serverSync = useServerSync() - const providers = useProviders(props.directory) - const providerConnect = useProviderConnectController({ onBack: props.onBack }) - - const connect = (provider?: string) => { - providerConnect.select(provider) - void dialog.show(() => ) - } + const providers = useProviders() const connected = createMemo(() => { return providers @@ -81,8 +72,7 @@ export const SettingsProvidersV2: Component<{ return language.t("settings.providers.tag.other") } - const canDisconnect = (item: ProviderItem) => - source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id)) + const canDisconnect = (item: ProviderItem) => source(item) !== "env" const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key @@ -95,7 +85,6 @@ export const SettingsProvidersV2: Component<{ } const disableProvider = async (providerID: string, name: string) => { - if (protocol() !== "v1") return const before = serverSync().data.config.disabled_providers ?? [] const next = before.includes(providerID) ? before : [...before, providerID] serverSync().set("config", "disabled_providers", next) @@ -217,47 +206,56 @@ export const SettingsProvidersV2: Component<{
- connect(item.id)}> + { + dialog.show(() => ) + }} + > {language.t("common.connect")}
)} - -
-
- -
-
- {language.t("provider.custom.title")} - {language.t("settings.providers.tag.custom")} -
-

- {language.t("settings.providers.custom.description")} -

+
+
+ +
+
+ {language.t("provider.custom.title")} + {language.t("settings.providers.tag.custom")}
+

{language.t("settings.providers.custom.description")}

- { - dialog.show(() => ) - }} - > - {language.t("common.connect")} -
- + { + dialog.show(() => ) + }} + > + {language.t("common.connect")} + +
-
diff --git a/packages/app/src/components/settings-v2/settings-v2.css b/packages/app/src/components/settings-v2/settings-v2.css index c4d47344a2..de59bbc6fe 100644 --- a/packages/app/src/components/settings-v2/settings-v2.css +++ b/packages/app/src/components/settings-v2/settings-v2.css @@ -20,11 +20,6 @@ height: 100%; overflow-y: auto; scrollbar-width: none; - user-select: none; -} - -.settings-v2-panel :is(input, textarea, [contenteditable="true"]) { - user-select: text; } .settings-v2-panel::-webkit-scrollbar { @@ -90,11 +85,6 @@ box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted); } -.settings-v2-interface-feature [data-component="settings-v2-list"] { - background-color: var(--v2-background-bg-base); - box-shadow: var(--v2-elevation-raised); -} - [data-component="settings-v2-row"] { display: flex; flex-wrap: wrap; @@ -133,10 +123,9 @@ } [data-slot="settings-v2-row-description"] { - margin-block: -3.5px; - font-size: 13px; + font-size: 11px; font-weight: 440; - line-height: 20px; + line-height: 1; color: var(--v2-text-text-muted); } @@ -192,7 +181,6 @@ flex-direction: column; gap: 8px; padding: 4px 0 4px 4px; - user-select: none; } .settings-v2-nav-footer > span { @@ -271,10 +259,10 @@ } .settings-v2-provider-description { - margin-block: -3.5px; + margin: 0; font-size: 13px; font-weight: 440; - line-height: 20px; + line-height: 1; color: var(--v2-text-text-muted); } @@ -372,65 +360,15 @@ transform: translateY(-50%); } -.settings-v2-models { - gap: 12px; -} - -.settings-v2-models .settings-v2-section { - gap: 12px; -} - -.settings-v2-models .settings-v2-section[data-expanded] { - padding-bottom: 12px; +.settings-v2-tab-body.settings-v2-models { + gap: 24px; } .settings-v2-models-group-header { - display: flex; - align-items: center; - height: 28px; -} - -.settings-v2-models-group-trigger { - display: flex; - align-items: center; - gap: 4px; - height: 28px; - padding: 4px 8px 4px 4px; - border: 0; - border-radius: 4px; - background: transparent; - color: var(--v2-text-text-base); -} - -.settings-v2-models-group-trigger:focus-visible { - outline: 2px solid var(--v2-border-border-focus); - outline-offset: 2px; -} - -@media (hover: hover) { - .settings-v2-models-group-trigger:not(:disabled):hover { - background-color: var(--v2-background-bg-layer-02); - } -} - -.settings-v2-models-group-trigger:disabled { - cursor: default; -} - -.settings-v2-models-group-chevron { - display: flex; - width: 20px; - height: 20px; - flex-shrink: 0; - align-items: center; - justify-content: center; - color: var(--v2-icon-icon-muted); -} - -.settings-v2-models-group-label { display: flex; align-items: center; gap: 8px; + padding-bottom: 8px; } .settings-v2-models .settings-v2-section-title { @@ -444,10 +382,6 @@ color: var(--v2-icon-icon-base); } -.settings-v2-models [data-component="settings-v2-list"] { - padding-inline: 16px; -} - .settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] { margin-top: 0; } @@ -465,7 +399,7 @@ overflow: hidden; font-size: 13px; font-weight: 440; - line-height: 16px; + line-height: 1; text-overflow: ellipsis; white-space: nowrap; } diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index 97982fa21f..68a3f6b226 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -16,7 +16,6 @@ import { type ServerHealth } from "@/utils/server-health" import { useGlobal } from "@/context/global" import { useSettings } from "@/context/settings" import { useMcpToggle } from "@/context/mcp" -import { useServerProtocol } from "@/context/server-sdk" const pluginEmptyMessage = (value: string, file: string): JSXElement => { const parts = value.split(file) @@ -258,7 +257,6 @@ export function StatusPopoverBody(props: { shown: Accessor }) { const language = useLanguage() const navigate = useNavigate() const settings = useSettings() - const protocol = useServerProtocol() const fail = (err: unknown) => { showToast({ @@ -278,12 +276,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) { dialogDead = true dialogRun += 1 }) - const sortedServers = createMemo(() => { - const list = settings.general.newLayoutDesigns() - ? global.servers.list() - : global.servers.list().filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2") - return listServersByHealth(list, server.key, global.servers.health) - }) + const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health)) const toggleMcp = useMcpToggle() const defaultServer = useDefaultServerKey(platform.getDefaultServer) const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) @@ -310,7 +303,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) { {!settings.general.newLayoutDesigns() && ( - {sortedServers().length > 0 ? `${sortedServers().length} ` : ""} + {global.servers.list().length > 0 ? `${global.servers.list().length} ` : ""} {language.t("status.popover.tab.servers")} )} @@ -322,12 +315,10 @@ export function StatusPopoverBody(props: { shown: Accessor }) { {lspCount() > 0 ? `${lspCount()} ` : ""} {language.t("status.popover.tab.lsp")} - - - {pluginCount() > 0 ? `${pluginCount()} ` : ""} - {language.t("status.popover.tab.plugins")} - - + + {pluginCount() > 0 ? `${pluginCount()} ` : ""} + {language.t("status.popover.tab.plugins")} + {!settings.general.newLayoutDesigns() && ( @@ -487,27 +478,25 @@ export function StatusPopoverBody(props: { shown: Accessor }) {
- - -
-
- 0} - fallback={
{pluginEmpty()}
} - > - - {(plugin) => ( -
-
- {plugin} -
- )} - - -
+ +
+
+ 0} + fallback={
{pluginEmpty()}
} + > + + {(plugin) => ( +
+
+ {plugin} +
+ )} + +
- -
+
+
) diff --git a/packages/app/src/components/status-popover-indicator.test.ts b/packages/app/src/components/status-popover-indicator.test.ts deleted file mode 100644 index d6d6d07a1d..0000000000 --- a/packages/app/src/components/status-popover-indicator.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { - hasNonBlockingServiceIssue, - hasServiceNeedingAttention, - serverStatusDotClass, -} from "./status-popover-indicator" - -describe("serverStatusDotClass", () => { - test("uses the success token while the server and services are healthy", () => { - expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base") - }) - - test("uses the session attention token when a service needs attention", () => { - expect(serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true })).toBe( - "bg-v2-background-bg-accent", - ) - }) - - test("uses the warning token for non-blocking issues while the server is online", () => { - expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base") - }) - - test("uses the critical token only after the server connection drops", () => { - expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false })).toBe("bg-icon-critical-base") - expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base") - }) - - test("stays neutral before status is ready", () => { - expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base") - expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base") - }) -}) - -describe("hasNonBlockingServiceIssue", () => { - test("detects MCP failures that do not block chatting", () => { - expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true) - expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true) - expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true) - expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false) - }) - - test("detects LSP failures that do not block chatting", () => { - expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["error"] })).toBe(true) - expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false) - }) -}) - -describe("hasServiceNeedingAttention", () => { - test("detects MCP states that need user attention", () => { - expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true) - expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true) - }) - - test("ignores states that do not need user attention", () => { - expect(hasServiceNeedingAttention({ mcp: ["failed"] })).toBe(false) - expect(hasServiceNeedingAttention({ mcp: ["connected", "pending", "disabled"] })).toBe(false) - }) -}) diff --git a/packages/app/src/components/status-popover-indicator.ts b/packages/app/src/components/status-popover-indicator.ts deleted file mode 100644 index 62fc3bedbf..0000000000 --- a/packages/app/src/components/status-popover-indicator.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { LspStatus } from "@opencode-ai/sdk/v2/client" -import type { McpServer } from "@opencode-ai/client/promise" - -export function hasServiceNeedingAttention(input: { mcp: Array }) { - return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration") -} - -export function hasNonBlockingServiceIssue(input: { - mcp: Array - lsp: Array -}) { - return ( - input.mcp.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") || - input.lsp.some((status) => status === "error") - ) -} - -export function serverStatusDotClass(input: { - ready: boolean - serverHealth: boolean | undefined - attention?: boolean - issue: boolean -}) { - if (input.serverHealth === false) return "bg-icon-critical-base" - if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base" - if (input.attention) return "bg-v2-background-bg-accent" - if (input.issue) return "bg-icon-warning-base" - if (input.serverHealth === true) return "bg-icon-success-base" - return "bg-border-weak-base" -} diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 767e7e62c6..c75dd5b655 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -9,11 +9,6 @@ import { ServerConnection, useServer } from "@/context/server" import { useServerSDK } from "@/context/server-sdk" import { useSync } from "@/context/sync" import { useGlobal } from "@/context/global" -import { - hasNonBlockingServiceIssue, - hasServiceNeedingAttention, - serverStatusDotClass, -} from "./status-popover-indicator" const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody }))) @@ -24,19 +19,16 @@ export function StatusPopover() { const global = useGlobal() const sync = useSync() const [shown, setShown] = createSignal(false) - const serverHealth = () => global.servers.health[server.key]?.healthy - const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) - const attention = createMemo(() => - hasServiceNeedingAttention({ - mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), - }), - ) - const issue = createMemo(() => - hasNonBlockingServiceIssue({ - mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), - lsp: (sync().data.lsp ?? []).map((item) => item.status), - }), - ) + const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready) + const mcpIssue = createMemo(() => { + const mcp = Object.values(sync().data.mcp ?? {}) + const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration") + const warn = mcp.some((item) => item.status === "needs_auth") + if (failed) return "critical" as const + if (warn) return "warning" as const + }) + const serverHealthy = () => global.servers.health[server.key]?.healthy === true + const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue()) return (
} @@ -94,24 +87,21 @@ function DirectoryStatusPopover() { const sync = useSync() const [shown, setShown] = createSignal(false) const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy - const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready)) - const attention = createMemo(() => - hasServiceNeedingAttention({ - mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), - }), - ) - const issue = createMemo(() => - hasNonBlockingServiceIssue({ - mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status), - lsp: (sync().data.lsp ?? []).map((item) => item.status), - }), - ) + const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready) + const mcpIssue = createMemo(() => { + const mcp = Object.values(sync().data.mcp ?? {}) + const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration") + const warn = mcp.some((item) => item.status === "needs_auth") + if (failed) return "critical" as const + if (warn) return "warning" as const + }) + const healthy = createMemo(() => serverHealth() === true && !mcpIssue()) const state = createMemo(() => ({ shown: shown(), ready: ready(), + healthy: healthy(), serverHealth: serverHealth(), - attention: attention(), - issue: issue(), + issue: mcpIssue(), label: language.t("status.popover.trigger"), onOpenChange: setShown, body: () => ( @@ -133,9 +123,8 @@ function ServerStatusPopover() { const state = createMemo(() => ({ shown: shown(), ready: serverHealth() !== undefined, + healthy: serverHealth() === true, serverHealth: serverHealth(), - attention: false, - issue: false, label: language.t("status.popover.trigger"), onOpenChange: setShown, body: () => ( @@ -151,9 +140,9 @@ function ServerStatusPopover() { type StatusPopoverState = { shown: boolean ready: boolean + healthy: boolean serverHealth: boolean | undefined - attention: boolean - issue: boolean + issue?: "critical" | "warning" label: string onOpenChange: (value: boolean) => void body: () => JSX.Element @@ -172,6 +161,16 @@ function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) { } function StatusPopoverView(props: { state: StatusPopoverState }) { + const statusDotClass = () => ({ + "absolute rounded-full": true, + "bg-icon-success-base": props.state.ready && props.state.healthy, + "bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning", + "bg-icon-critical-base": + props.state.serverHealth === false || + (props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"), + "bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready, + }) + const popoverProps = { class: "[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl", @@ -196,7 +195,8 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
} diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 7d25e7c649..8998fc06c8 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,8 +1,7 @@ import { withAlpha } from "@opencode-ai/ui/theme/color" import { useTheme } from "@opencode-ai/ui/theme/context" import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve" -import { resolveThemeVariantV2 } from "@opencode-ai/ui/theme/v2/resolve" -import type { HexColor, ResolvedV2Theme } from "@opencode-ai/ui/theme/types" +import type { HexColor } from "@opencode-ai/ui/theme/types" import { showToast } from "@/utils/toast" import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js" @@ -23,7 +22,6 @@ const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" export interface TerminalProps extends ComponentProps<"div"> { pty: LocalPTY autoFocus?: boolean - onAutoFocus?: () => void onSubmit?: () => void onCleanup?: (pty: Partial & { id: string }) => void onConnect?: () => void @@ -70,19 +68,6 @@ const debugTerminal = (...values: unknown[]) => { console.debug("[terminal]", ...values) } -const resolveV2Token = (tokens: ResolvedV2Theme, key: string) => { - let current = tokens[key] - for (let i = 0; i < 8 && current; i++) { - const match = /^var\(--([^)]+)\)$/.exec(current.trim()) - if (!match) { - const hex = current.trim() - if (/^#[0-9a-fA-F]{8}$/.test(hex)) return hex.slice(0, 7) - return hex - } - current = tokens[match[1]] - } -} - const useTerminalUiBindings = (input: { container: HTMLDivElement term: Term @@ -178,6 +163,7 @@ export const Terminal = (props: TerminalProps) => { // Terminal captures its connection for the PTY lifetime, so callers must key it per server/session. const connection = useServerSDK()().server const directory = sdk().directory + const client = sdk().client const url = sdk().url const auth = connection.http const username = auth?.username ?? "opencode" @@ -185,15 +171,7 @@ export const Terminal = (props: TerminalProps) => { const authToken = connection.type === "http" ? connection.authToken : false const sameOrigin = new URL(url, location.href).origin === location.origin let container!: HTMLDivElement - const [local, others] = splitProps(props, [ - "pty", - "class", - "classList", - "autoFocus", - "onAutoFocus", - "onConnect", - "onConnectError", - ]) + const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) const id = local.pty.id const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : "" const restoreSize = @@ -240,21 +218,10 @@ export const Terminal = (props: TerminalProps) => { } } - const pushSize = async (cols: number, rows: number) => { - if ((await sdk().protocol) === "v1") { - return sdk() - .client.pty.update({ - ptyID: id, - size: { cols, rows }, - }) - .catch((err) => { - debugTerminal("failed to sync terminal size", err) - }) - } - return sdk() - .api.pty.update({ + const pushSize = (cols: number, rows: number) => { + return client.pty + .update({ ptyID: id, - location: { directory }, size: { cols, rows }, }) .catch((err) => { @@ -271,10 +238,7 @@ export const Terminal = (props: TerminalProps) => { if (!variant?.seeds && !variant?.palette) return fallback const resolved = resolveThemeVariant(variant, mode === "dark") const text = resolved["text-stronger"] ?? fallback.foreground - const background = settings.general.newLayoutDesigns() - ? (resolveV2Token(resolveThemeVariantV2(variant, mode === "dark"), "v2-background-bg-base") ?? - fallback.background) - : (resolved["background-stronger"] ?? fallback.background) + const background = resolved["background-stronger"] ?? fallback.background const alpha = mode === "dark" ? 0.25 : 0.2 const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor) const selectionBackground = withAlpha(base, alpha) @@ -302,12 +266,7 @@ export const Terminal = (props: TerminalProps) => { const scheduleSize = (cols: number, rows: number) => { if (disposed) return - if (lastSize?.cols === cols && lastSize?.rows === rows) { - pendingSize = undefined - if (sizeTimer !== undefined) clearTimeout(sizeTimer) - sizeTimer = undefined - return - } + if (lastSize?.cols === cols && lastSize?.rows === rows) return pendingSize = { cols, rows } @@ -332,10 +291,8 @@ export const Terminal = (props: TerminalProps) => { createEffect(() => { const colors = terminalColors() - const mode = theme.mode() === "dark" ? "dark" : "light" if (!term) return setOptionIfSupported(term, "theme", colors) - setOptionIfSupported(term, "colorScheme", mode) }) createEffect(() => { @@ -382,11 +339,7 @@ export const Terminal = (props: TerminalProps) => { event.preventDefault() event.stopImmediatePropagation() - if (URL.canParse(text) && new URL(text).protocol === "file:" && platform.openLocalFile) { - platform.openLocalFile(text) - return - } - platform.openExternal(text) + platform.openLink(text) } onMount(() => { @@ -417,7 +370,6 @@ export const Terminal = (props: TerminalProps) => { } _ghostty = g term = t - setOptionIfSupported(t, "colorScheme", theme.mode() === "dark" ? "dark" : "light") output = terminalWriter((data, done) => t.write(data, () => { done?.() @@ -447,7 +399,6 @@ export const Terminal = (props: TerminalProps) => { fitAddon = fit serializeAddon = serializer - const active = document.activeElement t.open(container) useTerminalUiBindings({ container, @@ -457,22 +408,7 @@ export const Terminal = (props: TerminalProps) => { handleLinkClick, }) - if (local.autoFocus === true) { - focusTerminal() - local.onAutoFocus?.() - } - if (local.autoFocus !== true) { - const restoreFocus = () => { - const current = document.activeElement - if (current !== container && !container.contains(current)) return - t.blur() - t.textarea?.blur() - if (active instanceof HTMLElement && active.isConnected) active.focus() - } - restoreFocus() - const timer = setTimeout(restoreFocus, 0) - cleanups.push(() => clearTimeout(timer)) - } + if (local.autoFocus !== false) focusTerminal() if (typeof document !== "undefined" && document.fonts) { void document.fonts.ready.then(scheduleFit) @@ -536,54 +472,34 @@ export const Terminal = (props: TerminalProps) => { local.onConnectError?.(err) } - const gone = async () => { - if ((await sdk().protocol) === "v1") { - return sdk() - .client.pty.get({ ptyID: id }, { throwOnError: false }) - .then((result) => result.response.status === 404) - .catch((err) => { - debugTerminal("failed to inspect terminal session", err) - return false - }) - } - return sdk() - .api.pty.get({ ptyID: id, location: { directory } }) - .then((result) => result.data.status === "exited") + const gone = () => + client.pty + .get({ ptyID: id }, { throwOnError: false }) + .then((result) => result.response.status === 404) .catch((err) => { - if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true debugTerminal("failed to inspect terminal session", err) return false }) - } const connectToken = async () => { - if ((await sdk().protocol) === "v1") { - const result = await sdk() - .client.pty.connectToken( - { ptyID: id, directory }, - { - throwOnError: false, - headers: { "x-opencode-ticket": "1" }, - }, - ) - .catch((err: unknown) => { - if (err instanceof Error && err.message.includes("Request is not supported")) return - throw err - }) - if (!result) return - if (result.response.status === 200 && result.data?.ticket) return result.data.ticket - if (result.response.status === 404 || result.response.status === 405) return - if (result.response.status === 403) - throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") - throw new Error(`PTY connect ticket failed with ${result.response.status}`) - } - // return sdk() - // .api.pty.connectToken({ - // ptyID: id, - // location: { directory }, - // "x-opencode-ticket": "1", - // }) - // .then((result) => result.data.ticket) + const result = await client.pty + .connectToken( + { ptyID: id, directory }, + { + throwOnError: false, + headers: { "x-opencode-ticket": "1" }, + }, + ) + .catch((err: unknown) => { + if (err instanceof Error && err.message.includes("Request is not supported")) return + throw err + }) + if (!result) return + if (result.response.status === 200 && result.data?.ticket) return result.data.ticket + if (result.response.status === 404 || result.response.status === 405) return + if (result.response.status === 403) + throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") + throw new Error(`PTY connect ticket failed with ${result.response.status}`) } const retry = (err: unknown) => { @@ -613,14 +529,11 @@ export const Terminal = (props: TerminalProps) => { fail(err) return undefined }) - const protocol = await sdk().protocol - // if (protocol === "v2" && !ticket) return if (once.value) return if (disposed) return const socket = new WebSocket( terminalWebSocketURL({ - protocol, url, id, directory, @@ -640,7 +553,6 @@ export const Terminal = (props: TerminalProps) => { tries = 0 local.onConnect?.() scheduleSize(t.cols, t.rows) - if (t.getMode(2031)) t.write("\x1b[?996n") } const handleMessage = (event: MessageEvent) => { diff --git a/packages/app/src/components/titlebar-tab-gesture.ts b/packages/app/src/components/titlebar-tab-gesture.ts index 0aa5118936..329bf5e864 100644 --- a/packages/app/src/components/titlebar-tab-gesture.ts +++ b/packages/app/src/components/titlebar-tab-gesture.ts @@ -12,6 +12,6 @@ export function forwardTabRef(ref: Ref | undefined, element: HTM if (typeof ref === "function") ref(element) } -export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, pending: boolean) { - return !dragging && !editing && !pending +export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) { + return !dragging && !editing && !committing } diff --git a/packages/app/src/components/titlebar-tab-nav.css b/packages/app/src/components/titlebar-tab-nav.css index 13dfbe04fc..08bed7cf26 100644 --- a/packages/app/src/components/titlebar-tab-nav.css +++ b/packages/app/src/components/titlebar-tab-nav.css @@ -9,86 +9,53 @@ justify-content: center; } -[data-titlebar-tab] { - --tab-base: var(--v2-background-bg-deep); - --tab-overlay: transparent; - background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base); -} - -[data-titlebar-tab]:is(:hover, :has(> [data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not( - [data-dragging="true"] - ):not([data-editing="true"]) { - --tab-base: var(--v2-background-bg-layer-02); - --tab-overlay: var(--v2-overlay-simple-overlay-hover); -} - -[data-titlebar-tab]:is([data-state="pressed"], [data-dragging="true"], [data-editing="true"]) { - --tab-base: var(--v2-background-bg-layer-02); - --tab-overlay: var(--v2-overlay-simple-overlay-pressed); -} - -[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] { - background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base); -} - [data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] { display: none; } [data-titlebar-tab-list] { - gap: 6px; + gap: 13.5px; } [data-titlebar-tab-slot] { - --tab-separator: var(--v2-background-bg-layer-03); position: relative; } -[data-color-scheme="dark"] [data-titlebar-tab-slot] { - --tab-separator: var(--v2-background-bg-layer-02); -} - [data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before { content: ""; position: absolute; top: 8px; - left: -3.75px; + left: -6.75px; width: 1.5px; height: 12px; border-radius: 9999px; - background: var(--tab-separator); + background: var(--v2-background-bg-layer-02); } [data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before { display: none; } -[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover)::before, -[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) + [data-titlebar-tab-slot]::before { +[data-titlebar-tab] [data-slot="tab-close"]::before { + content: ""; + position: absolute; + top: 0; + height: 28px; + width: 16px; + pointer-events: none; + background: linear-gradient(to right, transparent, var(--tab-bg)); display: none; } -[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] { - --tab-title-fade-offset: 4px; - -webkit-mask-image: linear-gradient( - to right, - black 0, - black calc(100% - var(--tab-title-fade-offset) - 16px), - transparent calc(100% - var(--tab-title-fade-offset)), - transparent 100% - ); - mask-image: linear-gradient( - to right, - black 0, - black calc(100% - var(--tab-title-fade-offset) - 16px), - transparent calc(100% - var(--tab-title-fade-offset)), - transparent 100% - ); +[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before { + display: block; + right: 0; } -[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"]) - [data-slot="tab-link"] { - --tab-title-fade-offset: 24px; +[data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before, +[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"]) + [data-slot="tab-close"]::before { + right: 100%; } [data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"]) diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index 5003d0ed30..5f4dbe8807 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -1,13 +1,14 @@ import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" -import { createMutation } from "@tanstack/solid-query" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { useGlobal } from "@/context/global" +import { useLanguage } from "@/context/language" import { ServerConnection, serverName } from "@/context/server" import { displayName, projectForSession } from "@/pages/layout/helpers" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" +import { showToast } from "@/utils/toast" import type { Session } from "@opencode-ai/sdk/v2" import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture" import { TabPreviewPopover } from "./titlebar-tab-popover" @@ -22,22 +23,25 @@ export function TabNavItem(props: { server: ServerConnection.Key session: () => Session | undefined fallbackTitle?: string - onRename: (title: string) => Promise + onTitleChange?: (title: string) => void + onTitleChangeFailed?: (title: string) => void onClose: () => void onNavigate: () => void active?: boolean + activeServer: boolean forceTruncate?: boolean suppressNavigation?: () => boolean dragging?: boolean pressed?: boolean hidden?: boolean }) { + const language = useLanguage() const [editing, setEditing] = createSignal(false) const [titleOverflowing, setTitleOverflowing] = createSignal(false) let tabRoot!: HTMLDivElement let titleEl!: HTMLSpanElement + let committing = false let measureFrame: number | undefined - const rename = createMutation(() => ({ mutationFn: props.onRename })) const closeTab = (event: MouseEvent) => { event.preventDefault() @@ -67,6 +71,11 @@ export function TabNavItem(props: { const home = serverCtx()?.sync.data.path.home return home ? session.directory.replace(home, "~") : session.directory }) + const branch = createMemo(() => { + const session = props.session() + if (!session) return + return serverCtx()?.sync.child(session.directory, { bootstrap: false })[0].vcs?.branch + }) // Only label the server when multiple servers are connected. const serverLabel = createMemo(() => { if (global.servers.list().length <= 1) return @@ -113,20 +122,41 @@ export function TabNavItem(props: { selection?.addRange(range) } + const rename = async (title: string) => { + const ctx = serverCtx() + const session = props.session() + if (!ctx || !session) return + const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true }) + await client.session.update({ sessionID: session.id, title }) + } + const closeRename = async (save: boolean) => { - if (rename.isPending || !editing()) return + if (committing || !editing()) return + committing = true const original = props.session()?.title ?? "" const next = (titleEl.textContent ?? "").trim() titleEl.scrollLeft = 0 + if (save && next && next !== original) props.onTitleChange?.(next) setEditing(false) if (!save || !next || next === original) { + committing = false return } - await rename.mutateAsync(next) + try { + await rename(next) + } catch (err) { + props.onTitleChangeFailed?.(original) + showToast({ + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : undefined, + }) + } + + committing = false } createEffect(() => { @@ -140,7 +170,7 @@ export function TabNavItem(props: { const openRename = (event: MouseEvent) => { event.preventDefault() event.stopPropagation() - if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return + if (!canOpenTabRename(props.dragging, editing(), committing)) return const session = props.session() if (!session) return titleEl.textContent = session.title @@ -180,11 +210,11 @@ export function TabNavItem(props: { data-slot="titlebar-tab-item" data-title-overflow={titleOverflowing()} data-editing={editing()} - class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] px-1.5 [container-type:inline-size]" + class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" classList={{ invisible: props.hidden }} data-active={props.active} data-dragging={props.dragging} - data-state={props.active || props.pressed ? "pressed" : undefined} + data-pressed={props.pressed} onMouseDown={(event) => { if (event.button !== MIDDLE_MOUSE_BUTTON) return event.preventDefault() @@ -195,89 +225,76 @@ export function TabNavItem(props: { closeTab(event) }} > - { - event.preventDefault() - event.stopPropagation() - }} - onMouseDown={(event) => { - // Navigate on mousedown to shave the press-release delay off tab switches. - if (event.button !== 0) return - if (editing()) return - if (props.suppressNavigation?.()) return - props.onNavigate() - }} - onClick={(event) => { - event.preventDefault() - // Mouse navigation already happened on mousedown; detail 0 means keyboard activation. - if (event.detail > 0) return - if (editing()) return - if (props.suppressNavigation?.()) return - props.onNavigate() - }} - class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]" - > - - - - { - titleEl = el - titleEl.textContent = title() ?? "" - }} - data-slot="tab-title" - data-titlebar-tab-title - class="min-w-0 flex-1 outline-none leading-4" - classList={{ - "overflow-hidden text-clip whitespace-nowrap": !editing(), - "select-text": editing(), - }} - contenteditable={editing() ? true : undefined} - onDblClick={openRename} - onKeyDown={(event) => { - event.stopPropagation() - if (event.key === "Enter") { - event.preventDefault() - void closeRename(true) - return - } - if (event.key !== "Escape") return + + { event.preventDefault() - titleEl.textContent = props.session()?.title ?? "" - void closeRename(false) - }} - onBlur={() => void closeRename(true)} - onPointerDown={(event) => { - if (!editing()) return event.stopPropagation() }} onClick={(event) => { - if (!editing()) return event.preventDefault() + if (editing()) return + if (props.suppressNavigation?.()) return + props.onNavigate() }} - /> - + class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]" + > + + {(session) => ( + + + + )} + + { + titleEl = el + titleEl.textContent = title() ?? "" + }} + data-slot="tab-title" + data-titlebar-tab-title + class="min-w-0 flex-1 outline-none leading-4" + classList={{ + "overflow-hidden text-clip whitespace-nowrap": !editing(), + "select-text": editing(), + }} + contenteditable={editing() ? true : undefined} + onDblClick={openRename} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void closeRename(true) + return + } + if (event.key !== "Escape") return + event.preventDefault() + titleEl.textContent = props.session()?.title ?? "" + void closeRename(false) + }} + onBlur={() => void closeRename(true)} + onPointerDown={(event) => { + if (!editing()) return + event.stopPropagation() + }} + onClick={(event) => { + if (!editing()) return + event.preventDefault() + }} + /> + + -
+
@@ -335,8 +353,8 @@ export function DraftTabItem(props: { data-slot="titlebar-tab-item" data-active={props.active} data-dragging={props.dragging} - data-state={props.active || props.pressed ? "pressed" : undefined} - class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 [container-type:inline-size] whitespace-nowrap" + data-pressed={props.pressed} + class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" classList={{ invisible: props.hidden }} onMouseDown={(event) => { if (event.button !== MIDDLE_MOUSE_BUTTON) return @@ -357,16 +375,8 @@ export function DraftTabItem(props: { event.preventDefault() event.stopPropagation() }} - onMouseDown={(event) => { - // Navigate on mousedown to shave the press-release delay off tab switches. - if (event.button !== 0) return - if (props.suppressNavigation?.()) return - props.onNavigate() - }} onClick={(event) => { event.preventDefault() - // Mouse navigation already happened on mousedown; detail 0 means keyboard activation. - if (event.detail > 0) return if (props.suppressNavigation?.()) return props.onNavigate() }} @@ -382,7 +392,7 @@ export function DraftTabItem(props: { {props.title} -
+
{ - test("follows the visible left-to-right order", () => { - expect(adjacentTabKey(["c", "a", "b"], "c", 1)).toBe("a") - expect(adjacentTabKey(["c", "a", "b"], "a", -1)).toBe("c") - }) - - test("skips tabs omitted from the visible order", () => { - expect(adjacentTabKey(["a", "c"], "a", 1)).toBe("c") - expect(adjacentTabKey(["a", "c"], "c", 1)).toBe("a") - }) -}) - -test("merges reordered visible tabs around hidden tabs", () => { - expect(mergeVisibleTabOrder(["a", "hidden", "b", "c"], ["a", "b", "c"], ["c", "a", "b"])).toEqual([ - "c", - "hidden", - "a", - "b", - ]) -}) diff --git a/packages/app/src/components/titlebar-tab-order.ts b/packages/app/src/components/titlebar-tab-order.ts deleted file mode 100644 index a906b1fb32..0000000000 --- a/packages/app/src/components/titlebar-tab-order.ts +++ /dev/null @@ -1,12 +0,0 @@ -export function adjacentTabKey(order: string[], current: string | undefined, offset: -1 | 1) { - if (!current || order.length === 0) return - const index = order.indexOf(current) - if (index === -1) return - return order[(index + offset + order.length) % order.length] -} - -export function mergeVisibleTabOrder(all: string[], current: string[], next: string[]) { - const visible = new Set(current) - const reordered = next.values() - return all.map((key) => (visible.has(key) ? (reordered.next().value ?? key) : key)) -} diff --git a/packages/app/src/components/titlebar-tab-popover.css b/packages/app/src/components/titlebar-tab-popover.css index e3b96dcaa4..174579a640 100644 --- a/packages/app/src/components/titlebar-tab-popover.css +++ b/packages/app/src/components/titlebar-tab-popover.css @@ -10,7 +10,7 @@ width: 256px; display: flex; flex-direction: column; - gap: 6px; + gap: 8px; padding: 12px; background: var(--v2-background-bg-base); @@ -23,37 +23,30 @@ pointer-events: none; transform-origin: var(--kb-hovercard-content-transform-origin); - /* Entrance: instant (trying no animation — may revert to 120ms ease-out). */ - animation: none; + /* Entrance: fade in while scaling up from 0.96 to 1.0. */ + animation: sessionTabPopoverIn 150ms cubic-bezier(0.16, 1, 0.3, 1); &:focus-visible, &:focus-within { outline: none; } - /* Exit: instant (trying no animation — may revert to 80ms ease-out). */ + /* Exit: subtle fade + scale down, using the same ease-out curve as entry. */ &[data-closed] { - animation: none; - } - - /* Warm streak (rapid tab hopping): appear/disappear instantly so the enter - and exit animations don't repeat on every tab. */ - &[data-instant], - &[data-instant][data-closed] { - animation: none; + animation: sessionTabPopoverOut 100ms cubic-bezier(0.16, 1, 0.3, 1) forwards; } [data-slot="header"] { display: flex; flex-direction: column; - gap: 6px; + gap: 8px; width: 100%; } [data-slot="project"] { font-weight: 440; font-size: 13px; - line-height: 1.5; + line-height: 1; letter-spacing: -0.04px; color: var(--v2-text-text-muted); } @@ -61,7 +54,7 @@ [data-slot="title"] { font-weight: 530; font-size: 13px; - line-height: 1.5; + line-height: 1.2; letter-spacing: -0.04px; color: var(--v2-text-text-base); overflow-wrap: anywhere; @@ -69,18 +62,31 @@ [data-slot="row"] { display: flex; + align-items: center; + gap: 6px; width: 100%; min-width: 0; } + [data-slot="icon"] { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 16px; + height: 16px; + color: var(--v2-icon-icon-muted); + } + [data-slot="detail"] { flex: 1 0 0; min-width: 0; - /* Wrap the full path onto the next line instead of clipping the end. */ - overflow-wrap: anywhere; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-weight: 440; font-size: 13px; - line-height: 1.5; + line-height: 1; letter-spacing: -0.04px; color: var(--v2-text-text-muted); } diff --git a/packages/app/src/components/titlebar-tab-popover.tsx b/packages/app/src/components/titlebar-tab-popover.tsx index 0689302ca6..10d4cdef89 100644 --- a/packages/app/src/components/titlebar-tab-popover.tsx +++ b/packages/app/src/components/titlebar-tab-popover.tsx @@ -1,21 +1,23 @@ import { HoverCard as Kobalte } from "@kobalte/core/hover-card" -import { createSignal, Show, type JSXElement } from "solid-js" +import { Show, type JSXElement } from "solid-js" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import "./titlebar-tab-popover.css" // Initial hover delay before the preview appears, per design. -const OPEN_DELAY = 2_000 +const OPEN_DELAY = 200 // Mouse-out delay: begin closing immediately (a brief exit animation plays). const CLOSE_DELAY = 0 // After a preview closes, hovering a neighbouring tab within this window skips // the open delay — mirrors the tooltip's skipDelayDuration so moving across // tabs doesn't re-wait the full delay each time. -const SKIP_WINDOW = 500 +const SKIP_WINDOW = 300 let lastClosedAt = 0 export interface TabPreviewData { projectName?: string title?: string path?: string + branch?: string serverName?: string } @@ -26,18 +28,12 @@ export function TabPreviewPopover(props: { data: TabPreviewData }) { let triggerEl: HTMLDivElement | undefined - // When opened during a rapid tab-hopping streak, this preview appears and - // disappears instantly (no repeated enter/exit animation) — only the first, - // "cold" preview animates. Mirrors how browsers reuse one tab tooltip. - const [instant, setInstant] = createSignal(false) - const warm = () => Date.now() - lastClosedAt < SKIP_WINDOW // Kobalte reads openDelay lazily when the pointer enters the trigger, so this // resolves the skip window per-hover. - const resolveOpenDelay = () => (warm() ? 0 : OPEN_DELAY) + const resolveOpenDelay = () => (Date.now() - lastClosedAt < SKIP_WINDOW ? 0 : OPEN_DELAY) const handleOpenChange = (open: boolean) => { - if (open) setInstant(warm()) - else lastClosedAt = Date.now() + if (!open) lastClosedAt = Date.now() props.onOpenChange(open) } @@ -65,7 +61,6 @@ export function TabPreviewPopover(props: { if (theme) el.setAttribute("data-theme", theme) }} data-component="session-tab-popover" - data-instant={instant() || undefined} >
@@ -78,10 +73,22 @@ export function TabPreviewPopover(props: {
+ + + {props.data.path}
+ +
+ + + + {props.data.branch} +
+
+
{props.data.serverName}
diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index 0a4b4da12d..9b877614d1 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -1,5 +1,4 @@ -import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount, Show } from "solid-js" -import { createStore } from "solid-js/store" +import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js" import { createResizeObserver } from "@solid-primitives/resize-observer" import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" import { isSortable, useSortable } from "@dnd-kit/solid/sortable" @@ -16,23 +15,23 @@ import { useCommand } from "@/context/command" import { useTabs } from "@/context/tabs" import { createTabPromptState } from "@/context/prompt" import { base64Encode } from "@opencode-ai/core/util/encode" -import { showToast } from "@/utils/toast" import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" -import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order" -import type { Session } from "@opencode-ai/sdk/v2" + +const sortableTransition = { duration: 0 } function SessionTabSlot(props: { tab: SessionTab id: string index: () => number active: () => boolean + activeServerKey: ServerConnection.Key forceTruncate: boolean - session: () => Session | undefined - fallbackTitle?: string - onRename: (title: string) => Promise + serverCtx: () => ServerCtx | undefined onNavigate: (element: HTMLDivElement) => void onClose: () => void }) { + const tabs = useTabs() + const language = useLanguage() const sortable = useSortable({ get id() { return props.id @@ -42,50 +41,8 @@ function SessionTabSlot(props: { }, }) let ref!: HTMLDivElement - - return ( -
- { - ref = el - }} - href={tabHref(props.tab)} - server={props.tab.server} - session={props.session} - fallbackTitle={props.fallbackTitle} - onRename={props.onRename} - onNavigate={() => props.onNavigate(ref)} - onClose={props.onClose} - active={props.active()} - forceTruncate={props.forceTruncate} - dragging={sortable.isDragSource()} - /> -
- ) -} - -function SessionTabEntry(props: { - tab: SessionTab - id: string - index: () => number - active: () => boolean - forceTruncate: boolean - serverCtx: () => ServerCtx | undefined - onVisibleChange: (visible: boolean) => void - onNavigate: (element: HTMLDivElement) => void - onClose: () => void -}) { - const tabs = useTabs() - const language = useLanguage() const sdk = createMemo(() => props.serverCtx()?.sdk ?? null) const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId)) - const persisted = createMemo(() => tabs.info[props.id]) const [loadedSession] = createResource( () => { const ctx = props.serverCtx() @@ -95,30 +52,8 @@ function SessionTabEntry(props: { ) const session = createMemo(() => cachedSession() ?? loadedSession()) const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session()) - const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title) let prefetched = false - const rename = async (title: string) => { - const value = session() - const ctx = props.serverCtx() - if (!value || !ctx) return - - ctx.sync.session.remember({ ...value, title }) - try { - await ctx.sdk.api.session.rename({ sessionID: value.id, title }) - } catch (err) { - const current = session() - const currentCtx = props.serverCtx() - if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title }) - showToast({ - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : undefined, - }) - } - } - - createEffect(() => props.onVisibleChange(visible())) - createEffect(() => { const ctx = props.serverCtx() const value = session() @@ -139,10 +74,8 @@ function SessionTabEntry(props: { createEffect(() => { const value = session() - if (!value) return - tabs.rememberSessionInfo(props.tab, value) const current = sdk() - if (!current) return + if (!value || !current) return createTabPromptState(tabs, props.tab, current.scope, { dir: base64Encode(value.directory), id: value.id, @@ -150,20 +83,40 @@ function SessionTabEntry(props: { }) return ( - - + { + ref = el + }} + href={tabHref(props.tab)} + server={props.tab.server} session={session} - fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)} - onRename={rename} - onNavigate={props.onNavigate} + fallbackTitle={missingSession() ? language.t("session.tab.unknown") : undefined} + onTitleChange={(title) => { + const value = session() + const ctx = props.serverCtx() + if (value && ctx) ctx.sync.session.remember({ ...value, title }) + }} + onTitleChangeFailed={(title) => { + const value = session() + const ctx = props.serverCtx() + if (value && ctx) ctx.sync.session.remember({ ...value, title }) + }} + onNavigate={() => props.onNavigate(ref)} onClose={props.onClose} + active={props.active()} + activeServer={props.tab.server === props.activeServerKey} + forceTruncate={props.forceTruncate} + dragging={sortable.isDragSource()} /> - +
) } @@ -212,6 +165,7 @@ function DraftTabSlot(props: { export function TitlebarTabStrip(props: { tabs: Tab[] currentTab: () => Tab | undefined + activeServerKey: ServerConnection.Key forceTruncate: boolean onNavigate: (tab: Tab, el?: HTMLDivElement) => void onClose: (tab: Tab) => void @@ -220,39 +174,11 @@ export function TitlebarTabStrip(props: { }) { const global = useGlobal() const language = useLanguage() - const command = useCommand() let scrollRef!: HTMLDivElement let listRef!: HTMLDivElement let resizeFrame: number | undefined - const [visibility, setVisibility] = createStore>({}) - const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)])) - const visibleTabIds = () => visibleTabs().map(tabKey) - command.register("titlebar-tab-cycle", () => [ - { - id: `tab.prev`, - category: "tab", - title: "", - keybind: `mod+option+ArrowLeft,ctrl+shift+tab`, - hidden: true, - onSelect: () => selectAdjacentTab(-1), - }, - { - id: `tab.next`, - category: "tab", - title: "", - keybind: `mod+option+ArrowRight,ctrl+tab`, - hidden: true, - onSelect: () => selectAdjacentTab(1), - }, - ]) - - function selectAdjacentTab(offset: -1 | 1) { - const current = props.currentTab() - const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset) - const next = props.tabs.find((tab) => tabKey(tab) === key) - if (next) props.onNavigate(next) - } + const tabIds = () => props.tabs.map(tabKey) function refreshOverflow() { if (!scrollRef) return @@ -280,7 +206,7 @@ export function TitlebarTabStrip(props: { createEffect(() => { props.tabs.length - visibleTabIds() + tabIds() refreshOverflow() }) @@ -316,29 +242,22 @@ export function TitlebarTabStrip(props: { props.onNavigate(tab, tabEl ?? undefined) }} onDragEnd={(event) => { - const current = visibleTabIds() + const current = tabIds() const source = event.operation.source if (event.canceled || !isSortable(source)) return const { initialIndex, index } = source if (initialIndex !== index) { - props.onReorder( - mergeVisibleTabOrder( - props.tabs.map(tabKey), - current, - arrayMove(current, source.initialIndex, source.index), - ), - ) + props.onReorder(arrayMove(current, source.initialIndex, source.index)) } }} >
- {(tab) => { + {(tab, index) => { const id = tabKey(tab) let ref!: HTMLDivElement - const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id) - useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref)) + useTabShortcut(index, () => props.onNavigate(tab, ref)) const serverCtx = createMemo(() => { if (tab.type !== "session") return const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server) @@ -347,14 +266,14 @@ export function TitlebarTabStrip(props: { if (tab.type === "session") { return ( - props.currentTab() === tab} + activeServerKey={props.activeServerKey} forceTruncate={props.forceTruncate} serverCtx={serverCtx} - onVisibleChange={(visible) => setVisibility(id, visible)} onNavigate={(element) => { ref = element props.onNavigate(tab, element) @@ -368,7 +287,7 @@ export function TitlebarTabStrip(props: { props.currentTab() === tab} title={language.t("command.session.new")} onNavigate={(element) => { @@ -402,7 +321,7 @@ function useTabShortcut(index: () => number, onSelect: () => void) { command.register(() => { const number = index() + 1 - if (number < 1 || number > 9) return [] + if (number > 9) return [] return [ { id: `tab.${number}`, diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 649a8f5777..e772aa400a 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -1,10 +1,11 @@ -import { createEffect, createMemo, createResource, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js" +import { createEffect, createMemo, createResource, createSignal, Match, Show, Switch, untrack } from "solid-js" import { createStore } from "solid-js/store" import { useLocation, useNavigate, useParams } from "@solidjs/router" import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { Button } from "@opencode-ai/ui/button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { useTheme } from "@opencode-ai/ui/theme/context" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" @@ -24,16 +25,34 @@ import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/comp import { useGlobal } from "@/context/global" import { ServerConnection, useServer } from "@/context/server" import { tabKey, useTabs } from "@/context/tabs" -import type { PromptSession } from "@/context/prompt" import "./titlebar.css" import { newTabTooltipKeybind } from "./command-tooltip-keybind" -import { normalizeSessionInfo } from "@/utils/session" +type TauriDesktopWindow = { + startDragging?: () => Promise + toggleMaximize?: () => Promise +} + +type TauriThemeWindow = { + setTheme?: (theme?: "light" | "dark" | null) => Promise +} + +type TauriApi = { + window?: { + getCurrentWindow?: () => TauriDesktopWindow + } + webviewWindow?: { + getCurrentWebviewWindow?: () => TauriThemeWindow + } +} + +const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__ +const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.() +const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.() const legacyTitlebarHeight = 40 const v2TitlebarHeight = 36 const minTitlebarZoom = 0.25 const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each. -const macTrafficLightsBaseWidth = 84 export type TitlebarUpdate = { version: () => string | undefined @@ -41,18 +60,13 @@ export type TitlebarUpdate = { install: () => void } -export function useTitlebarRightMount() { - const [mount, setMount] = createSignal(null) - onMount(() => setMount(document.getElementById("opencode-titlebar-right"))) - return mount -} - -export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) { +export function Titlebar(props: { update?: TitlebarUpdate }) { const layout = useLayout() const platform = usePlatform() const command = useCommand() const language = useLanguage() const settings = useSettings() + const theme = useTheme() const server = useServer() const navigate = useNavigate() const location = useLocation() @@ -63,9 +77,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") + const electronWindows = createMemo(() => windows() && !tauriApi()) const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") const web = createMemo(() => platform.platform === "web") - const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.()) const zoom = () => platform.webviewZoom?.() ?? 1 const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom()) const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1) @@ -154,6 +168,56 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl }, ]) + const getWin = () => { + if (platform.platform !== "desktop") return + return currentDesktopWindow() + } + + createEffect(() => { + if (platform.platform !== "desktop") return + + const scheme = theme.colorScheme() + const value = scheme === "system" ? null : scheme + + const win = currentThemeWindow() + if (!win?.setTheme) return + + void win.setTheme(value).catch(() => undefined) + }) + + const interactive = (target: EventTarget | null) => { + if (!(target instanceof Element)) return false + + const selector = + "button, a, input, textarea, select, option, [role='button'], [role='menuitem'], [contenteditable='true'], [contenteditable='']" + + return !!target.closest(selector) + } + + const drag = (e: MouseEvent) => { + if (platform.platform !== "desktop") return + if (e.buttons !== 1) return + if (interactive(e.target)) return + + const win = getWin() + if (!win?.startDragging) return + + e.preventDefault() + void win.startDragging().catch(() => undefined) + } + + const maximize = (e: MouseEvent) => { + if (platform.platform !== "desktop") return + if (interactive(e.target)) return + if (e.target instanceof Element && e.target.closest("[data-tauri-decorum-tb]")) return + + const win = getWin() + if (!win?.toggleMaximize) return + + e.preventDefault() + void win.toggleMaximize().catch(() => undefined) + } + return (
@@ -192,9 +260,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined }, ({ route, sdk }) => - sdk.api.session + sdk.client.session .get({ sessionID: route.sessionId }) - .then(normalizeSessionInfo) + .then((x) => x.data) .catch(() => {}), ) @@ -250,38 +318,16 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl const route = layout.route() const activeSession = session() if (route.type === "session" && activeSession) { - const sessionTab = { - type: "session" as const, - server: route.server ?? server.key, - sessionId: activeSession.id, - } - const model = tabs.stateValue(sessionTab, "prompt")?.model.current() - tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model) + tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "") return } const activeTab = currentTab() if (activeTab?.type === "draft") { - const model = tabs.stateValue(activeTab, "prompt")?.model.current() - tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model) + tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "") return } - if (route.type === "home") { - const selection = layout.home.selection() - const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server) - const project = conn - ? global - .ensureServerCtx(conn) - .projects.list() - .find((item) => item.worktree === selection.directory) - : undefined - if (conn && project) { - tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "") - return - } - } - const current = layout.projects.list()[0] if (current) { tabs.newDraft({ server: server.key, directory: current.worktree }, "") @@ -317,7 +363,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl id: "tab.new", category: "tab", title: language.t("command.session.new"), - keybind: "mod+t,mod+n", + keybind: "mod+t", hidden: true, onSelect: openNewTab, }, @@ -328,15 +374,42 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl keybind: "mod+w", hidden: true, onSelect: () => { - tabsStoreActions.closeTab(tabsStore.findIndex((tab) => current === tab)) + tabsStoreActions.removeTab(tabsStore.findIndex((tab) => current === tab)) }, }, { - id: "tab.reopenClosed", - category: language.t("command.category.file"), - title: language.t("command.tab.reopenClosed"), - keybind: "mod+shift+t", - onSelect: () => tabsStoreActions.reopenClosedTab(), + id: `tab.prev`, + category: "tab", + title: "", + keybind: `mod+option+ArrowLeft,ctrl+shift+tab`, + hidden: true, + onSelect: () => { + let index = tabsStore.findIndex((tab) => tab === currentTab()) + if (index === -1) return + + index -= 1 + if (index === -1) index = tabsStore.length - 1 + + const next = tabsStore[index] + if (next) tabs.select(next) + }, + }, + { + id: `tab.next`, + category: "tab", + title: "", + keybind: `mod+option+ArrowRight,ctrl+tab`, + hidden: true, + onSelect: () => { + let index = tabsStore.findIndex((tab) => tab === currentTab()) + if (index === -1) return + + index += 1 + if (index === tabsStore.length) index = 0 + + const next = tabsStore[index] + if (next) tabs.select(next) + }, }, ].filter((v) => v !== undefined) }) @@ -349,11 +422,11 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl classList={{ "pt-2": !bottom(), "pb-2": bottom(), - "md:pl-2": macTrafficLights(), - "md:pl-4": !macTrafficLights(), + "md:pl-2": mac(), + "md:pl-4": !mac(), }} > - + @@ -383,6 +456,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl { @@ -391,31 +465,36 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl }} onClose={(tab) => { const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab)) - if (index !== -1) tabsStoreActions.closeTab(index) + if (index !== -1) tabsStoreActions.removeTab(index) }} onReorder={(keys) => tabsStoreActions.reorder(keys)} /> - - {language.t("command.session.new")} - - - } - > - } - onClick={openNewTab} - aria-label={language.t("command.session.new")} - /> - + + + {language.t("command.session.new")} + + + } + > + } + onClick={openNewTab} + aria-label={language.t("command.session.new")} + /> + +
+ +
+
) }} @@ -428,13 +507,14 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
+ {/*
*/}
+
-
@@ -564,10 +644,12 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl "pr-2": !windows(), }} data-tauri-drag-region + onMouseDown={drag} >
-
+ {!tauriApi() &&
} +
@@ -630,27 +712,12 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) { ) } -function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) { - const channel = import.meta.env.VITE_OPENCODE_CHANNEL - if (channel === "dev" && props.debugTools) { - return ( - - ) - } - +function ChannelIndicator() { return ( <> - {["beta", "dev"].includes(channel) && ( + {["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
- {channel.toUpperCase()} + {import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
)} diff --git a/packages/app/src/components/ui/drawer.tsx b/packages/app/src/components/ui/drawer.tsx deleted file mode 100644 index 9ac555ec9d..0000000000 --- a/packages/app/src/components/ui/drawer.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Taken from https://www.solid-ui.com/docs/components/drawer - * Only used in one place hence not a v2 component yet... can be promoted to ui/v2 later - */ - -import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js" -import { splitProps } from "solid-js" -import type { ContentProps, DescriptionProps, DynamicProps, LabelProps, OverlayProps } from "@corvu/drawer" -import DrawerPrimitive from "@corvu/drawer" - -const Drawer = DrawerPrimitive - -const DrawerTrigger = DrawerPrimitive.Trigger - -const DrawerPortal = DrawerPrimitive.Portal - -const DrawerClose = DrawerPrimitive.Close - -type DrawerOverlayProps = OverlayProps & { class?: string } - -const DrawerOverlay = (props: DynamicProps>) => { - const [, rest] = splitProps(props as DrawerOverlayProps, ["class"]) - const drawerContext = DrawerPrimitive.useContext() - const overlayStyle = () => { - const state = drawerContext.transitionState() - if (state === "opening" || state === "closing") return undefined - const open = drawerContext.openPercentage() - return { - opacity: open, - "backdrop-filter": `blur(${4 * open}px)`, - } - } - return ( - - ) -} - -type DrawerContentProps = ContentProps & { - class?: string - children?: JSX.Element -} - -const DrawerContent = (props: DynamicProps>) => { - const [, rest] = splitProps(props as DrawerContentProps, ["class", "children"]) - return ( - - - - {props.children} - - - ) -} - -const DrawerHeader: Component> = (props) => { - const [, rest] = splitProps(props, ["class"]) - return
-} - -const DrawerFooter: Component> = (props) => { - const [, rest] = splitProps(props, ["class"]) - return
-} - -type DrawerTitleProps = LabelProps & { class?: string } - -const DrawerTitle = (props: DynamicProps>) => { - const [, rest] = splitProps(props as DrawerTitleProps, ["class"]) - return ( - - ) -} - -type DrawerDescriptionProps = DescriptionProps & { - class?: string -} - -const DrawerDescription = (props: DynamicProps>) => { - const [, rest] = splitProps(props as DrawerDescriptionProps, ["class"]) - return ( - - ) -} - -export { - Drawer, - DrawerPortal, - DrawerOverlay, - DrawerTrigger, - DrawerClose, - DrawerContent, - DrawerHeader, - DrawerFooter, - DrawerTitle, - DrawerDescription, -} diff --git a/packages/app/src/components/virtual-scroll-element.test.ts b/packages/app/src/components/virtual-scroll-element.test.ts deleted file mode 100644 index 20c25a8561..0000000000 --- a/packages/app/src/components/virtual-scroll-element.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { expect, test } from "bun:test" -import { virtualScrollElement } from "./virtual-scroll-element" - -test("resolves the connected viewport that owns the virtual root", () => { - const stale = document.createElement("div") - stale.className = "scroll-view__viewport" - const viewport = document.createElement("div") - viewport.className = "scroll-view__viewport" - const root = document.createElement("div") - viewport.append(root) - document.body.append(viewport) - - expect(virtualScrollElement(root)).toBe(viewport) - expect(virtualScrollElement(root)).not.toBe(stale) - - viewport.remove() - expect(virtualScrollElement(root)).toBeNull() -}) diff --git a/packages/app/src/components/virtual-scroll-element.ts b/packages/app/src/components/virtual-scroll-element.ts deleted file mode 100644 index 8708781d86..0000000000 --- a/packages/app/src/components/virtual-scroll-element.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function virtualScrollElement(root: HTMLElement | undefined) { - if (!root?.isConnected) return null - return root.closest(".scroll-view__viewport") -} diff --git a/packages/app/src/components/windows-app-menu.tsx b/packages/app/src/components/windows-app-menu.tsx index e41c2a584a..017ac4cf8d 100644 --- a/packages/app/src/components/windows-app-menu.tsx +++ b/packages/app/src/components/windows-app-menu.tsx @@ -43,7 +43,7 @@ export function WindowsAppMenu(props: { runAction(entry.action) return } - if (entry.href) props.platform.openExternal(entry.href) + if (entry.href) props.platform.openLink(entry.href) } return ( diff --git a/packages/app/src/context/closed-tabs.ts b/packages/app/src/context/closed-tabs.ts deleted file mode 100644 index 5c49b94b6f..0000000000 --- a/packages/app/src/context/closed-tabs.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { SessionTab, Tab } from "./tabs" - -export type ClosedTab = { - tab: SessionTab - index: number -} - -const CLOSED_TAB_LIMIT = 25 - -// Only session tabs are recorded; closing a draft tab deletes its persisted -// state, so a reopened draft would come back empty anyway. -export function pushClosedTab(stack: ClosedTab[], tab: Tab, index: number): ClosedTab[] { - if (tab.type !== "session") return stack - return [...stack, { tab: { ...tab }, index }].slice(-CLOSED_TAB_LIMIT) -} - -// Pops the most recently closed tab that is not open again, -// discarding stale entries along the way. -export function takeClosedTab(stack: ClosedTab[], tabs: Tab[]): { entry?: ClosedTab; stack: ClosedTab[] } { - const remaining = [...stack] - while (remaining.length) { - const entry = remaining.pop() - if (entry && !isOpen(tabs, entry.tab)) return { entry, stack: remaining } - } - return { stack: remaining } -} - -export function removeClosedTabs(stack: ClosedTab[], server: SessionTab["server"], sessionIDs: string[]) { - const removed = new Set(sessionIDs) - return stack.filter((entry) => entry.tab.server !== server || !removed.has(entry.tab.sessionId)) -} - -export function nextTabAfterClose(tabs: Tab[], index: number, active: boolean) { - if (!active) return undefined - return tabs[index + 1] ?? tabs[index - 1] ?? null -} - -function isOpen(tabs: Tab[], tab: SessionTab) { - return tabs.some((item) => item.type === "session" && item.server === tab.server && item.sessionId === tab.sessionId) -} diff --git a/packages/app/src/context/command.test.ts b/packages/app/src/context/command.test.ts index e0d9057511..2b956287c5 100644 --- a/packages/app/src/context/command.test.ts +++ b/packages/app/src/context/command.test.ts @@ -1,70 +1,25 @@ import { describe, expect, test } from "bun:test" -import { - activeCommandRegistrations, - addCommandRegistration, - commandPaletteOptions, - resolveKeybindOption, - type CommandOption, -} from "./command" +import { upsertCommandRegistration } from "./command" -const paletteOptions: CommandOption[] = [ - { id: "settings.open", title: "Open settings" }, - { id: "session.undo", title: "Undo" }, - { id: "file.open", title: "Open file" }, - { id: "hidden", title: "Hidden", hidden: true }, - { id: "disabled", title: "Disabled", disabled: true }, -] - -describe("commandPaletteOptions", () => { - test("keeps visible enabled commands", () => { - expect(commandPaletteOptions(paletteOptions).map((option) => option.id)).toEqual(["settings.open", "session.undo"]) - }) -}) - -describe("command registrations", () => { - test("shadows keyed registrations while retaining the previous owner", () => { +describe("upsertCommandRegistration", () => { + test("replaces keyed registrations", () => { const one = () => [{ id: "one", title: "One" }] const two = () => [{ id: "two", title: "Two" }] - const registrations = addCommandRegistration([{ key: "layout", options: one }], { - key: "layout", - options: two, - }) - const active = activeCommandRegistrations(registrations) + const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two }) - expect(registrations).toHaveLength(2) - expect(active).toHaveLength(1) - expect(active[0]?.options).toBe(two) - - const restored = activeCommandRegistrations(registrations.filter((entry) => entry.options !== two)) - expect(restored).toHaveLength(1) - expect(restored[0]?.options).toBe(one) + expect(next).toHaveLength(1) + expect(next[0]?.options).toBe(two) }) test("keeps unkeyed registrations additive", () => { const one = () => [{ id: "one", title: "One" }] const two = () => [{ id: "two", title: "Two" }] - const next = activeCommandRegistrations(addCommandRegistration([{ options: one }], { options: two })) + const next = upsertCommandRegistration([{ options: one }], { options: two }) expect(next).toHaveLength(2) expect(next[0]?.options).toBe(two) expect(next[1]?.options).toBe(one) }) }) - -describe("resolveKeybindOption", () => { - test("prefers a matching contextual command over the global fallback", () => { - const fallback = { id: "tab.close", title: "Close tab" } - const contextual = { id: "terminal.close", title: "Close terminal", when: () => true } - - expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(contextual) - }) - - test("uses the global fallback outside the command context", () => { - const fallback = { id: "tab.close", title: "Close tab" } - const contextual = { id: "terminal.close", title: "Close terminal", when: () => false } - - expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(fallback) - }) -}) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 054b99e806..0c935307ad 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -11,7 +11,7 @@ import { Persist, persisted } from "@/utils/persist" const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) const PALETTE_ID = "command.palette" -export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p" +const DEFAULT_PALETTE_KEYBIND = "mod+shift+p" const SUGGESTED_PREFIX = "suggested." const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"]) @@ -82,22 +82,10 @@ export interface CommandOption { suggested?: boolean disabled?: boolean hidden?: boolean - when?: (event: KeyboardEvent) => boolean onSelect?: (source?: "palette" | "keybind" | "slash") => void onHighlight?: () => (() => void) | void } -export function commandPaletteOptions(options: CommandOption[]) { - return options.filter( - (option) => - !option.disabled && !option.hidden && !option.id.startsWith(SUGGESTED_PREFIX) && option.id !== "file.open", - ) -} - -export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) { - return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when) -} - type CommandSource = "palette" | "keybind" | "slash" export type CommandCatalogItem = { @@ -114,18 +102,9 @@ export type CommandRegistration = { options: Accessor } -export function addCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) { - return [entry, ...registrations] -} - -export function activeCommandRegistrations(registrations: CommandRegistration[]) { - const keys = new Set() - return registrations.filter((entry) => { - if (entry.key === undefined) return true - if (keys.has(entry.key)) return false - keys.add(entry.key) - return true - }) +export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) { + if (entry.key === undefined) return [entry, ...registrations] + return [entry, ...registrations.filter((x) => x.key !== entry.key)] } export function parseKeybind(config: string): Keybind[] { @@ -290,7 +269,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const seen = new Set() const all: CommandOption[] = [] - for (const reg of activeCommandRegistrations(store.registrations)) { + for (const reg of store.registrations) { for (const opt of reg.options()) { if (seen.has(opt.id)) { if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) { @@ -355,7 +334,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex }) const keymap = createMemo(() => { - const map = new Map() + const map = new Map() for (const option of options()) { if (option.id.startsWith(SUGGESTED_PREFIX)) continue if (option.disabled) continue @@ -365,12 +344,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex for (const kb of keybinds) { if (!kb.key) continue const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt) - const existing = map.get(sig) - if (existing) { - existing.push(option) - continue - } - map.set(sig, [option]) + if (map.has(sig)) continue + map.set(sig, option) } } return map @@ -391,7 +366,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex } const showPalette = () => { - run(PALETTE_ID, "palette") + run("file.open", "palette") } const handleKeyDown = (event: KeyboardEvent) => { @@ -399,7 +374,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const sig = signatureFromEvent(event) const isPalette = palette().has(sig) - const option = resolveKeybindOption(keymap().get(sig), event) + const option = keymap().get(sig) const modified = event.ctrlKey || event.metaKey || event.altKey const isTab = event.key === "Tab" @@ -408,19 +383,17 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex if (isPalette) { event.preventDefault() - event.stopPropagation() showPalette() return } if (!option) return event.preventDefault() - event.stopPropagation() option.onSelect?.("keybind") } onMount(() => { - makeEventListener(document, "keydown", handleKeyDown, { capture: true }) + makeEventListener(document, "keydown", handleKeyDown) }) function register(cb: () => CommandOption[]): void @@ -434,7 +407,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex key: id, options, } - setStore("registrations", (arr) => addCommandRegistration(arr, entry)) + setStore("registrations", (arr) => upsertCommandRegistration(arr, entry)) onCleanup(() => { setStore("registrations", (arr) => arr.filter((x) => x !== entry)) }) diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index 09e890a5e5..afc59b5956 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -85,15 +85,7 @@ function createCommentSessionState(store: Store, setStore: SetStor active: null as CommentFocus | null, }) - // Reuse the previous array when contents are unchanged so consumers keep a stable - // identity; a fresh array per call cascaded into diff annotation re-renders. - let lastAll: LineComment[] = [] - const all = () => { - const next = aggregate(store.comments) - if (next.length === lastAll.length && next.every((item, index) => item === lastAll[index])) return lastAll - lastAll = next - return next - } + const all = () => aggregate(store.comments) const setRef = ( key: "focus" | "active", diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index befd5b61e0..68e6b19cef 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -5,7 +5,6 @@ import { produce, reconcile, type SetStoreFunction } from "solid-js/store" import type { createServerSdkContext } from "./server-sdk" import type { createServerSyncContextInner } from "./server-sync" import type { State } from "./global-sync/types" -import { normalizeSessionInfo } from "@/utils/session" const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const sessionFields = new Set([ @@ -16,7 +15,6 @@ const sessionFields = new Set([ "permission", "question", "message", - "session_message", "part", "part_text_accum_delta", ]) @@ -116,6 +114,7 @@ export const createDirSyncContext = ( await serverSync.session.sync(sessionID, options) index(sessionID) }, + diff: serverSync.session.diff, todo: serverSync.session.todo, history: serverSync.session.history, evict(sessionID: string) { @@ -124,9 +123,9 @@ export const createDirSyncContext = ( fetch: async (count = 10) => { const [store, setStore] = current() setStore("limit", (value) => value + count) - const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" }) - const sessions = response.data - .map(normalizeSessionInfo) + const response = await client.session.list() + const sessions = (response.data ?? []) + .filter((session) => !!session?.id) .sort((a, b) => cmp(a.id, b.id)) .slice(0, store.limit) sessions.forEach(serverSync.session.remember) @@ -134,8 +133,7 @@ export const createDirSyncContext = ( }, more: createMemo(() => current()[0].session.length >= current()[0].limit), archive: async (sessionID: string) => { - if ((await serverSDK.protocol) !== "v1") return - await serverSDK.client.session.update({ sessionID, directory, time: { archived: Date.now() } }) + await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } }) current()[1]( "session", produce((draft) => { diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index fbbef3a2a8..f7668c1949 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -203,23 +203,12 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ return promise } - const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) => - serverSDK() - .api.file.find( - { - location: { directory: sdk().directory }, - query, - type: dirs === "true" ? "directory" : "file", - limit: options?.limit, - }, - { signal: options?.signal }, - ) + const search = (query: string, dirs: "true" | "false") => + sdk() + .client.find.files({ query, dirs }) .then( - (x) => x.data.map((entry) => path.normalize(entry.path)), - (error) => { - if (options?.signal?.aborted) throw error - return [] - }, + (x) => (x.data ?? []).map(path.normalize), + () => [], ) const stop = sdk().event.listen((e) => { @@ -295,8 +284,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ setScrollLeft, selectedLines, setSelectedLines, - searchFiles: (query: string, options?: { limit?: number; signal?: AbortSignal }) => - search(query, "false", options), + searchFiles: (query: string) => search(query, "false"), searchFilesAndDirectories: (query: string) => search(query, "true"), } }, diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts index 99dd88ae0e..feef6d466e 100644 --- a/packages/app/src/context/file/path.test.ts +++ b/packages/app/src/context/file/path.test.ts @@ -21,29 +21,6 @@ describe("file path helpers", () => { expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts") }) - test("normalizes Windows directory separators", () => { - const path = createPathHelpers(() => "C:\\repo") - expect(path.normalizeDir("frontend\\")).toBe("frontend") - expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src") - expect(path.normalizeDir("C:\\repo\\frontend\\")).toBe("frontend") - }) - - test("normalizes separators for Windows roots written with forward slashes", () => { - const path = createPathHelpers(() => "C:/repo") - expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src") - }) - - test("normalizes separators for Windows UNC roots", () => { - const path = createPathHelpers(() => "\\\\server\\share") - expect(path.normalizeDir("\\\\server\\share\\frontend\\")).toBe("frontend") - }) - - test("preserves backslashes in POSIX directory names", () => { - const path = createPathHelpers(() => "/repo") - expect(path.normalizeDir("literal\\name\\")).toBe("literal\\name\\") - expect(path.normalizeDir("literal\\name/")).toBe("literal\\name") - }) - test("keeps query/hash stripping behavior stable", () => { expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts") expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts") diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts index 2bc4bde5e9..53f072b6cb 100644 --- a/packages/app/src/context/file/path.ts +++ b/packages/app/src/context/file/path.ts @@ -140,12 +140,7 @@ export function createPathHelpers(scope: () => string) { return normalize(tabValue) } - const normalizeDir = (input: string) => { - const path = normalize(input) - const root = scope() - const windows = /^[A-Za-z]:/.test(root) || root.startsWith("\\\\") - return (windows ? path.replace(/\\/g, "/") : path).replace(/\/+$/, "") - } + const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "") return { normalize, diff --git a/packages/app/src/context/file/tree-store.ts b/packages/app/src/context/file/tree-store.ts index 5769c318f0..a86051d286 100644 --- a/packages/app/src/context/file/tree-store.ts +++ b/packages/app/src/context/file/tree-store.ts @@ -127,14 +127,10 @@ export function createFileTreeStore(options: TreeStoreOptions) { return promise } - // `list: false` marks a directory expanded without fetching its children, for - // trees whose nodes are synthesized from a filter; listing directories that - // only exist on a diff's base branch fails and surfaces error toasts. - const expandDir = (input: string, behavior?: { list?: boolean }) => { + const expandDir = (input: string) => { const dir = options.normalizeDir(input) ensureDir(dir) setTree("dir", dir, "expanded", true) - if (behavior?.list === false) return void listDir(dir) } diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index a95706a3db..73de5b9ce6 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -2,89 +2,54 @@ import { describe, expect, test } from "bun:test" import { createStore } from "solid-js/store" import { QueryClient } from "@tanstack/solid-query" import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client" -import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise" import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" -import { - bootstrapDirectory, - loadAgentsQuery, - loadCommands, - loadGlobalConfigQuery, - loadPathQuery, - loadProjectsQuery, - loadProvidersQuery, - loadReferencesQuery, -} from "./bootstrap" +import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap" import type { State, VcsCache } from "./types" import { ServerScope } from "@/utils/server-scope" -import type { ServerApi } from "@/utils/server" - -type ProjectApi = ServerApi["project"] const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse -const api = { - agent: { list: async () => ({ location: {}, data: [] }) }, - provider: { list: async () => ({ location: {}, data: [] }) }, - model: { - list: async () => ({ location: {}, data: [] }), - default: async () => ({ location: {}, data: null }), - }, - permission: { request: { list: async () => ({ location: {}, data: [] }) } }, - project: { - list: async () => [], - current: async () => ({ id: "project", directory: "/project" }), - }, - question: { request: { list: async () => ({ location: {}, data: [] }) } }, - reference: { list: async () => ({ location: {}, data: [] }) }, - vcs: { get: async () => ({ location: {}, data: {} }) }, -} as unknown as ServerApi - -function directoryState() { - return createStore({ - status: "loading", - agent: [], - command: [], - reference: [], - project: "", - projectMeta: undefined, - icon: undefined, - provider_ready: true, - provider, - config: {}, - path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, - session: [], - sessionTotal: 0, - session_status: {}, - session_working(id: string) { - return this.session_status[id]?.type !== "idle" - }, - session_diff: {}, - todo: {}, - permission: {}, - question: {}, - mcp_ready: true, - mcp: {}, - mcp_resource: {}, - lsp_ready: true, - lsp: [], - vcs: undefined, - limit: 5, - message: {}, - session_message: {}, - part: {}, - part_text_accum_delta: {}, - }) -} describe("bootstrapDirectory", () => { - test("uses legacy MCP endpoints while refreshing a v1 directory", async () => { - const legacyConfigReads: string[] = [] + test("marks a loading directory partial during bootstrap and complete after success", async () => { const mcpReads: string[] = [] - const [store, setStore] = directoryState() + const [store, setStore] = createStore({ + status: "loading", + agent: [], + command: [], + reference: [], + project: "", + projectMeta: undefined, + icon: undefined, + provider_ready: true, + provider, + config: {}, + path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, + session: [], + sessionTotal: 0, + session_status: {}, + session_working(id: string) { + return this.session_status[id]?.type !== "idle" + }, + session_diff: {}, + todo: {}, + permission: {}, + question: {}, + mcp_ready: true, + mcp: {}, + mcp_resource: {}, + lsp_ready: true, + lsp: [], + vcs: undefined, + limit: 5, + message: {}, + part: {}, + part_text_accum_delta: {}, + }) await bootstrapDirectory({ directory: "/project", scope: ServerScope.local, - mcp: true, + mcp: false, global: { config: {} satisfies Config, path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, @@ -93,12 +58,7 @@ describe("bootstrapDirectory", () => { }, sdk: { app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) }, - config: { - get: async () => { - legacyConfigReads.push("directory") - return { data: {} } - }, - }, + config: { get: async () => ({ data: {} }) }, session: { status: async () => ({ data: {} }) }, vcs: { get: async () => ({ data: undefined }) }, command: { @@ -116,24 +76,14 @@ describe("bootstrapDirectory", () => { return { data: {} } }, }, - experimental: { - resource: { - list: async () => { - mcpReads.push("resource") - return { data: {} } - }, - }, - }, provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, } as unknown as OpencodeClient, - api, store, setStore, vcsCache: { setStore() {} } as unknown as VcsCache, loadSessions() {}, translate: (key) => key, queryClient: new QueryClient(), - protocol: Promise.resolve("v1"), }) expect(store.status).toBe("partial") @@ -141,190 +91,21 @@ describe("bootstrapDirectory", () => { await new Promise((resolve) => setTimeout(resolve, 80)) expect(store.status).toBe("complete") - expect(legacyConfigReads).toEqual(["directory"]) - expect(mcpReads.sort()).toEqual(["command", "resource", "status"]) - }) - - test("skips legacy config while refreshing a v2 directory", async () => { - const [store, setStore] = directoryState() - - await bootstrapDirectory({ - directory: "/project", - scope: ServerScope.local, - mcp: false, - global: { - config: {} satisfies Config, - path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, - project: [{ id: "project", worktree: "/project" } as Project], - provider, - }, - sdk: { - config: { - get: async () => { - throw new Error("legacy directory config should not be called") - }, - }, - } as unknown as OpencodeClient, - api, - store, - setStore, - vcsCache: { setStore() {} } as unknown as VcsCache, - loadSessions() {}, - translate: (key) => key, - queryClient: new QueryClient(), - protocol: Promise.resolve("v2"), - }) - - expect(store.status).toBe("partial") - - await new Promise((resolve) => setTimeout(resolve, 80)) - - expect(store.status).toBe("complete") - }) -}) - -describe("config queries", () => { - test("skips legacy global config for v2 servers", async () => { - const sdk = { - global: { - config: { - get: async () => { - throw new Error("legacy global config should not be called") - }, - }, - }, - } as unknown as OpencodeClient - - const result = await new QueryClient().fetchQuery( - loadGlobalConfigQuery(ServerScope.local, sdk, Promise.resolve("v2")), - ) - - expect(result).toEqual({}) - }) - - test("loads legacy global config for v1 servers", async () => { - const calls: string[] = [] - const config = { shell: "zsh" } satisfies Config - const sdk = { - global: { - config: { - get: async () => { - calls.push("global") - return { data: config } - }, - }, - }, - } as unknown as OpencodeClient - - const result = await new QueryClient().fetchQuery( - loadGlobalConfigQuery(ServerScope.local, sdk, Promise.resolve("v1")), - ) - - expect(result).toEqual(config) - expect(calls).toEqual(["global"]) + expect(mcpReads).toEqual([]) }) }) describe("query keys", () => { test("partitions identical directories by server scope", () => { - const client = {} as Parameters[2] - const api = {} as CatalogApi + const client = {} as OpencodeClient const remote = "https://debian.example" as typeof ServerScope.local expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"]) expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"]) - expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"]) - }) - - test("loads the current provider and model catalog", async () => { - const calls: unknown[] = [] - const api = { - provider: { - list: async (input: unknown) => { - calls.push(["provider", input]) - return { location: {}, data: [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] } - }, - }, - model: { - list: async (input: unknown) => { - calls.push(["model", input]) - return { location: {}, data: [] } - }, - default: async (input: unknown) => { - calls.push(["default", input]) - return { location: {}, data: null } - }, - }, - } as unknown as CatalogApi - - const result = await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api)) - - expect(calls).toEqual([ - ["provider", { location: { directory: "/repo" } }], - ["model", { location: { directory: "/repo" } }], - ["default", { location: { directory: "/repo" } }], + expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([ + "https://debian.example", + null, + "providers", ]) - expect(result.connected).toEqual(["openai"]) - }) - - test("loads agents from the current location-scoped endpoint", async () => { - const calls: unknown[] = [] - const api = { - list: async (input: unknown) => { - calls.push(input) - return { location: {}, data: [] } - }, - } as unknown as AgentApi - - const result = await new QueryClient().fetchQuery(loadAgentsQuery(ServerScope.local, "/repo", api)) - - expect(calls).toEqual([{ location: { directory: "/repo" } }]) - expect(result).toEqual([]) - }) - - test("loads commands from the current location-scoped endpoint", async () => { - const calls: unknown[] = [] - const api = { - list: async (input: unknown) => { - calls.push(input) - return { - location: {}, - data: [{ name: "review", template: "Review files" /* source: "command" as const */ }], - } - }, - } as unknown as CommandApi - - const result = await loadCommands("/repo", api) - - expect(calls).toEqual([{ location: { directory: "/repo" } }]) - expect(result).toEqual([{ name: "review", template: "Review files" /* source: "command" */ }]) - }) - - test("loads projects from the current endpoint", async () => { - const api = { - list: async () => [ - { id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] }, - { id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] }, - ], - } as unknown as ProjectApi - - const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api)) - - expect(result.map((project) => project.id)).toEqual(["a", "b"]) - }) - - test("loads references from the current location-scoped endpoint", async () => { - const calls: unknown[] = [] - const api = { - list: async (input: unknown) => { - calls.push(input) - return { location: {}, data: [{ name: "AGENTS.md", path: "/repo/AGENTS.md", source: "instructions" }] } - }, - } as unknown as ReferenceApi - - const result = await new QueryClient().fetchQuery(loadReferencesQuery(ServerScope.local, "/repo", api)) - - expect(calls).toEqual([{ location: { directory: "/repo" } }]) - expect(result).toHaveLength(1) }) }) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 0f3e473816..8f253a975a 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -9,20 +9,6 @@ import type { ReferenceInfo, Session, } from "@opencode-ai/sdk/v2/client" -import type { - AgentListInput, - AgentListOutput, - CatalogApi, - CommandInfo, - CommandListInput, - CommandListOutput, - ProjectCurrentInput, - ProjectCurrentOutput, - ProjectListOutput, - ReferenceListInput, - ReferenceListOutput, - SessionApi, -} from "@opencode-ai/client/promise" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" import { retry } from "@opencode-ai/core/util/retry" @@ -30,21 +16,12 @@ import { batch } from "solid-js" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { State, VcsCache } from "./types" import type { ServerSession } from "../server-session" -import { - cmp, - normalizeAgentList, - normalizePermissionRequest, - normalizeProjectInfo, - normalizeProviderList, -} from "./utils" +import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { formatServerError } from "@/utils/server-errors" import { QueryClient, queryOptions } from "@tanstack/solid-query" import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { ScopedKey, type ServerScope } from "@/utils/server-scope" -import { normalizeSessionInfo } from "@/utils/session" -import type { ServerProtocol } from "@/utils/server-protocol" -import type { ServerApi } from "@/utils/server" type GlobalStore = { ready: boolean @@ -105,35 +82,21 @@ function showErrors(input: { }) } -export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient, protocol?: Promise) => +export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) => queryOptions({ queryKey: [scope, "config"], - queryFn: async () => { - if ((await protocol) !== "v1") return {} - return retry(() => sdk.global.config.get().then((x) => x.data!)) - }, + queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)), }) -type ProjectApi = { - readonly list: () => Promise - readonly current: (input?: ProjectCurrentInput) => Promise -} - -type McpApi = ServerApi["mcp"] -type PermissionApi = ServerApi["permission"] -type QuestionApi = ServerApi["question"] -type VcsApi = ServerApi["vcs"] - -export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) => +export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) => queryOptions({ queryKey: [scope, "project"], queryFn: () => retry(() => - api.list().then((projects) => { - return projects + sdk.project.list().then((x) => { + return (x.data ?? []) .filter((p) => !!p?.id) .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) - .map(normalizeProjectInfo) .slice() .sort((a, b) => cmp(a.id, b.id)) }), @@ -142,8 +105,6 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) => export async function bootstrapGlobal(input: { serverSDK: OpencodeClient - serverAPI: CatalogApi & { readonly project: ProjectApi } - protocol?: Promise scope: ServerScope requestFailedTitle: string translate: (key: string, vars?: Record) => string @@ -152,15 +113,12 @@ export async function bootstrapGlobal(input: { queryClient: QueryClient }) { const slow = [ - () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK, input.protocol)), - () => - input.queryClient.fetchQuery( - loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol), - ), - () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)), + () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)), + () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)), + () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)), () => input.queryClient - .fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)) + .fetchQuery(loadProjectsQuery(input.scope, input.serverSDK)) .then((data) => input.setGlobalStore("project", data)), ] await runAll(slow) @@ -204,126 +162,44 @@ function warmSessions(input: { ids: string[] store: Store setStore: SetStoreFunction - api: SessionApi + sdk: OpencodeClient }) { const known = new Set(input.store.session.map((item) => item.id)) const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id)) if (ids.length === 0) return Promise.resolve() return Promise.all( ids.map((sessionID) => - retry(() => input.api.get({ sessionID })).then((session) => - mergeSession(input.setStore, normalizeSessionInfo(session)), - ), + retry(() => input.sdk.session.get({ sessionID })).then((x) => { + const session = x.data + if (!session?.id) return + mergeSession(input.setStore, session) + }), ), ).then(() => undefined) } -export const loadProvidersQuery = ( - scope: ServerScope, - directory: string | null, - sdk: CatalogApi, - legacy?: OpencodeClient, - protocol?: Promise, -) => +export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => queryOptions({ queryKey: [scope, directory, "providers"], - queryFn: () => - retry(async () => { - if ((await protocol) === "v1" && legacy) { - const result = await legacy.provider.list() - return normalizeProviderList(result.data!) - } - const location = directory ? { location: { directory } } : undefined - const [providers, models, defaultModel] = await Promise.all([ - sdk.provider.list(location), - sdk.model.list(location), - sdk.model.default(location), - ]) - return normalizeProviderList(providers.data, models.data, defaultModel.data) - }), + queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))), }) -type AgentListApi = { - readonly list: (input?: AgentListInput) => Promise -} - -type CommandListApi = { - readonly list: (input?: CommandListInput) => Promise -} - -type ReferenceListApi = { - readonly list: (input?: ReferenceListInput) => Promise -} - -export const loadAgentsQuery = ( - scope: ServerScope, - directory: string, - sdk: AgentListApi, - legacy?: OpencodeClient, - protocol?: Promise, -) => +export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => queryOptions({ queryKey: [scope, directory, "agents"], - queryFn: () => - retry(async () => { - if ((await protocol) === "v1" && legacy) return normalizeAgentList((await legacy.app.agents()).data ?? []) - return sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data)) - }), + queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))), }) -export const loadCommands = ( - directory: string, - api: CommandListApi, - legacy?: OpencodeClient, - protocol?: Promise, -): Promise => - retry(async () => { - if ((await protocol) === "v1" && legacy) { - return ((await legacy.command.list()).data ?? []).map((command) => { - const [providerID, id] = command.model?.split("/") ?? [] - return { - name: command.name, - template: command.template, - description: command.description, - agent: command.agent, - model: providerID && id ? { providerID, id } : undefined, - subtask: command.subtask, - // source: command.source === "skill" ? undefined : command.source, - } - }) - } - return api.list({ location: { directory } }).then((result) => result.data) - }) - -export const loadPathQuery = ( - scope: ServerScope, - directory: string | null, - sdk: OpencodeClient, - protocol?: Promise, -) => +export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => queryOptions({ queryKey: [scope, directory, "path"], - queryFn: async () => { - if ((await protocol) !== "v1") - return { state: "", config: "", worktree: "", directory: directory ?? "", home: "" } - return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!)) - }, + queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)), }) -export const loadReferencesQuery = ( - scope: ServerScope, - directory: string, - api: ReferenceListApi, - legacy?: OpencodeClient, - protocol?: Promise, -) => +export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => queryOptions({ queryKey: [scope, directory, "references"] as const, - queryFn: () => - retry(async () => { - if ((await protocol) === "v1" && legacy) return (await legacy.v2.reference.list()).data?.data ?? [] - return api.list({ location: { directory } }).then((result) => result.data) - }).catch(() => []), + queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []), placeholderData: [], }) @@ -332,17 +208,6 @@ export async function bootstrapDirectory(input: { scope: ServerScope mcp: boolean sdk: OpencodeClient - api: CatalogApi & { - readonly agent: AgentListApi - readonly command: CommandListApi - readonly mcp: McpApi - readonly permission: PermissionApi - readonly project: ProjectApi - readonly question: QuestionApi - readonly reference: ReferenceListApi - readonly session: SessionApi - readonly vcs: VcsApi - } store: Store setStore: SetStoreFunction vcsCache: VcsCache @@ -356,7 +221,6 @@ export async function bootstrapDirectory(input: { } queryClient: QueryClient session?: ServerSession - protocol?: Promise }) { const loading = input.store.status !== "complete" const seededProject = projectID(input.directory, input.global.project) @@ -376,86 +240,62 @@ export async function bootstrapDirectory(input: { () => Promise.resolve(input.loadSessions(input.directory)), () => input.queryClient - .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol)) + .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk)) .then((data) => input.setStore("agent", data)), () => - retry(async () => { - if ((await input.protocol) !== "v1") return - return input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false }))) - }), + retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))), () => retry(() => - (async () => { - if ((await input.protocol) !== "v1") return - const x = await input.sdk.session.status() - if (!input.session) { - input.setStore("session_status", x.data!) - return + input.sdk.session.status().then(async (x) => { + if (input.session) { + const statuses = x.data ?? {} + await Promise.all( + Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)), + ) + input.session.set( + "session_status", + produce((draft) => { + for (const sessionID of Object.keys(draft)) { + if (statuses[sessionID]) continue + if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID] + } + }), + ) + for (const [sessionID, status] of Object.entries(statuses)) { + input.session.set("session_status", sessionID, reconcile(status)) + } } - const statuses = x.data ?? {} - input.session.set( - "session_status", - produce((draft) => { - for (const sessionID of Object.keys(draft)) { - if (statuses[sessionID]) continue - if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID] - } - }), - ) - for (const [sessionID, status] of Object.entries(statuses)) { - input.session.set("session_status", sessionID, reconcile(status)) - } - await Promise.all( - Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)), - ) - })(), + if (!input.session) input.setStore("session_status", x.data!) + }), ), !seededProject && - (() => - retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) => - input.setStore("project", project.id), - )), + (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))), !seededPath && (() => - input.queryClient - .ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk, input.protocol)) - .then((data) => { - const next = projectID(data.directory ?? input.directory, input.global.project) - if (next) input.setStore("project", next) - })), - () => - retry(async () => { - if ((await input.protocol) !== "v1") return - return input.sdk.vcs.get().then((result) => { - const next = { branch: result.data?.branch, default_branch: result.data?.default_branch } - input.setStore("vcs", next) - if (next) input.vcsCache.setStore("value", next) - }) - }), - input.mcp && - (() => - loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) => - input.setStore("command", commands), - )), - () => - input.queryClient.fetchQuery( - loadReferencesQuery(input.scope, input.directory, input.api.reference, input.sdk, input.protocol), - ), + input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => { + const next = projectID(data.directory ?? input.directory, input.global.project) + if (next) input.setStore("project", next) + })), () => retry(() => - (async () => { - if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? [] - return input.api.permission.request - .list({ location: { directory: input.directory } }) - .then((result) => result.data.map(normalizePermissionRequest)) - })().then((permissions) => { - const ids = permissions.map((permission) => permission.sessionID) + input.sdk.vcs.get().then((x) => { + const next = x.data ?? input.store.vcs + input.setStore("vcs", next) + if (next) input.vcsCache.setStore("value", next) + }), + ), + input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))), + () => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)), + () => + retry(() => + input.sdk.permission.list().then((x) => { + const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id) const grouped = groupBySession( - permissions.filter((permission) => !!permission.id && !!permission.sessionID), + (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), ) const warm = input.session ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) - : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) + : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }) return warm.then(() => batch(() => { const current = input.session?.data.permission ?? input.store.permission @@ -479,19 +319,12 @@ export async function bootstrapDirectory(input: { ), () => retry(() => - (async () => { - if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? [] - return input.api.question.request - .list({ location: { directory: input.directory } }) - .then((result) => result.data) - })().then((questions) => { - const ids = questions.map((question) => question.sessionID) - const grouped = groupBySession( - questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[], - ) + input.sdk.question.list().then((x) => { + const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id) + const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) const warm = input.session ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) - : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) + : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }) return warm.then(() => batch(() => { const current = input.session?.data.question ?? input.store.question @@ -514,27 +347,17 @@ export async function bootstrapDirectory(input: { }), ), () => Promise.resolve(input.loadSessions(input.directory)), - input.mcp && - (() => - input.queryClient.fetchQuery( - loadMcpQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol), - )), - input.mcp && - (() => - input.queryClient.fetchQuery( - loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol), - )), + input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))), + input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))), () => - input.queryClient - .fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol)) - .catch((err) => { - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), - }) - }), + input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => { + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(err, input.translate), + }) + }), ].filter(Boolean) as (() => Promise)[] await waitForPaint() diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index e05b7c39a9..ddbfffe1ad 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -221,56 +221,4 @@ describe("createChildStoreManager", () => { dispose() } }) - - test("keeps non-bootstrapping children passive until a real directory access", () => { - let manager: ReturnType | undefined - const offset = querySingles.length - const bootstraps: string[] = [] - - const dispose = createOwner((owner) => { - manager = createChildStoreManager({ - owner, - scope: ServerScope.local, - persist, - isBooting: () => false, - isLoadingSessions: () => false, - onBootstrap(directory) { - bootstraps.push(directory) - }, - onMcp() {}, - onDispose() {}, - translate: (key) => key, - queryOptions: queryOptionsApi, - global: { provider }, - }) - }) - - try { - if (!manager) throw new Error("manager required") - const [store] = manager.child("/project", { bootstrap: false }) - const queries = querySingles.slice(offset) - - expect(queries).toHaveLength(6) - expect(queries[0]?.().enabled).toBe(false) - expect(queries[3]?.().enabled).toBe(false) - expect(queries[4]?.().enabled).toBe(false) - expect(queries[5]?.().enabled).toBe(false) - expect(store.path.directory).toBe("/project") - expect(store.provider_ready).toBe(false) - expect(store.lsp_ready).toBe(false) - expect(bootstraps).toEqual([]) - - manager.child("/project") - expect(queries[0]?.().enabled).toBe(true) - expect(queries[3]?.().enabled).toBe(true) - expect(queries[4]?.().enabled).toBe(true) - expect(queries[5]?.().enabled).toBe(true) - expect(bootstraps).toEqual(["/project"]) - - manager.child("/project", { bootstrap: false }) - expect(queries[0]?.().enabled).toBe(true) - } finally { - dispose() - } - }) }) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 4eaa785789..53b9536182 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -45,8 +45,6 @@ export function createChildStoreManager(input: { const disposers = new Map void>() const mcpDirectories = new Set() const mcpToggles = new Map void>() - const activeDirectories = new Set() - const activationToggles = new Map void>() const markKey = (key: DirectoryKey) => { if (!key) return @@ -120,8 +118,6 @@ export function createChildStoreManager(input: { lifecycle.delete(key) mcpDirectories.delete(key) mcpToggles.delete(key) - activeDirectories.delete(key) - activationToggles.delete(key) const dispose = disposers.get(key) if (dispose) { dispose() @@ -186,27 +182,20 @@ export function createChildStoreManager(input: { const initialMeta = meta[0].value const initialIcon = icon[0].value const [mcpEnabled, setMcpEnabled] = createSignal(false) - const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false) - const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() })) + const pathQuery = useQuery(() => input.queryOptions.path(key)) const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() })) const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() })) - const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() })) - const providerQuery = useQuery(() => ({ - ...input.queryOptions.providers(key), - enabled: instanceQueriesEnabled(), - })) - const referenceQuery = useQuery(() => ({ - ...input.queryOptions.references(key), - enabled: instanceQueriesEnabled(), - })) + const lspQuery = useQuery(() => input.queryOptions.lsp(key)) + const providerQuery = useQuery(() => input.queryOptions.providers(key)) + const referenceQuery = useQuery(() => input.queryOptions.references(key)) const child = createStore({ project: "", projectMeta: initialMeta, icon: initialIcon, get provider_ready() { - return instanceQueriesEnabled() && !providerQuery.isLoading + return !providerQuery.isLoading }, get provider() { const EMPTY = { all: new Map(), connected: [], default: {} } @@ -247,7 +236,7 @@ export function createChildStoreManager(input: { return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {}) }, get lsp_ready() { - return instanceQueriesEnabled() && !lspQuery.isLoading + return !lspQuery.isLoading }, get lsp() { return lspQuery.isLoading ? [] : (lspQuery.data ?? []) @@ -255,14 +244,12 @@ export function createChildStoreManager(input: { vcs: vcsStore.value, limit: 5, message: {}, - session_message: {}, part: {}, part_text_accum_delta: {}, }) children[key] = child disposers.set(key, dispose) mcpToggles.set(key, setMcpEnabled) - activationToggles.set(key, setInstanceQueriesEnabled) const onPersistedInit = (init: Promise | string | null, run: () => void) => { if (!(init instanceof Promise)) return @@ -303,7 +290,6 @@ export function createChildStoreManager(input: { pinForOwner(key) if (options.mcp) enableMcp(directory, key, childStore) const shouldBootstrap = options.bootstrap ?? true - if (shouldBootstrap) activate(key) if (shouldBootstrap && childStore[0].status === "loading") { input.onBootstrap(directory) } @@ -315,7 +301,6 @@ export function createChildStoreManager(input: { const childStore = ensureChild(directory) if (options.mcp) enableMcp(directory, key, childStore) const shouldBootstrap = options.bootstrap ?? true - if (shouldBootstrap) activate(key) if (shouldBootstrap && childStore[0].status === "loading") { input.onBootstrap(directory) } @@ -329,16 +314,6 @@ export function createChildStoreManager(input: { if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1]) } - // Passive Home/project metadata reads must not initialize the directory. - // A real directory access enables these queries once for the store lifetime. - // TODO(v2): After Home switches to v2.project.list and root-filtered, - // updated-time v2.session.list, remove any Home-only passive child creation. - function activate(key: DirectoryKey) { - if (activeDirectories.has(key)) return - activeDirectories.add(key) - activationToggles.get(key)?.(true) - } - function disableMcp(directory: string) { const key = directoryKey(directory) if (!mcpDirectories.delete(key)) return @@ -385,7 +360,6 @@ export function createChildStoreManager(input: { unpin, pinned, mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)), - active: (directory: string) => activeDirectories.has(directoryKey(directory)), disableMcp, disposeDirectory, runEviction, diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index b53fb691b3..2811e6e3c6 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -80,7 +80,6 @@ const baseState = (input: Partial = {}) => vcs: undefined, limit: 10, message: {}, - session_message: {}, part: {}, part_text_accum_delta: {}, ...input, @@ -244,26 +243,10 @@ describe("applyDirectoryEvent", () => { expect(store.session_status.ses_1).toBeUndefined() }) - test("ignores an archived session absent from a passive directory store", () => { - const [store, setStore] = createStore(baseState({ session: [], sessionTotal: 0 })) - - applyDirectoryEvent({ - event: { type: "session.updated", properties: { info: rootSession({ id: "missing", archived: 10 }) } }, - store, - setStore, - push() {}, - directory: "/tmp", - loadLsp() {}, - }) - - expect(store.session).toEqual([]) - expect(store.sessionTotal).toBe(0) - }) - test("cleans session caches when deleted and decrements only root totals", () => { const cases = [ - { info: rootSession({ id: "ses_1" }), expectedTotal: 1, current: false }, - { info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2, current: true }, + { info: rootSession({ id: "ses_1" }), expectedTotal: 1 }, + { info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2 }, ] for (const item of cases) { @@ -287,10 +270,7 @@ describe("applyDirectoryEvent", () => { ) applyDirectoryEvent({ - event: { - type: "session.deleted", - properties: item.current ? { sessionID: item.info.id } : { info: item.info }, - }, + event: { type: "session.deleted", properties: { info: item.info } }, store, setStore, push() {}, diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 8f203715a6..15d125df39 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -8,9 +8,9 @@ import type { QuestionRequest, Session, SessionStatus, + SnapshotFileDiff, Todo, } from "@opencode-ai/sdk/v2/client" -import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" @@ -146,14 +146,15 @@ export function applyDirectoryEvent(input: { const info = (event.properties as { info: Session }).info const result = Binary.search(input.store.session, info.id, (s) => s.id) if (info.time.archived) { - if (!result.found) break if (input.store.session[result.index]!.time.archived === info.time.archived) break - input.setStore( - "session", - produce((draft) => { - draft.splice(result.index, 1) - }), - ) + if (result.found) { + input.setStore( + "session", + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo) if (info.parentID) break input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) @@ -171,11 +172,8 @@ export function applyDirectoryEvent(input: { break } case "session.deleted": { - const properties = event.properties as { sessionID?: string; info?: Session } - const sessionID = properties.info?.id ?? properties.sessionID - if (!sessionID) break - const result = Binary.search(input.store.session, sessionID, (s) => s.id) - const info = properties.info ?? (result.found ? input.store.session[result.index] : undefined) + const info = (event.properties as { info: Session }).info + const result = Binary.search(input.store.session, info.id, (s) => s.id) if (result.found) { input.setStore( "session", @@ -184,77 +182,14 @@ export function applyDirectoryEvent(input: { }), ) } - cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo) - if (info?.parentID) break + cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo) + if (info.parentID) break input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) break } - case "session.renamed": { - const properties = event.properties as { sessionID: string; title: string } - const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) - if (!result.found) break - input.setStore("session", result.index, (session) => ({ - ...session, - title: properties.title, - time: { ...session.time, updated: Date.now() }, - })) - break - } - case "session.usage.updated": { - const properties = event.properties as Pick & { sessionID: string } - const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) - if (!result.found) break - input.setStore("session", result.index, (session) => ({ - ...session, - cost: properties.cost, - tokens: properties.tokens, - })) - break - } - // case "session.archived": { - // const properties = event.properties as { sessionID: string } - // const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) - // if (!result.found) break - // const info = input.store.session[result.index] - // input.setStore( - // "session", - // produce((draft) => void draft.splice(result.index, 1)), - // ) - // cleanupSessionCaches(input.setStore, properties.sessionID) - // if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) - // break - // } - case "session.moved": { - const properties = event.properties as { - sessionID: string - location: { directory: string; workspaceID?: string } - projectID?: string - subpath?: string - } - const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) - if (!result.found) break - if (properties.location.directory === input.directory) { - input.setStore("session", result.index, (session) => ({ - ...session, - projectID: properties.projectID ?? session.projectID, - workspaceID: properties.location.workspaceID, - directory: properties.location.directory, - path: properties.subpath, - time: { ...session.time, updated: Date.now() }, - })) - break - } - const info = input.store.session[result.index] - input.setStore( - "session", - produce((draft) => void draft.splice(result.index, 1)), - ) - if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) - break - } case "session.diff": { - const props = event.properties as { sessionID: string; diff: FileDiffInfo[] } - input.setStore("session_diff", props.sessionID, reconcile(list(props.diff) as FileDiffInfo[], { key: "file" })) + const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } + input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" })) break } case "todo.updated": { diff --git a/packages/app/src/context/global-sync/home-session-index.test.ts b/packages/app/src/context/global-sync/home-session-index.test.ts deleted file mode 100644 index 4e40cc78ea..0000000000 --- a/packages/app/src/context/global-sync/home-session-index.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { SessionV2Info } from "@opencode-ai/sdk/v2/client" -import { - applyHomeSessionEvent, - appendHomeSessionEvent, - HOME_V2_SESSION_PAGE_LIMIT, - loadHomeSessionIndex, - homeSessionIndexSessions, - homeSessionIndexRefresh, - parseHomeSessionIndex, - retainHomeSessions, -} from "./home-session-index" - -const session = (input: { - id: string - directory?: string - parentID?: string - archived?: number - updated?: number -}) => ({ - id: input.id, - parentID: input.parentID, - projectID: "project", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: input.updated ?? 1, archived: input.archived }, - title: input.id, - location: { directory: input.directory ?? "/project" }, -}) - -describe("Home V2 session index", () => { - test("loads the Home index with one global V2 request", async () => { - const calls: unknown[] = [] - const result = await loadHomeSessionIndex(async (input) => { - calls.push(input) - return { data: { data: [session({ id: "root" })], cursor: {} } } - }) - - expect(result.sessions).toHaveLength(1) - expect(calls).toEqual([{ limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }]) - }) - - test("loads subsequent pages until the session index is complete", async () => { - const calls: unknown[] = [] - const controller = new AbortController() - const result = await loadHomeSessionIndex( - async (input, options) => { - calls.push({ input, signal: options.signal }) - if (!("cursor" in input)) { - return { - data: { - data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => - session({ id: `page-1-${index}` }), - ), - cursor: { next: "next-page" }, - }, - } - } - return { data: { data: [session({ id: "page-2" })], cursor: {} } } - }, - 0, - controller.signal, - ) - - expect(result.sessions).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1) - expect(calls).toEqual([ - { input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }, signal: controller.signal }, - { - input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc", cursor: "next-page" }, - signal: controller.signal, - }, - ]) - }) - - test("maps visible roots to Home session summaries", () => { - const activeNull = { - ...session({ id: "active-null", updated: 20 }), - time: { created: 1, updated: 20, archived: null }, - } as unknown as SessionV2Info - const result = parseHomeSessionIndex([ - session({ id: "root", updated: 30 }), - activeNull, - session({ id: "child", parentID: "root", updated: 40 }), - session({ id: "archived", archived: 50, updated: 50 }), - ]) - - expect(result).toEqual([ - expect.objectContaining({ - id: "root", - slug: "root", - version: "", - directory: "/project", - projectID: "project", - title: "root", - time: { created: 1, updated: 30 }, - }), - expect.objectContaining({ - id: "active-null", - time: { created: 1, updated: 20, archived: null }, - }), - ]) - }) - - test("preserves the per-directory Home retention limit", () => { - const now = 10 * 60 * 60 * 1000 - const sessions = Array.from({ length: 80 }, (_, index) => ({ - ...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0], - directory: index % 2 === 0 ? "/one" : "/two", - })) - - const retained = retainHomeSessions(sessions, 10, now) - expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10) - expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10) - }) - - test("replays session events over the loaded index", () => { - const initial = parseHomeSessionIndex([session({ id: "old" })]) - const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } } - - const afterCreate = applyHomeSessionEvent(initial, { - type: "session.created", - properties: { sessionID: created.id, info: created }, - }) - expect( - applyHomeSessionEvent(afterCreate, { - type: "session.deleted", - properties: { sessionID: initial[0]!.id, info: initial[0]! }, - }), - ).toEqual([created]) - }) - - test("applies only events newer than the index baseline", () => { - const initial = parseHomeSessionIndex([session({ id: "old" })]) - const stale = { ...initial[0], title: "stale" } - const current = { ...initial[0], title: "current" } - const first = appendHomeSessionEvent(undefined, { - type: "session.updated", - properties: { sessionID: stale.id, info: stale }, - }) - const events = appendHomeSessionEvent(first, { - type: "session.updated", - properties: { sessionID: current.id, info: current }, - }) - - expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current") - }) - - test("refetches after reconnect, disposal, and session moves", () => { - expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false }) - expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true }) - expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true) - expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true) - }) -}) diff --git a/packages/app/src/context/global-sync/home-session-index.ts b/packages/app/src/context/global-sync/home-session-index.ts deleted file mode 100644 index 03a085e34d..0000000000 --- a/packages/app/src/context/global-sync/home-session-index.ts +++ /dev/null @@ -1,174 +0,0 @@ -import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client" -import type { QueryClient } from "@tanstack/solid-query" -import { trimSessions } from "./session-trim" -import { pathKey } from "@/utils/path-key" - -export const HOME_V2_SESSION_PAGE_LIMIT = 5_000 - -export type HomeSessionEvent = { - type: "session.created" | "session.updated" | "session.deleted" - properties: { sessionID: string; info: Session } -} -export type HomeSessionEvents = { - sequence: number - entries: Array<{ sequence: number; event: HomeSessionEvent }> -} -export type HomeSessionIndex = { - sessions: Session[] - eventSequence: number -} - -export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const -export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const - -type HomeSessionPage = { data?: V2SessionListResponse } - -export async function loadHomeSessionIndex( - list: ( - input: { limit: number; order: "desc"; cursor?: string }, - options: { signal?: AbortSignal }, - ) => Promise, - eventSequence = 0, - signal?: AbortSignal, -) { - const data: SessionV2Info[] = [] - let cursor: string | undefined - - for (;;) { - const response = await list( - { - limit: HOME_V2_SESSION_PAGE_LIMIT, - order: "desc", - ...(cursor ? { cursor } : {}), - }, - { signal }, - ) - const page = response.data! - data.push(...page.data) - if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next) - return { sessions: parseHomeSessionIndex(data), eventSequence } - cursor = page.cursor.next - } -} - -export function appendHomeSessionEvent(current: HomeSessionEvents | undefined, event: HomeSessionEvent) { - const sequence = (current?.sequence ?? 0) + 1 - return { - sequence, - entries: [...(current?.entries ?? []), { sequence, event }], - } -} - -export function trimHomeSessionEvents(current: HomeSessionEvents | undefined, sequence: number): HomeSessionEvents { - return { - sequence: current?.sequence ?? sequence, - entries: (current?.entries ?? []).filter((entry) => entry.sequence > sequence), - } -} - -export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) { - if (!index) return [] - return (events?.entries ?? []) - .filter((entry) => entry.sequence > index.eventSequence) - .reduce((sessions, entry) => applyHomeSessionEvent(sessions, entry.event), index.sessions) -} - -export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) { - if (event === "server.connected") return { connected: true, refetch: connected } - return { - connected, - refetch: event === "global.disposed" || event === "session.next.moved", - } -} - -export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) { - const indexKey = homeSessionIndexKey(server) - const eventsKey = homeSessionEventsKey(server) - let connected = false - - return { - indexKey, - eventsKey, - eventSequence() { - return queryClient.getQueryData(eventsKey)?.sequence ?? 0 - }, - complete(sequence: number) { - // Keep events received after the fetch began so its response cannot overwrite them. - queryClient.setQueryData(eventsKey, (current) => trimHomeSessionEvents(current, sequence)) - }, - sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) { - return homeSessionIndexSessions(index, events) - }, - apply(event: HomeSessionEvent) { - if (!queryClient.getQueryState(indexKey)) return - const next = appendHomeSessionEvent(queryClient.getQueryData(eventsKey), event) - if (queryClient.isFetching({ queryKey: indexKey, exact: true }) > 0) { - queryClient.setQueryData(eventsKey, next) - return - } - - const index = queryClient.getQueryData(indexKey) - if (index) { - queryClient.setQueryData(indexKey, { - sessions: homeSessionIndexSessions(index, next), - eventSequence: next.sequence, - }) - } - queryClient.setQueryData(eventsKey, { sequence: next.sequence, entries: [] }) - }, - refresh(event: Event["type"]) { - const result = homeSessionIndexRefresh(event, connected) - connected = result.connected - if (!result.refetch) return - void queryClient.refetchQueries({ queryKey: indexKey, exact: true, type: "active" }) - }, - } -} - -// TODO(v2): This deliberately dumb full-table scan is necessary because the -// current V2 API orders by creation time and cannot filter roots, archives, or -// multiple directories. A bounded page could omit an old session updated today. -// Once released, use client.v2.project.list() and client.v2.session.list({ -// parentID: null, order: "desc" }), then remove this adapter and its V1 fields. -export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] { - return sessions.flatMap((item) => { - if (item.parentID || typeof item.time.archived === "number") return [] - return [toLegacySummary(item)] - }) -} - -export function retainHomeSessions(sessions: Session[], limit: number, now: number) { - const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory)) - return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now })) -} - -export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) { - const info = event.properties.info - const index = sessions.findIndex((session) => session.id === info.id) - if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") { - if (index === -1) return sessions - return sessions.toSpliced(index, 1) - } - if (event.type !== "session.created" && event.type !== "session.updated") return sessions - if (index === -1) return [...sessions, info] - return sessions.with(index, info) -} - -function toLegacySummary(session: SessionV2Info): Session { - return { - id: session.id, - slug: session.id, - projectID: session.projectID, - workspaceID: session.location.workspaceID, - directory: session.location.directory, - path: session.subpath, - parentID: session.parentID, - cost: session.cost, - tokens: session.tokens, - title: session.title, - agent: session.agent, - model: session.model, - version: "", - time: session.time, - } -} diff --git a/packages/app/src/context/global-sync/mcp.test.ts b/packages/app/src/context/global-sync/mcp.test.ts index ebfd9738ee..a292d23df9 100644 --- a/packages/app/src/context/global-sync/mcp.test.ts +++ b/packages/app/src/context/global-sync/mcp.test.ts @@ -31,24 +31,4 @@ describe("toggleMcp", () => { await toggleMcp(input("disabled")) expect(calls).toEqual(["connect", "refresh"]) }) - - test("does not toggle a server while its connection is pending", async () => { - const calls: string[] = [] - await toggleMcp({ - status: "pending", - connect: async () => { - calls.push("connect") - }, - disconnect: async () => { - calls.push("disconnect") - }, - authenticate: async () => { - calls.push("authenticate") - }, - refresh: async () => { - calls.push("refresh") - }, - }) - expect(calls).toEqual([]) - }) }) diff --git a/packages/app/src/context/global-sync/mcp.ts b/packages/app/src/context/global-sync/mcp.ts index cd91f396d0..2eeb297b95 100644 --- a/packages/app/src/context/global-sync/mcp.ts +++ b/packages/app/src/context/global-sync/mcp.ts @@ -1,13 +1,12 @@ -import type { McpServer } from "@opencode-ai/client/promise" +import type { McpStatus } from "@opencode-ai/sdk/v2/client" export async function toggleMcp(input: { - status: McpServer["status"]["status"] + status: McpStatus["status"] connect: () => Promise disconnect: () => Promise authenticate: () => Promise refresh: () => Promise }) { - if (input.status === "pending") return await { connected: input.disconnect, needs_auth: input.authenticate, diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index 45fbe38abe..4b2be505ea 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from "bun:test" -import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client" -import type { FileDiffInfo } from "@opencode-ai/client/promise" +import type { + Message, + Part, + PermissionRequest, + QuestionRequest, + SessionStatus, + SnapshotFileDiff, + Todo, +} from "@opencode-ai/sdk/v2/client" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" const msg = (id: string, sessionID: string) => @@ -26,10 +33,9 @@ describe("app session cache", () => { test("dropSessionCaches clears orphaned parts without message rows", () => { const store: { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record - session_message: Record part: Record permission: Record question: Record @@ -39,7 +45,6 @@ describe("app session cache", () => { session_diff: { ses_1: [] }, todo: { ses_1: [] as Todo[] }, message: {}, - session_message: {}, part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, permission: { ses_1: [] as PermissionRequest[] }, question: { ses_1: [] as QuestionRequest[] }, @@ -62,10 +67,9 @@ describe("app session cache", () => { const m = msg("msg_1", "ses_1") const store: { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record - session_message: Record part: Record permission: Record question: Record @@ -75,7 +79,6 @@ describe("app session cache", () => { session_diff: {}, todo: {}, message: { ses_1: [m] }, - session_message: {}, part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, permission: {}, question: {}, diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 7d684a5a1a..05cdc84643 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -1,15 +1,20 @@ -import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client" -import type { FileDiffInfo } from "@opencode-ai/client/promise" -import type { SessionMessageInfo } from "@opencode-ai/client/promise" +import type { + Message, + Part, + PermissionRequest, + QuestionRequest, + SessionStatus, + SnapshotFileDiff, + Todo, +} from "@opencode-ai/sdk/v2/client" export const SESSION_CACHE_LIMIT = 40 type SessionCache = { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record - session_message: Record part: Record permission: Record question: Record @@ -32,7 +37,6 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable; directory: string; limit: number }) { - const result = await input.api.list({ - directory: input.directory, - parentID: null, - limit: input.limit, - order: "desc", - }) - return { - data: result.data.map(normalizeSessionInfo), - limit: input.limit, - limited: true, - } as const -} - -export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) { +export async function loadRootSessionsWithFallback(input: RootLoadArgs) { try { - const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit }) - return { data: result.data, limit: input.limit, limited: true } as const + const result = await input.list({ directory: input.directory, roots: true, limit: input.limit }) + return { + data: result.data, + limit: input.limit, + limited: true, + } as const } catch { - const result = await input.client.session.list({ directory: input.directory, roots: true }) - return { data: result.data, limit: input.limit, limited: false } as const + const result = await input.list({ directory: input.directory, roots: true }) + return { + data: result.data, + limit: input.limit, + limited: false, + } as const } } diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 74191e10b0..86b489cd09 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -1,7 +1,10 @@ import type { Agent, + Command, Config, LspStatus, + McpResource, + McpStatus, Message, Part, Path, @@ -10,12 +13,11 @@ import type { ReferenceInfo, Session, SessionStatus, + SnapshotFileDiff, Todo, VcsInfo, } from "@opencode-ai/sdk/v2/client" -import type { FileDiffInfo } from "@opencode-ai/client/promise" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" -import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise" import type { Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" @@ -33,7 +35,7 @@ export type ProjectMeta = { export type State = { status: "loading" | "partial" | "complete" agent: Agent[] - command: CommandInfo[] + command: Command[] reference: ReferenceInfo[] project: string projectMeta: ProjectMeta | undefined @@ -49,7 +51,7 @@ export type State = { } session_working(id: string): boolean session_diff: { - [sessionID: string]: FileDiffInfo[] + [sessionID: string]: SnapshotFileDiff[] } todo: { [sessionID: string]: Todo[] @@ -62,7 +64,7 @@ export type State = { } mcp_ready: boolean mcp: { - [name: string]: McpServer["status"] + [name: string]: McpStatus } mcp_resource: { [key: string]: McpResource @@ -74,9 +76,6 @@ export type State = { message: { [sessionID: string]: Message[] } - session_message: { - [sessionID: string]: SessionMessageInfo[] - } part: { [messageID: string]: Part[] } @@ -129,6 +128,18 @@ export type DisposeCheck = { loadingSessions: boolean } +export type RootLoadArgs = { + directory: string + limit: number + list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }> +} + +export type RootLoadResult = { + data?: Session[] + limit: number + limited: boolean +} + export const MAX_DIR_STORES = 30 export const DIR_IDLE_TTL_MS = 20 * 60 * 1000 export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000 diff --git a/packages/app/src/context/global-sync/utils.test.ts b/packages/app/src/context/global-sync/utils.test.ts index 69ca494992..406c0f124e 100644 --- a/packages/app/src/context/global-sync/utils.test.ts +++ b/packages/app/src/context/global-sync/utils.test.ts @@ -1,117 +1,36 @@ import { describe, expect, test } from "bun:test" -import type { - AgentListOutput, - ModelDefaultOutput, - ModelListOutput, - ProviderListOutput, -} from "@opencode-ai/client/promise" -import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils" +import type { Agent } from "@opencode-ai/sdk/v2/client" +import { directoryKey, normalizeAgentList } from "./utils" + +const agent = (name = "build") => + ({ + name, + mode: "primary", + permission: {}, + options: {}, + }) as Agent describe("normalizeAgentList", () => { - test("adapts current agents to the app agent shape", () => { - const result = normalizeAgentList([ - { - id: "build", - name: "Build", - mode: "primary", - hidden: false, - color: "primary", - model: { id: "gpt-5", providerID: "openai", variant: "high" }, - request: { settings: { temperature: 0.2, topP: 0.9 }, headers: {}, body: {} }, - system: "Build software", - permissions: [{ action: "read", resource: "*", effect: "allow" }], - }, - ] as AgentListOutput["data"]) - - expect(result).toEqual([ - { - name: "build", - description: undefined, - mode: "primary", - hidden: false, - temperature: 0.2, - topP: 0.9, - color: "primary", - permission: [{ permission: "read", pattern: "*", action: "allow" }], - model: { providerID: "openai", modelID: "gpt-5" }, - variant: "high", - prompt: "Build software", - options: { temperature: 0.2, topP: 0.9 }, - steps: undefined, - }, - ]) + test("keeps array payloads", () => { + expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")]) }) -}) -describe("normalizePermissionRequest", () => { - test("adapts the current permission request to app state", () => { + test("wraps a single agent payload", () => { + expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")]) + }) + + test("extracts agents from keyed objects", () => { expect( - normalizePermissionRequest({ - id: "permission-1", - sessionID: "session-1", - action: "read", - resources: ["README.md"], - save: ["*.md"], - metadata: { path: "README.md" }, - source: { type: "tool", messageID: "message-1", callID: "call-1" }, + normalizeAgentList({ + build: agent("build"), + docs: agent("docs"), }), - ).toEqual({ - id: "permission-1", - sessionID: "session-1", - permission: "read", - patterns: ["README.md"], - always: ["*.md"], - metadata: { path: "README.md" }, - tool: { messageID: "message-1", callID: "call-1" }, - }) + ).toEqual([agent("build"), agent("docs")]) }) -}) -describe("normalizeProviderList", () => { - test("groups current models into the app provider catalog", () => { - const result = normalizeProviderList( - [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] as ProviderListOutput["data"], - [ - { - id: "gpt-5", - modelID: "gpt-5", - providerID: "openai", - name: "GPT-5", - capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, - variants: [{ id: "high" }], - time: { released: 1 }, - cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }], - status: "active", - enabled: true, - limit: { context: 128_000, output: 8_192 }, - }, - { - id: "gpt-old", - modelID: "gpt-old", - providerID: "openai", - name: "GPT Old", - capabilities: { tools: false, input: ["text"], output: ["text"] }, - variants: [], - time: { released: 0 }, - cost: [], - status: "deprecated", - enabled: true, - limit: { context: 1, output: 1 }, - }, - ] as ModelListOutput["data"], - { id: "gpt-5", providerID: "openai" } as ModelDefaultOutput["data"], - ) - - expect(result.connected).toEqual(["openai"]) - expect(result.default).toEqual({ openai: "gpt-5" }) - expect(result.all.get("openai")?.models["gpt-old"]).toBeUndefined() - expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({ - id: "gpt-5", - providerID: "openai", - capabilities: { toolcall: true, attachment: true }, - cost: { input: 1, output: 2 }, - variants: { high: {} }, - }) + test("drops invalid payloads", () => { + expect(normalizeAgentList({ name: "AbortError" })).toEqual([]) + expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")]) }) }) diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts index 59632e53c9..e54bc88d4d 100644 --- a/packages/app/src/context/global-sync/utils.ts +++ b/packages/app/src/context/global-sync/utils.ts @@ -1,152 +1,39 @@ -import type { - AgentListOutput, - ModelDefaultOutput, - ModelListOutput, - PermissionV2Request, - ProviderListOutput, -} from "@opencode-ai/client/promise" -import type { Agent, PermissionRequest, Project, Provider, ProviderListResponse } from "@opencode-ai/sdk/v2/client" -import type { Project as CurrentProject } from "@opencode-ai/client/promise" +import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key" export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) -export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] { - if (input.every((agent) => !("request" in agent))) return input as Agent[] - return (input as AgentListOutput["data"]).map((agent) => ({ - name: agent.id, - description: agent.description, - mode: agent.mode, - hidden: agent.hidden, - temperature: - typeof agent.request.settings.temperature === "number" ? agent.request.settings.temperature : undefined, - topP: typeof agent.request.settings.topP === "number" ? agent.request.settings.topP : undefined, - color: agent.color, - permission: agent.permissions.map((rule) => ({ - permission: rule.action, - pattern: rule.resource, - action: rule.effect, - })), - model: agent.model && { providerID: agent.model.providerID, modelID: agent.model.id }, - variant: agent.model?.variant, - prompt: agent.system, - options: agent.request.settings, - steps: agent.steps, - })) +function isAgent(input: unknown): input is Agent { + if (!input || typeof input !== "object") return false + const item = input as { name?: unknown; mode?: unknown } + if (typeof item.name !== "string") return false + return item.mode === "subagent" || item.mode === "primary" || item.mode === "all" } -export function normalizePermissionRequest(input: PermissionV2Request | PermissionRequest): PermissionRequest { - if ("permission" in input) return input +export function normalizeAgentList(input: unknown): Agent[] { + if (Array.isArray(input)) return input.filter(isAgent) + if (isAgent(input)) return [input] + if (!input || typeof input !== "object") return [] + return Object.values(input).filter(isAgent) +} + +export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse { return { - id: input.id, - sessionID: input.sessionID, - permission: input.action, - patterns: input.resources, - always: input.save ?? [], - metadata: input.metadata ?? {}, - tool: - input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined, - } -} - -export function normalizeProviderList( - providers: ProviderListOutput["data"] | ProviderListResponse, - models?: ModelListOutput["data"], - defaultModel?: ModelDefaultOutput["data"], -): NormalizedProviderListResponse { - if (!Array.isArray(providers)) { - return { - ...providers, - all: new Map( - providers.all.map((provider) => [ - provider.id, - { - ...provider, - models: Object.fromEntries( - Object.entries(provider.models).filter(([, model]) => model.status !== "deprecated"), - ), - }, - ]), + ...input, + all: new Map( + input.all.map( + (provider) => + [ + provider.id, + { + ...provider, + models: Object.fromEntries( + Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"), + ), + }, + ] as const, ), - } - } - const all = new Map() - - for (const provider of providers) { - all.set(provider.id, { - id: provider.id, - name: provider.name, - source: "custom", - env: [], - options: provider.settings ?? {}, - models: {}, - }) - } - - for (const model of models ?? []) { - const provider = all.get(model.providerID) - if (!provider || model.status === "deprecated") continue - const cost = model.cost.find((item) => item.tier === undefined) ?? model.cost[0] - provider.models[model.id] = { - id: model.id, - providerID: model.providerID, - api: { - id: model.modelID, - url: "", - npm: model.package ?? provider.id, - }, - name: model.name, - family: model.family, - capabilities: { - temperature: false, - reasoning: false, - attachment: model.capabilities.input.some((item) => item !== "text"), - toolcall: model.capabilities.tools, - input: { - text: model.capabilities.input.includes("text"), - audio: model.capabilities.input.includes("audio"), - image: model.capabilities.input.includes("image"), - video: model.capabilities.input.includes("video"), - pdf: model.capabilities.input.includes("pdf"), - }, - output: { - text: model.capabilities.output.includes("text"), - audio: model.capabilities.output.includes("audio"), - image: model.capabilities.output.includes("image"), - video: model.capabilities.output.includes("video"), - pdf: model.capabilities.output.includes("pdf"), - }, - interleaved: false, - }, - cost: { - input: cost?.input ?? 0, - output: cost?.output ?? 0, - cache: { - read: cost?.cache.read ?? 0, - write: cost?.cache.write ?? 0, - }, - }, - limit: model.limit, - status: model.status, - options: model.settings ?? {}, - headers: model.headers ?? {}, - release_date: new Date(model.time.released).toISOString().slice(0, 10), - variants: Object.fromEntries(model.variants.map((variant) => [variant.id, variant.settings ?? {}])), - } - } - - return { - all, - connected: providers.map((provider) => provider.id), - default: Object.fromEntries( - providers.flatMap((provider) => { - const model = - defaultModel?.providerID === provider.id - ? defaultModel - : models?.find((item) => item.providerID === provider.id && item.status !== "deprecated") - return model ? [[provider.id, model.id]] : [] - }), ), } } @@ -162,10 +49,3 @@ export function sanitizeProject(project: Project) { }, } } - -export function normalizeProjectInfo(project: Project | CurrentProject): Project { - return { - ...project, - vcs: project.vcs === "git" ? "git" : undefined, - } -} diff --git a/packages/app/src/context/global.tsx b/packages/app/src/context/global.tsx index 83438898b5..138b17c4fc 100644 --- a/packages/app/src/context/global.tsx +++ b/packages/app/src/context/global.tsx @@ -1,8 +1,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { createEffect, createMemo, createRoot } from "solid-js" import { createStore } from "solid-js/store" -import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server" -import { pathKey } from "@/utils/path-key" +import { createServerProjects, ServerConnection, useServer } from "./server" import { useServerHealth } from "@/utils/server-health" import { createServerSdkContext } from "./server-sdk" import { createServerSyncContext } from "./server-sync" @@ -128,14 +127,6 @@ function createServerCtx( } const projectsList = createMemo(() => projects.list().map(enrich)) - const recentlyClosedList = createMemo(() => { - const known = new Set(sync.data.project.map((project) => pathKey(project.worktree))) - return projects - .recentlyClosed() - .filter((worktree) => known.has(pathKey(worktree))) - .slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT) - .map((worktree) => enrich({ worktree, expanded: false })) - }) const isLocal = (conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url)) @@ -148,7 +139,6 @@ function createServerCtx( projects: { ...projects, list: projectsList, - recentlyClosed: recentlyClosedList, }, } } diff --git a/packages/app/src/context/layout-tabs.test.ts b/packages/app/src/context/layout-tabs.test.ts deleted file mode 100644 index 646ed086ce..0000000000 --- a/packages/app/src/context/layout-tabs.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { - SESSION_OPEN_FILE_TAB, - closeSessionTab, - openSessionTab, - previewSessionTab, - type SessionTabState, -} from "./layout-tabs" - -const state = (all: string[], active?: string, preview?: string): SessionTabState => ({ - tabs: { all, active }, - preview, -}) - -describe("previewSessionTab", () => { - test("appends the Open File placeholder", () => { - expect(previewSessionTab(state(["file://a.ts"], "file://a.ts"), SESSION_OPEN_FILE_TAB)).toEqual( - state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB), - ) - }) - - test("replaces the current preview in place", () => { - expect( - previewSessionTab( - state(["context", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB), - "file://a.ts", - ), - ).toEqual(state(["context", "file://a.ts", "file://b.ts"], "file://a.ts", "file://a.ts")) - }) - - test("activates a durable tab without duplicating it", () => { - expect( - previewSessionTab( - state(["file://a.ts", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB), - "file://b.ts", - ), - ).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts")) - }) - - test("replaces a restored Open File placeholder", () => { - expect( - previewSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts"), - ).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts", "file://b.ts")) - }) -}) - -describe("openSessionTab", () => { - test("pins the current preview", () => { - expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://a.ts")).toEqual( - state(["file://a.ts"], "file://a.ts"), - ) - }) - - test("replaces a preview with a directly opened file", () => { - expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://b.ts")).toEqual( - state(["file://b.ts"], "file://b.ts"), - ) - }) - - test("keeps the preview when switching to Review", () => { - expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "review")).toEqual( - state(["file://a.ts"], "review", "file://a.ts"), - ) - }) - - test("replaces a restored Open File placeholder with a direct open", () => { - expect(openSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts")).toEqual( - state(["file://a.ts", "file://b.ts"], "file://b.ts"), - ) - }) -}) - -describe("closeSessionTab", () => { - test("clears preview metadata and selects the left neighbor", () => { - expect( - closeSessionTab( - state(["file://a.ts", "file://b.ts", "file://c.ts"], "file://b.ts", "file://b.ts"), - "file://b.ts", - ), - ).toEqual(state(["file://a.ts", "file://c.ts"], "file://a.ts")) - }) -}) diff --git a/packages/app/src/context/layout-tabs.ts b/packages/app/src/context/layout-tabs.ts deleted file mode 100644 index ea4c675ff4..0000000000 --- a/packages/app/src/context/layout-tabs.ts +++ /dev/null @@ -1,103 +0,0 @@ -export const SESSION_OPEN_FILE_TAB = "open-file" - -export type SessionTabs = { - active?: string - all: string[] -} - -export type SessionTabState = { - tabs: SessionTabs - preview?: string -} - -const sessionTabPreview = (current: SessionTabState) => - current.preview ?? (current.tabs.all.includes(SESSION_OPEN_FILE_TAB) ? SESSION_OPEN_FILE_TAB : undefined) - -export function previewSessionTab(current: SessionTabState, tab: string): SessionTabState { - const preview = sessionTabPreview(current) - const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1 - const existingIndex = current.tabs.all.indexOf(tab) - - if (existingIndex !== -1) { - if (previewIndex === -1 || preview === tab) { - return { tabs: { all: current.tabs.all, active: tab }, preview: preview === tab ? tab : undefined } - } - return { - tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab }, - } - } - - if (previewIndex === -1) { - return { tabs: { all: [...current.tabs.all, tab], active: tab }, preview: tab } - } - - return { - tabs: { - all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)), - active: tab, - }, - preview: tab, - } -} - -export function openSessionTab(current: SessionTabState, tab: string): SessionTabState { - const preview = sessionTabPreview(current) - if (tab === "review") { - return { - tabs: { all: current.tabs.all.filter((item) => item !== tab), active: tab }, - preview, - } - } - - if (tab === "context") { - return { - tabs: { all: [tab, ...current.tabs.all.filter((item) => item !== tab)], active: tab }, - preview, - } - } - - const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1 - const existingIndex = current.tabs.all.indexOf(tab) - if (existingIndex !== -1) { - if (previewIndex === -1 || preview === tab) { - return { tabs: { all: current.tabs.all, active: tab } } - } - return { - tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab }, - } - } - - if (previewIndex === -1) { - return { tabs: { all: [...current.tabs.all, tab], active: tab } } - } - - return { - tabs: { - all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)), - active: tab, - }, - } -} - -export function closeSessionTab(current: SessionTabState, tab: string): SessionTabState { - if (tab === "review") { - if (current.tabs.active !== tab) return current - return { - tabs: { all: current.tabs.all, active: current.tabs.all[0] }, - preview: current.preview, - } - } - - const all = current.tabs.all.filter((item) => item !== tab) - const preview = current.preview === tab ? undefined : current.preview - if (current.tabs.active !== tab) return { tabs: { ...current.tabs, all }, preview } - - const index = current.tabs.all.indexOf(tab) - return { - tabs: { - all, - active: current.tabs.all[index - 1] ?? current.tabs.all[index + 1] ?? all[0], - }, - preview, - } -} diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 6235f35c45..1487676e54 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -5,12 +5,10 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { makeEventListener } from "@solid-primitives/event-listener" import { useServerSync } from "./server-sync" import { useServerSDK } from "./server-sdk" -import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server" +import { ServerConnection, useServer } from "./server" import { usePlatform } from "./platform" import { Project } from "@opencode-ai/sdk/v2" -import { normalizeProjectInfo } from "./global-sync/utils" import { Persist, persisted, removePersisted } from "@/utils/persist" -import { pathKey } from "@/utils/path-key" import { decode64 } from "@/utils/base64" import { same } from "@/utils/same" import { createScrollPersistence, type SessionScroll } from "./layout-scroll" @@ -20,7 +18,6 @@ import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/u import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" import { requireServerKey } from "@/utils/session-route" import { type DraftTab, useTabs } from "./tabs" -import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs" export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } @@ -48,28 +45,23 @@ export function getAvatarColors(key?: string) { } export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant { + if (key === "orange") return "orange" + if (key === "pink") return "pink" + if (key === "cyan") return "cyan" + if (key === "purple") return "purple" if (key === "mint") return "cyan" if (key === "lime") return "green" - if ( - key === "orange" || - key === "yellow" || - key === "cyan" || - key === "green" || - key === "red" || - key === "pink" || - key === "blue" || - key === "purple" || - key === "gray" - ) - return key return "gray" } +type SessionTabs = { + active?: string + all: string[] +} + type SessionView = { scroll: Record reviewOpen?: string[] - reviewMode?: ReviewChangeMode - reviewFile?: string pendingMessage?: string pendingMessageAt?: number todoCollapsed?: boolean @@ -86,7 +78,6 @@ export type LocalProject = Partial & { worktree: string; expanded: bool export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string } export type ReviewDiffStyle = "unified" | "split" -export type ReviewChangeMode = "git" | "branch" | "turn" export type ReviewPanelSource = "context-button" | "other" export type LayoutRoute = @@ -95,6 +86,14 @@ export type LayoutRoute = | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key } | { type: "session"; sessionId: string; server?: ServerConnection.Key } +function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs { + const all = current?.all ?? [] + if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab } + if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab } + if (!all.includes(tab)) return { all: [...all, tab], active: tab } + return { all, active: tab } +} + const sessionPath = (key: string) => { const dir = SessionStateKey.route(key).split("/")[0] if (!dir) return @@ -127,7 +126,7 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => { } } -export const currentRoute = (pathname: string, search: string): LayoutRoute => { +const currentRoute = (pathname: string, search: string): LayoutRoute => { const parts = pathname.split("/").filter(Boolean) if (parts.length === 0) return { type: "home" } @@ -308,7 +307,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( ) const [ephemeral, setEphemeral] = createStore({ reviewPanelSource: "other" as ReviewPanelSource, - sessionTabPreview: {} as Record, }) const MAX_SESSION_KEYS = 50 @@ -367,12 +365,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( scroll.drop(drop) dropSessionState(drop) - setEphemeral( - "sessionTabPreview", - produce((draft) => { - for (const key of drop) delete draft[key] - }), - ) for (const key of drop) { usage.used.delete(key) @@ -501,7 +493,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const root = rootFor(project.worktree) if (root === project.worktree) continue - server.projects.remove(project.worktree) + server.projects.close(project.worktree) if (!seen.has(root)) { server.projects.open(root) @@ -570,22 +562,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( continue } - const projectID = project.id - void (async () => { - const sdk = serverSdk() - if ((await sdk.protocol) !== "v1") return - return sdk.client.project - .update({ projectID, directory: worktree, icon: { color } }) - .then((response) => response.data) - .then((result) => { - if (!result) return - serverSync().set("project", (items) => - items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)), - ) - }) - })().catch(() => { - if (colorRequested.get(worktree) === color) colorRequested.delete(worktree) - }) + void serverSdk() + .client.project.update({ projectID: project.id, directory: worktree, icon: { color } }) + .catch(() => { + if (colorRequested.get(worktree) === color) colorRequested.delete(worktree) + }) } }) @@ -632,14 +613,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, projects: { list, - recentlyClosed: createMemo(() => { - const known = new Set(serverSync().data.project.map((project) => pathKey(project.worktree))) - return server.projects - .recentlyClosed() - .filter((worktree) => known.has(pathKey(worktree))) - .slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT) - .map((worktree) => enrich({ worktree, expanded: false })) - }), open(directory: string) { const root = rootFor(directory) if (server.projects.list().find((x) => x.worktree === root)) return @@ -809,14 +782,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( view(sessionKey: string | Accessor) { const key = createSessionKeyReader(sessionKey, ensureKey) const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} }) - const reviewMode = createMemo(() => { - const mode = s().reviewMode - if (mode === "git" || mode === "branch" || mode === "turn") return mode - }) - const reviewFile = createMemo(() => { - const file = s().reviewFile - if (typeof file === "string") return file - }) const terminalOpened = createMemo(() => store.terminal?.opened ?? false) const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED) const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other")) @@ -900,32 +865,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, }, review: { - mode: reviewMode, - setMode(mode: ReviewChangeMode) { - const session = key() - const current = store.sessionView[session] - if (!current) { - setStore("sessionView", session, { scroll: {}, reviewMode: mode }) - prune(session) - return - } - if (current.reviewMode === mode) return - setStore("sessionView", session, "reviewMode", mode) - prune(session) - }, - file: reviewFile, - setFile(file: string) { - const session = key() - const current = store.sessionView[session] - if (!current) { - setStore("sessionView", session, { scroll: {}, reviewFile: file }) - prune(session) - return - } - if (current.reviewFile === file) return - setStore("sessionView", session, "reviewFile", file) - prune(session) - }, open: createMemo(() => s().reviewOpen ?? []), setOpen(open: string[]) { const session = key() @@ -997,17 +936,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] }) const normalize = (tab: string) => normalizeSessionTab(path(), tab) const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all) - const apply = (session: string, next: ReturnType) => { - batch(() => { - setStore("sessionTabs", session, next.tabs) - setEphemeral("sessionTabPreview", session, next.preview) - }) - } return { tabs, active: createMemo(() => tabs().active), all: createMemo(() => tabs().all.filter((tab) => tab !== "review")), - preview: createMemo(() => ephemeral.sessionTabPreview[key()]), setActive(tab: string | undefined) { const session = key() const next = tab ? normalize(tab) : tab @@ -1020,44 +952,40 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setAll(all: string[]) { const session = key() const next = normalizeAll(all).filter((tab) => tab !== "review") - batch(() => { - if (!store.sessionTabs[session]) { - setStore("sessionTabs", session, { all: next, active: undefined }) - } else { - setStore("sessionTabs", session, "all", next) - } - const preview = ephemeral.sessionTabPreview[session] - if (preview && !next.includes(preview)) setEphemeral("sessionTabPreview", session, undefined) - }) + if (!store.sessionTabs[session]) { + setStore("sessionTabs", session, { all: next, active: undefined }) + } else { + setStore("sessionTabs", session, "all", next) + } }, async open(tab: string) { const session = key() - apply( - session, - openSessionTab( - { tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] }, - normalize(tab), - ), - ) - }, - previewTab(tab: string) { - const session = key() - apply( - session, - previewSessionTab( - { tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] }, - normalize(tab), - ), - ) + const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab)) + setStore("sessionTabs", session, next) }, close(tab: string) { const session = key() const current = store.sessionTabs[session] if (!current) return - apply( - session, - closeSessionTab({ tabs: current, preview: ephemeral.sessionTabPreview[session] }, normalize(tab)), - ) + + if (tab === "review") { + if (current.active !== tab) return + setStore("sessionTabs", session, "active", current.all[0]) + return + } + + const all = current.all.filter((x) => x !== tab) + if (current.active !== tab) { + setStore("sessionTabs", session, "all", all) + return + } + + const index = current.all.findIndex((f) => f === tab) + const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0] + batch(() => { + setStore("sessionTabs", session, "all", all) + setStore("sessionTabs", session, "active", next) + }) }, move(tab: string, to: number) { const session = key() diff --git a/packages/app/src/context/local-agent.test.ts b/packages/app/src/context/local-agent.test.ts deleted file mode 100644 index c7a95f9a5b..0000000000 --- a/packages/app/src/context/local-agent.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { hasCustomAgent, resolveAgent } from "./local-agent" - -describe("hasCustomAgent", () => { - test("detects explicitly custom agents", () => { - expect(hasCustomAgent([{ native: true }, { native: false }])).toBe(true) - }) - - test("ignores built-in and unclassified agents", () => { - expect(hasCustomAgent([{ native: true }, {}])).toBe(false) - }) -}) - -describe("resolveAgent", () => { - const agents = [{ name: "plan" }, { name: "build" }, { name: "custom" }] - - test("uses the requested available agent", () => { - expect(resolveAgent(agents, "custom")?.name).toBe("custom") - }) - - test("defaults to build", () => { - expect(resolveAgent(agents)?.name).toBe("build") - expect(resolveAgent(agents, "missing")?.name).toBe("build") - }) - - test("uses the first agent when build is unavailable", () => { - expect(resolveAgent([{ name: "custom" }], "missing")?.name).toBe("custom") - }) -}) diff --git a/packages/app/src/context/local-agent.ts b/packages/app/src/context/local-agent.ts deleted file mode 100644 index f5c76d1469..0000000000 --- a/packages/app/src/context/local-agent.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function hasCustomAgent(items: Array<{ native?: boolean }>) { - return items.some((item) => item.native === false) -} - -export function resolveAgent(items: T[], name?: string) { - return items.find((item) => item.name === name) ?? items.find((item) => item.name === "build") ?? items[0] -} diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 83db571385..415da747e0 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -4,10 +4,8 @@ import { useParams } from "@solidjs/router" import { batch, createEffect, createMemo, startTransition } from "solid-js" import { createStore } from "solid-js/store" import { useModels } from "@/context/models" -import { useSettings } from "@/context/settings" import { useProviders } from "@/hooks/use-providers" import { Persist, persisted } from "@/utils/persist" -import { hasCustomAgent, resolveAgent } from "./local-agent" import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" import { useSDK } from "./sdk" import { useSync } from "./sync" @@ -64,14 +62,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const serverSDK = useServerSDK() const providers = useProviders(() => sdk().directory) const models = useModels() - const settings = useSettings() const id = createMemo(() => params.id || undefined) const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)) - const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list())) const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) - const [saved, setSaved, , savedReady] = persisted( + const [saved, setSaved] = persisted( { ...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]), migrate, @@ -111,7 +107,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } const pickAgent = (name: string | undefined) => { - return resolveAgent(list(), name) + const items = list() + if (items.length === 0) return + return items.find((item) => item.name === name) ?? items[0] } createEffect(() => { @@ -182,9 +180,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const agent = { list, - visible: agentsVisible, current() { - return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build") + return pickAgent(scope()?.agent ?? store.current) }, set(name: string | undefined) { const item = pickAgent(name) @@ -378,12 +375,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ model, agent, session: { - ready: savedReady, reset() { setStore({ draft: undefined, promoting: undefined }) }, - promote(dir: string, session: string, state?: State) { - const next = clone(state ?? snapshot()) + promote(dir: string, session: string) { + const next = clone(snapshot()) if (!next) return const key = handoffKey(serverSDK().scope, dir, session) handoff.set(key, next) @@ -413,5 +409,3 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return result }, }) - -export type ModelSelection = ReturnType["model"] diff --git a/packages/app/src/context/models.tsx b/packages/app/src/context/models.tsx index a80cf2e580..736dc3a7c5 100644 --- a/packages/app/src/context/models.tsx +++ b/packages/app/src/context/models.tsx @@ -26,7 +26,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext( name: "Models", gate: false, init: (props: { directory?: Accessor } = {}) => { - const providers = useProviders(() => props.directory?.()) + const providers = useProviders(props.directory) const [store, setStore, _, ready] = persisted( Persist.global("model", ["model.v1"]), diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 799c4713dd..e12f8f1250 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -1,6 +1,6 @@ import { createStore, reconcile } from "solid-js/store" import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" -import { useNavigate, useParams, useSearchParams } from "@solidjs/router" +import { useParams, useSearchParams } from "@solidjs/router" import { createSimpleContext } from "@opencode-ai/ui/context" import type { ServerSDK } from "./server-sdk" import type { ServerSync } from "./server-sync" @@ -118,7 +118,6 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const global = useGlobal() const server = useServer() const tabs = useTabs() - const navigate = useNavigate() const platform = usePlatform() const settings = useSettings() const language = useLanguage() @@ -154,7 +153,6 @@ export const { use: useNotification, provider: NotificationProvider } = createSi platform, settings, language, - navigate, }), }), owner ?? undefined, @@ -178,14 +176,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi onCleanup(() => states.forEach((value) => value.dispose())) - const selected = () => { - const list = global.servers.list() - const key = activeServer() - if (list.some((conn) => ServerConnection.key(conn) === key)) return ensure(key) - const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0] - if (!conn) throw new Error("Notification server not found") - return ensure(ServerConnection.key(conn)) - } + const selected = () => ensure(activeServer()) return { ready: () => selected().ready(), @@ -219,7 +210,6 @@ function createServerNotificationState(input: { platform: ReturnType settings: ReturnType language: ReturnType - navigate: (href: string) => void }) { const serverSDK = () => input.sdk const serverSync = () => input.sync @@ -356,9 +346,7 @@ function createServerNotificationState(input: { const href = `/${base64Encode(directory)}/session/${sessionID}` if (settings.notifications.agent()) { - void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () => - input.navigate(href), - ) + void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href) } }) } @@ -391,7 +379,7 @@ function createServerNotificationState(input: { (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription")) const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}` if (settings.notifications.errors()) { - void platform.notify(language.t("notification.session.error.title"), description, () => input.navigate(href)) + void platform.notify(language.t("notification.session.error.title"), description, href) } }) } diff --git a/packages/app/src/context/permission-auto-respond.test.ts b/packages/app/src/context/permission-auto-respond.test.ts index 794e03ae48..002ae94e5b 100644 --- a/packages/app/src/context/permission-auto-respond.test.ts +++ b/packages/app/src/context/permission-auto-respond.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" import { base64Encode } from "@opencode-ai/core/util/encode" -import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond" +import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond" const session = (input: { id: string; parentID?: string }) => ({ @@ -69,7 +69,6 @@ describe("autoRespondsPermission", () => { } expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true) - expect(sessionAutoAccept(autoAccept, sessions, permission("root"), directory)).toBeUndefined() }) test("session-level override takes precedence over directory-level", () => { @@ -82,28 +81,6 @@ describe("autoRespondsPermission", () => { expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false) }) - - test("parent false override takes precedence over directory-level auto-accept", () => { - const directory = "/tmp/project" - const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] - const autoAccept = { - [`${base64Encode(directory)}/*`]: true, - [`${base64Encode(directory)}/root`]: false, - } - - expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false) - }) - - test("parent true override takes precedence over disabled directory fallback", () => { - const directory = "/tmp/project" - const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })] - const autoAccept = { - [`${base64Encode(directory)}/*`]: false, - [`${base64Encode(directory)}/root`]: true, - } - - expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true) - }) }) describe("isDirectoryAutoAccepting", () => { diff --git a/packages/app/src/context/permission-auto-respond.ts b/packages/app/src/context/permission-auto-respond.ts index 8fff6d70c1..58ab75c57d 100644 --- a/packages/app/src/context/permission-auto-respond.ts +++ b/packages/app/src/context/permission-auto-respond.ts @@ -11,7 +11,8 @@ export function directoryAcceptKey(directory: string) { function accepted(autoAccept: Record, sessionID: string, directory?: string) { const key = acceptKey(sessionID, directory) - return autoAccept[key] ?? autoAccept[sessionID] + const directoryKey = directory ? directoryAcceptKey(directory) : undefined + return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined) } export function isDirectoryAutoAccepting(autoAccept: Record, directory: string) { @@ -43,18 +44,8 @@ export function autoRespondsPermission( permission: { sessionID: string }, directory?: string, ) { - const value = sessionAutoAccept(autoAccept, session, permission, directory) - if (value !== undefined) return value - return directory ? isDirectoryAutoAccepting(autoAccept, directory) : false -} - -export function sessionAutoAccept( - autoAccept: Record, - session: { id: string; parentID?: string }[], - permission: { sessionID: string }, - directory?: string, -) { - return sessionLineage(session, permission.sessionID) + const value = sessionLineage(session, permission.sessionID) .map((id) => accepted(autoAccept, id, directory)) .find((item): item is boolean => item !== undefined) + return value ?? false } diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index d6d8019262..ff43638bbc 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -1,25 +1,17 @@ -import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" +import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" import { Persist, persisted } from "@/utils/persist" -import type { ServerSDK } from "@/context/server-sdk" -import type { ServerSync } from "./server-sync" -import { useParams, useSearchParams } from "@solidjs/router" +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "./server-sync" +import { useParams } from "@solidjs/router" import { decode64 } from "@/utils/base64" -import { useGlobal } from "./global" -import { ServerConnection, useServer } from "./server" -import { type DraftTab, useTabs } from "./tabs" -import { useSettings } from "./settings" -import { requireServerKey } from "@/utils/session-route" -import type { ServerScope } from "@/utils/server-scope" -import { normalizePermissionRequest } from "./global-sync/utils" import { acceptKey, directoryAcceptKey, isDirectoryAutoAccepting, autoRespondsPermission, - sessionAutoAccept, } from "./permission-auto-respond" type PermissionRespondFn = (input: { @@ -55,430 +47,234 @@ function hasPermissionPromptRules(permission: unknown) { export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ name: "Permission", gate: false, - init: () => { - const params = useParams<{ serverKey?: string; dir?: string; id?: string }>() - const [search] = useSearchParams<{ draftId?: string }>() - const global = useGlobal() - const server = useServer() - const tabs = useTabs() - const settings = useSettings() - const owner = getOwner() - const states = new Map void; state: PermissionState }>() - - const activeDraft = createMemo(() => { - if (!search.draftId) return - return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId) - }) - - const activeServer = createMemo(() => { - if (params.serverKey && settings.general.newLayoutDesigns()) return requireServerKey(params.serverKey) - return activeDraft()?.server ?? server.key - }) - - const ensure = (key: ServerConnection.Key) => { - const conn = global.servers.list().find((item) => ServerConnection.key(item) === key) - if (!conn) throw new Error(`Permission server not found: ${key}`) - const ctx = global.ensureServerCtx(conn) - const existing = states.get(ctx.sdk.scope) - if (existing && global.servers.list().some((item) => ServerConnection.key(item) === existing.key)) { - return existing.state - } - if (existing) { - existing.dispose() - states.delete(ctx.sdk.scope) - } - const root = createRoot( - (dispose) => ({ - key, - dispose, - state: createServerPermissionState({ sdk: ctx.sdk, sync: ctx.sync }), - }), - owner ?? undefined, - ) - states.set(ctx.sdk.scope, root) - return root.state - } - - createEffect(() => { - global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn))) - }) - - createEffect(() => { - const list = global.servers.list() - const keys = new Set(list.map(ServerConnection.key)) - states.forEach((value, scope) => { - if (keys.has(value.key)) return - value.dispose() - states.delete(scope) - const replacement = list.find((conn) => server.scope(ServerConnection.key(conn)) === scope) - if (replacement) ensure(ServerConnection.key(replacement)) - }) - }) - - onCleanup(() => states.forEach((value) => value.dispose())) - - let lastSelected: PermissionState | undefined - const selected = () => { - const key = activeServer() - if (global.servers.list().some((conn) => ServerConnection.key(conn) === key)) { - lastSelected = ensure(key) - } - if (lastSelected) return lastSelected - return ensure(server.key) - } - const activeDirectory = createMemo(() => { - const directory = decode64(params.dir) - if (directory) return directory - const draft = activeDraft() - if (draft) return draft.directory - if (!params.id) return - if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return - return selected().sync.session.lineage.peek(params.id)?.session.directory - }) - - createEffect(() => { - const directory = activeDirectory() - if (!directory) return - selected().enableConfiguredDirectory(directory) - }) + init: (props: { directory?: Accessor }) => { + const params = useParams() + const serverSDK = useServerSDK() + const serverSync = useServerSync() const permissionsEnabled = createMemo(() => { - const directory = activeDirectory() + const directory = props.directory?.() ?? decode64(params.dir) if (!directory) return false - return selected().permissionsEnabled(directory) + const [store] = serverSync().child(directory) + return hasPermissionPromptRules(store.config.permission) }) + const [store, setStore, _, ready] = persisted( + { + ...Persist.serverGlobal(serverSDK().scope, "permission", ["permission.v3"]), + migrate(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return value + + const data = value as Record + if (data.autoAccept) return value + + return { + ...data, + autoAccept: + typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits) + ? data.autoAcceptEdits + : {}, + } + }, + }, + createStore({ + autoAccept: {} as Record, + }), + ) + + // When config has permission: "allow", auto-enable directory-level auto-accept + createEffect(() => { + if (!ready()) return + const directory = props.directory?.() ?? decode64(params.dir) + if (!directory) return + const [childStore] = serverSync().child(directory) + const perm = childStore.config.permission + if (typeof perm === "string" && perm === "allow") { + const key = directoryAcceptKey(directory) + if (store.autoAccept[key] === undefined) { + setStore( + produce((draft) => { + draft.autoAccept[key] = true + }), + ) + } + } + }) + + const MAX_RESPONDED = 1000 + const RESPONDED_TTL_MS = 60 * 60 * 1000 + const responded = new Map() + const enableVersion = new Map() + + function pruneResponded(now: number) { + for (const [id, ts] of responded) { + if (now - ts < RESPONDED_TTL_MS) break + responded.delete(id) + } + + for (const id of responded.keys()) { + if (responded.size <= MAX_RESPONDED) break + responded.delete(id) + } + } + + const respond: PermissionRespondFn = (input) => { + serverSDK() + .client.permission.respond(input) + .catch(() => { + responded.delete(input.permissionID) + }) + } + + function respondOnce(permission: PermissionRequest, directory?: string) { + const now = Date.now() + const hit = responded.has(permission.id) + responded.delete(permission.id) + responded.set(permission.id, now) + pruneResponded(now) + if (hit) return + respond({ + sessionID: permission.sessionID, + permissionID: permission.id, + response: "once", + directory, + }) + } + + function isAutoAccepting(sessionID: string, directory?: string) { + const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : [] + return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory) + } + + function isAutoAcceptingDirectory(directory: string) { + return isDirectoryAutoAccepting(store.autoAccept, directory) + } + + function shouldAutoRespond(permission: PermissionRequest, directory?: string) { + const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : [] + return autoRespondsPermission(store.autoAccept, session, permission, directory) + } + + function bumpEnableVersion(sessionID: string, directory?: string) { + const key = acceptKey(sessionID, directory) + const next = (enableVersion.get(key) ?? 0) + 1 + enableVersion.set(key, next) + return next + } + + const unsubscribe = serverSDK().event.listen((e) => { + const event = e.details + if (event?.type !== "permission.asked") return + + const perm = event.properties + if (!shouldAutoRespond(perm, e.name)) return + + respondOnce(perm, e.name) + }) + onCleanup(unsubscribe) + + function enableDirectory(directory: string) { + const key = directoryAcceptKey(directory) + setStore( + produce((draft) => { + draft.autoAccept[key] = true + }), + ) + + serverSDK() + .client.permission.list({ directory }) + .then((x) => { + if (!isAutoAcceptingDirectory(directory)) return + for (const perm of x.data ?? []) { + if (!perm?.id) continue + if (!shouldAutoRespond(perm, directory)) continue + respondOnce(perm, directory) + } + }) + .catch(() => undefined) + } + + function disableDirectory(directory: string) { + const key = directoryAcceptKey(directory) + setStore( + produce((draft) => { + draft.autoAccept[key] = false + }), + ) + } + + function enable(sessionID: string, directory: string) { + const key = acceptKey(sessionID, directory) + const version = bumpEnableVersion(sessionID, directory) + setStore( + produce((draft) => { + draft.autoAccept[key] = true + delete draft.autoAccept[sessionID] + }), + ) + + serverSDK() + .client.permission.list({ directory }) + .then((x) => { + if (enableVersion.get(key) !== version) return + if (!isAutoAccepting(sessionID, directory)) return + for (const perm of x.data ?? []) { + if (!perm?.id) continue + if (!shouldAutoRespond(perm, directory)) continue + respondOnce(perm, directory) + } + }) + .catch(() => undefined) + } + + function disable(sessionID: string, directory?: string) { + bumpEnableVersion(sessionID, directory) + const key = directory ? acceptKey(sessionID, directory) : sessionID + setStore( + produce((draft) => { + draft.autoAccept[key] = false + if (!directory) return + delete draft.autoAccept[sessionID] + }), + ) + } + return { - ready: () => selected().ready(), - ensureServerState: (key: ServerConnection.Key) => ensure(key).api, - currentServerState: () => selected().api, - respond(input: Parameters[0]) { - selected().respond(input) - }, + ready, + respond, autoResponds(permission: PermissionRequest, directory?: string) { - return selected().autoResponds(permission, directory) - }, - isAutoAccepting(sessionID: string, directory?: string) { - return selected().isAutoAccepting(sessionID, directory) - }, - isAutoAcceptingDirectory(directory: string) { - return selected().isAutoAcceptingDirectory(directory) + return shouldAutoRespond(permission, directory) }, + isAutoAccepting, + isAutoAcceptingDirectory, toggleAutoAccept(sessionID: string, directory: string) { - selected().toggleAutoAccept(sessionID, directory) + if (isAutoAccepting(sessionID, directory)) { + disable(sessionID, directory) + return + } + + enable(sessionID, directory) }, toggleAutoAcceptDirectory(directory: string) { - selected().toggleAutoAcceptDirectory(directory) + if (isAutoAcceptingDirectory(directory)) { + disableDirectory(directory) + return + } + enableDirectory(directory) }, enableAutoAccept(sessionID: string, directory: string) { - selected().enableAutoAccept(sessionID, directory) + if (isAutoAccepting(sessionID, directory)) return + enable(sessionID, directory) }, disableAutoAccept(sessionID: string, directory?: string) { - selected().disableAutoAccept(sessionID, directory) + disable(sessionID, directory) }, permissionsEnabled, isPermissionAllowAll(directory: string) { - return selected().isPermissionAllowAll(directory) + const [childStore] = serverSync().child(directory) + const perm = childStore.config.permission + return typeof perm === "string" && perm === "allow" }, } }, }) - -type PermissionState = ReturnType -type PermissionEvent = Parameters[0]>[0] - -function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) { - const [store, setStore, _, ready] = persisted( - { - ...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]), - migrate(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) return value - - const data = value as Record - if (data.autoAccept) return value - - return { - ...data, - autoAccept: - typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits) - ? data.autoAcceptEdits - : {}, - } - }, - }, - createStore({ - autoAccept: {} as Record, - }), - ) - - function enableConfiguredDirectory(directory: string) { - if (input.sdk.protocolKind() !== "v1") return - if (meta.disposed || !ready()) return - const [childStore] = input.sync.child(directory) - if (childStore.config.permission !== "allow") return - const key = directoryAcceptKey(directory) - if (store.autoAccept[key] !== undefined) return - setStore( - produce((draft) => { - draft.autoAccept[key] = true - }), - ) - } - - const MAX_RESPONDED = 1000 - const RESPONDED_TTL_MS = 60 * 60 * 1000 - const responded = new Map() - const enableVersion = new Map() - const meta = { disposed: false } - - function pruneResponded(now: number) { - for (const [id, ts] of responded) { - if (now - ts < RESPONDED_TTL_MS) break - responded.delete(id) - } - - for (const id of responded.keys()) { - if (responded.size <= MAX_RESPONDED) break - responded.delete(id) - } - } - - const respond: PermissionRespondFn = (request) => { - if (meta.disposed) return - input.sdk.api.permission - .reply({ - sessionID: request.sessionID, - requestID: request.permissionID, - reply: request.response, - location: request.directory ? { directory: request.directory } : undefined, - }) - .catch(() => { - responded.delete(request.permissionID) - }) - } - - const list = async (directory: string) => { - if ((await input.sdk.protocol) === "v1") { - return (await input.sdk.client.permission.list({ directory })).data ?? [] - } - return input.sdk.api.permission.request - .list({ location: { directory } }) - .then((result) => result.data.map(normalizePermissionRequest)) - } - - function respondOnce(permission: PermissionRequest, directory?: string) { - const now = Date.now() - const hit = responded.has(permission.id) - responded.delete(permission.id) - responded.set(permission.id, now) - pruneResponded(now) - if (hit) return - respond({ - sessionID: permission.sessionID, - permissionID: permission.id, - response: "once", - directory, - }) - } - - function sessions(directory?: string) { - const info = Object.values(input.sync.session.data.info).filter((session) => !!session) - if (!directory) return info - return [...info, ...input.sync.child(directory, { bootstrap: false })[0].session] - } - - function isAutoAccepting(sessionID: string, directory?: string) { - return autoRespondsPermission(store.autoAccept, sessions(directory), { sessionID }, directory) - } - - function isAutoAcceptingDirectory(directory: string) { - return isDirectoryAutoAccepting(store.autoAccept, directory) - } - - function shouldAutoRespond(permission: PermissionRequest, directory?: string) { - return autoRespondsPermission(store.autoAccept, sessions(directory), permission, directory) - } - - function isPending(permission: PermissionRequest) { - const pending = input.sync.session.data.permission[permission.sessionID] - return pending === undefined || pending.some((item) => item.id === permission.id) - } - - async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) { - const override = sessionAutoAccept(store.autoAccept, sessions(directory), permission, directory) - if (override !== undefined) return override - if (input.sync.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory) - const lineage = await input.sync.session.lineage.resolve(permission.sessionID).catch(() => undefined) - if (meta.disposed || !lineage) return false - return shouldAutoRespond(permission, directory) - } - - async function respondPending( - permission: PermissionRequest, - directory?: string, - current: () => boolean = () => true, - ) { - if (!current() || !isPending(permission)) return - if (!(await shouldAutoRespondResolved(permission, directory))) return - if (meta.disposed || !current() || !isPending(permission)) return - respondOnce(permission, directory) - } - - function bumpEnableVersion(sessionID: string, directory?: string) { - const key = acceptKey(sessionID, directory) - const next = (enableVersion.get(key) ?? 0) + 1 - enableVersion.set(key, next) - return next - } - - const handlePermission = (e: PermissionEvent) => { - const event = e.details - if (event?.type !== "permission.asked") return - void respondPending(event.properties, e.name) - } - - const unsubscribe = input.sdk.event.listen((event) => { - if (ready()) { - handlePermission(event) - return - } - void ready.promise?.then(() => { - if (meta.disposed) return - handlePermission(event) - }) - }) - onCleanup(() => { - meta.disposed = true - unsubscribe() - }) - - function enableDirectory(directory: string) { - if (meta.disposed) return - const key = directoryAcceptKey(directory) - setStore( - produce((draft) => { - draft.autoAccept[key] = true - }), - ) - - list(directory) - .then((permissions) => { - if (meta.disposed) return - if (!isAutoAcceptingDirectory(directory)) return - for (const permission of permissions) { - void respondPending(permission, directory, () => isAutoAcceptingDirectory(directory)) - } - }) - .catch(() => undefined) - } - - function disableDirectory(directory: string) { - if (meta.disposed) return - const key = directoryAcceptKey(directory) - setStore( - produce((draft) => { - draft.autoAccept[key] = false - }), - ) - } - - function enable(sessionID: string, directory: string) { - if (meta.disposed) return - const key = acceptKey(sessionID, directory) - const version = bumpEnableVersion(sessionID, directory) - setStore( - produce((draft) => { - draft.autoAccept[key] = true - delete draft.autoAccept[sessionID] - }), - ) - - list(directory) - .then((permissions) => { - if (meta.disposed) return - if (enableVersion.get(key) !== version) return - if (!isAutoAccepting(sessionID, directory)) return - for (const permission of permissions) { - void respondPending( - permission, - directory, - () => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory), - ) - } - }) - .catch(() => undefined) - } - - function disable(sessionID: string, directory?: string) { - if (meta.disposed) return - bumpEnableVersion(sessionID, directory) - const key = directory ? acceptKey(sessionID, directory) : sessionID - setStore( - produce((draft) => { - draft.autoAccept[key] = false - if (!directory) return - delete draft.autoAccept[sessionID] - }), - ) - } - - const api = { - ready: () => !meta.disposed && ready(), - respond, - autoResponds(permission: PermissionRequest, directory?: string) { - if (meta.disposed) return false - return shouldAutoRespond(permission, directory) - }, - isAutoAccepting(sessionID: string, directory?: string) { - if (meta.disposed) return false - return isAutoAccepting(sessionID, directory) - }, - isAutoAcceptingDirectory(directory: string) { - if (meta.disposed) return false - return isAutoAcceptingDirectory(directory) - }, - toggleAutoAccept(sessionID: string, directory: string) { - if (meta.disposed) return - if (isAutoAccepting(sessionID, directory)) { - disable(sessionID, directory) - return - } - - enable(sessionID, directory) - }, - toggleAutoAcceptDirectory(directory: string) { - if (meta.disposed) return - if (isAutoAcceptingDirectory(directory)) { - disableDirectory(directory) - return - } - enableDirectory(directory) - }, - enableAutoAccept(sessionID: string, directory: string) { - if (meta.disposed) return - if (isAutoAccepting(sessionID, directory)) return - enable(sessionID, directory) - }, - disableAutoAccept(sessionID: string, directory?: string) { - if (meta.disposed) return - disable(sessionID, directory) - }, - isPermissionAllowAll(directory: string) { - if (meta.disposed) return false - const [childStore] = input.sync.child(directory) - return childStore.config.permission === "allow" - }, - } - - return { - ...api, - api, - sync: input.sync, - enableConfiguredDirectory, - permissionsEnabled(directory: string) { - if (meta.disposed) return false - const [childStore] = input.sync.child(directory) - return hasPermissionPromptRules(childStore.config.permission) - }, - } -} diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 0408b233ff..e59bec9aaf 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -5,7 +5,6 @@ import type { DesktopMenuAction } from "../desktop-menu" import { ServerConnection } from "./server" import type { WslServersPlatform } from "../wsl/types" import type { UpdaterPlatform } from "../updater" -import type { DraftStore } from "@/utils/draft-store" type PickerPaths = string | string[] | null type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } @@ -32,23 +31,23 @@ type PlatformBase = { /** App version */ version?: string - /** Open a web or mail URL in the default system application */ - openExternal(url: string): void + /** Open a URL in the default browser */ + openLink(url: string): void /** Open a local path in a local app (desktop only) */ openPath?(path: string, app?: string): Promise - /** Open a local file URL in its default app (desktop only) */ - openLocalFile?(url: string): void - - /** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */ - revealPath?(path: string): Promise - /** Restart the app */ restart(): Promise - /** Send a system notification */ - notify(title: string, description?: string, onClick?: () => void): Promise + /** Navigate back in history */ + back(): void + + /** Navigate forward in history */ + forward(): void + + /** Send a system notification (optional deep link) */ + notify(title: string, description?: string, href?: string): Promise /** Open a native attachment picker and read selected files sequentially (desktop only) */ openAttachmentPickerDialog?( @@ -65,9 +64,6 @@ type PlatformBase = { /** Storage mechanism, defaults to localStorage */ storage?: (name?: string) => SyncStorage | AsyncStorage - /** Prompt drafts, history, and their blobs. */ - draftStore?: DraftStore - /** Stable platform window identity for window-scoped persistence */ windowID?: string @@ -92,12 +88,12 @@ type PlatformBase = { /** Set the preferred display backend (desktop only) */ setDisplayBackend?(backend: DisplayBackend): Promise + /** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */ + parseMarkdown?(markdown: string): Promise + /** Webview zoom level (desktop only) */ webviewZoom?: Accessor - /** Whether the native desktop window is fullscreen */ - windowFullscreen?: Accessor - /** Get whether native pinch/Ctrl-scroll zoom gestures are enabled (desktop only) */ getPinchZoomEnabled?(): Promise | boolean @@ -116,9 +112,6 @@ type PlatformBase = { /** Export collected diagnostic logs (desktop only) */ exportDebugLogs?(): Promise - /** Force focus styles on interactive elements through desktop devtools (desktop only) */ - setForceFocus?(enabled: boolean): Promise - /** Record a fatal renderer error in platform logs (desktop only) */ recordFatalRendererError?(error: FatalRendererErrorLog): Promise } diff --git a/packages/app/src/context/prompt-state.test.ts b/packages/app/src/context/prompt-state.test.ts deleted file mode 100644 index 24afe5424e..0000000000 --- a/packages/app/src/context/prompt-state.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { createRoot } from "solid-js" -import { createPromptState, DEFAULT_PROMPT } from "./prompt-state" - -describe("prompt state initialization", () => { - test("initializes prompt text, cursor, and model together", () => { - createRoot((dispose) => { - const model = { providerID: "anthropic", modelID: "claude", variant: "high" } - const prompt = createPromptState({ prompt: "hello", model }) - - expect(prompt.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }]) - expect(prompt.cursor()).toBe(5) - expect(prompt.model.current()).toEqual(model) - expect(prompt.model.current()).not.toBe(model) - dispose() - }) - }) - - test("uses the default prompt without initial values", () => { - createRoot((dispose) => { - const prompt = createPromptState() - - expect(prompt.current()).toEqual(DEFAULT_PROMPT) - expect(prompt.cursor()).toBeUndefined() - expect(prompt.model.current()).toBeUndefined() - dispose() - }) - }) -}) diff --git a/packages/app/src/context/prompt-state.ts b/packages/app/src/context/prompt-state.ts deleted file mode 100644 index ea639a5bdd..0000000000 --- a/packages/app/src/context/prompt-state.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { checksum } from "@opencode-ai/core/util/encode" -import type { FilePartSource } from "@opencode-ai/sdk/v2/client" -import { batch, createMemo, type Accessor } from "solid-js" -import { createStore, type SetStoreFunction } from "solid-js/store" -import type { FileSelection } from "@/context/file" -import { Persist, persisted } from "@/utils/persist" -import type { ServerScope } from "@/utils/server-scope" -import type { BlobReference } from "@/utils/draft-store" - -interface PartBase { - content: string - start: number - end: number -} - -export interface TextPart extends PartBase { - type: "text" -} - -export interface FileAttachmentPart extends PartBase { - type: "file" - path: string - selection?: FileSelection - mime?: string - filename?: string - url?: string - source?: FilePartSource -} - -export interface AgentPart extends PartBase { - type: "agent" - name: string -} - -export interface ImageAttachmentPart { - type: "image" - id: string - filename: string - sourcePath?: string - mime: string - blob: BlobReference -} - -export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart -export type Prompt = ContentPart[] - -export type PromptModel = { - providerID: string - modelID: string - variant?: string | null -} - -export type FileContextItem = { - type: "file" - path: string - selection?: FileSelection - comment?: string - commentID?: string - commentOrigin?: "review" | "file" - preview?: string -} - -export type ContextItem = FileContextItem -export type PromptScope = { draftID: string } | { dir: string; id?: string } - -export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] - -export type PromptStore = { - prompt: Prompt - cursor?: number - model?: PromptModel - context: { - items: (ContextItem & { key: string })[] - } -} - -type InitialPrompt = { - prompt?: string - model?: PromptModel -} - -function isSelectionEqual(a?: FileSelection, b?: FileSelection) { - if (!a && !b) return true - if (!a || !b) return false - return ( - a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar - ) -} - -function isPartEqual(partA: ContentPart, partB: ContentPart) { - switch (partA.type) { - case "text": - return partB.type === "text" && partA.content === partB.content - case "file": - return ( - partB.type === "file" && - partA.path === partB.path && - partA.mime === partB.mime && - partA.filename === partB.filename && - isSelectionEqual(partA.selection, partB.selection) - ) - case "agent": - return partB.type === "agent" && partA.name === partB.name - case "image": - return partB.type === "image" && partA.id === partB.id - } -} - -export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { - if (promptA.length !== promptB.length) return false - for (let i = 0; i < promptA.length; i++) { - if (!isPartEqual(promptA[i], promptB[i])) return false - } - return true -} - -function cloneSelection(selection?: FileSelection) { - if (!selection) return undefined - return { ...selection } -} - -function clonePart(part: ContentPart): ContentPart { - if (part.type === "text") return { ...part } - if (part.type === "image") return { ...part } - if (part.type === "agent") return { ...part } - return { - ...part, - selection: cloneSelection(part.selection), - } -} - -function clonePrompt(prompt: Prompt): Prompt { - return prompt.map(clonePart) -} - -function contextItemKey(item: ContextItem) { - if (item.type !== "file") return item.type - const start = item.selection?.startLine - const end = item.selection?.endLine - const key = `${item.type}:${item.path}:${start}:${end}` - - if (item.commentID) return `${key}:c=${item.commentID}` - const comment = item.comment?.trim() - if (!comment) return key - const digest = checksum(comment) ?? comment - return `${key}:c=${digest.slice(0, 8)}` -} - -export function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { - return item.type === "file" && !!item.comment?.trim() -} - -function createPromptActions(setStore: SetStoreFunction) { - return { - set(prompt: Prompt, cursorPosition?: number) { - const next = clonePrompt(prompt) - batch(() => { - setStore("prompt", next) - if (cursorPosition !== undefined) setStore("cursor", cursorPosition) - }) - }, - reset() { - batch(() => { - setStore("prompt", clonePrompt(DEFAULT_PROMPT)) - setStore("cursor", 0) - }) - }, - } -} - -function promptTarget(serverScope: ServerScope, scope: PromptScope) { - if ("draftID" in scope) return Persist.prompt(Persist.draft(scope.draftID, "prompt")) - const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` - return Persist.prompt(Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])) -} - -function promptStore(initial?: InitialPrompt): PromptStore { - const text = initial?.prompt - return { - prompt: - text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }], - cursor: text === undefined ? undefined : text.length, - model: initial?.model ? { ...initial.model } : undefined, - context: { - items: [], - }, - } -} - -function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction) { - const actions = createPromptActions(setStore) - const value = { - store: [() => store, setStore] as [Accessor, SetStoreFunction], - current: () => store.prompt, - cursor: createMemo(() => store.cursor), - dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), - model: { - current: () => store.model, - set: (model: PromptModel | undefined) => setStore("model", model), - }, - context: { - items: createMemo(() => store.context.items), - add(item: ContextItem) { - const key = contextItemKey(item) - if (store.context.items.find((x) => x.key === key)) return - setStore("context", "items", (items) => [...items, { key, ...item }]) - }, - remove(key: string) { - setStore("context", "items", (items) => items.filter((x) => x.key !== key)) - }, - removeComment(path: string, commentID: string) { - setStore("context", "items", (items) => - items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)), - ) - }, - updateComment(path: string, commentID: string, next: Partial & { comment?: string }) { - setStore("context", "items", (items) => - items.map((item) => { - if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item - const value = { ...item, ...next } - return { ...value, key: contextItemKey(value) } - }), - ) - }, - replaceComments(items: FileContextItem[]) { - setStore("context", "items", (current) => [ - ...current.filter((item) => !isCommentItem(item)), - ...items.map((item) => ({ ...item, key: contextItemKey(item) })), - ]) - }, - }, - set: actions.set, - reset: actions.reset, - capture: () => value, - } - return value -} - -function createPersistedPrompt(target: ReturnType, initial?: InitialPrompt) { - const [store, setStore, _, ready] = persisted(target, createStore(promptStore(initial))) - return { ready, ...createPromptStateValue(store, setStore) } -} - -export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) { - return createPersistedPrompt(promptTarget(serverScope, scope), initial) -} - -export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) { - return createPersistedPrompt(Persist.prompt(Persist.draft(draftID, "prompt")), initial) -} - -export type PromptSession = ReturnType - -export function createPromptReady(session: Accessor) { - return Object.defineProperty(() => session().ready(), "promise", { - get: () => session().ready.promise, - }) as (() => boolean) & { readonly promise: Promise | undefined } -} - -export function createPromptState(initial?: InitialPrompt) { - const [store, setStore] = createStore(promptStore(initial)) - const ready = Object.assign(() => true, { promise: Promise.resolve(true) }) - return { - ready, - ...createPromptStateValue(store, setStore), - } -} diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 41a99f3b67..c361493516 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,51 +1,186 @@ -import { base64Encode } from "@opencode-ai/core/util/encode" import { createSimpleContext } from "@opencode-ai/ui/context" +import { base64Encode, checksum } from "@opencode-ai/core/util/encode" import { useParams, useSearchParams } from "@solidjs/router" -import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js" -import { requireServerKey } from "@/utils/session-route" -import { ServerConnection } from "./server" +import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js" +import { createStore, type SetStoreFunction } from "solid-js/store" +import type { FileSelection } from "@/context/file" +import { Persist, persisted } from "@/utils/persist" import { useServerSDK } from "./server-sdk" -import { useSettings } from "./settings" +import type { ServerScope } from "@/utils/server-scope" import { useSDK } from "./sdk" import { useTabs, type Tab } from "./tabs" -import { - createPromptReady, - createPromptSession, - type ContextItem, - type FileContextItem, - type Prompt, - type PromptModel, - type PromptScope, - type PromptSession, -} from "./prompt-state" +import { ServerConnection } from "./server" +import { requireServerKey } from "@/utils/session-route" +import { useSettings } from "./settings" +import type { FilePartSource } from "@opencode-ai/sdk/v2/client" -export { - createPromptReady, - createPromptSession, - createPromptState, - DEFAULT_PROMPT, - isCommentItem, - isPromptEqual, -} from "./prompt-state" -export type { - AgentPart, - ContentPart, - ContextItem, - FileAttachmentPart, - FileContextItem, - ImageAttachmentPart, - Prompt, - PromptModel, - PromptStore, - PromptScope, - PromptSession, - TextPart, -} from "./prompt-state" +interface PartBase { + content: string + start: number + end: number +} + +export interface TextPart extends PartBase { + type: "text" +} + +export interface FileAttachmentPart extends PartBase { + type: "file" + path: string + selection?: FileSelection + mime?: string + filename?: string + url?: string + source?: FilePartSource +} + +export interface AgentPart extends PartBase { + type: "agent" + name: string +} + +export interface ImageAttachmentPart { + type: "image" + id: string + filename: string + sourcePath?: string + mime: string + dataUrl: string +} + +export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart +export type Prompt = ContentPart[] + +export type FileContextItem = { + type: "file" + path: string + selection?: FileSelection + comment?: string + commentID?: string + commentOrigin?: "review" | "file" + preview?: string +} + +export type ContextItem = FileContextItem + +export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] + +function isSelectionEqual(a?: FileSelection, b?: FileSelection) { + if (!a && !b) return true + if (!a || !b) return false + return ( + a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar + ) +} + +function isPartEqual(partA: ContentPart, partB: ContentPart) { + switch (partA.type) { + case "text": + return partB.type === "text" && partA.content === partB.content + case "file": + return ( + partB.type === "file" && + partA.path === partB.path && + partA.mime === partB.mime && + partA.filename === partB.filename && + isSelectionEqual(partA.selection, partB.selection) + ) + case "agent": + return partB.type === "agent" && partA.name === partB.name + case "image": + return partB.type === "image" && partA.id === partB.id + } +} + +export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { + if (promptA.length !== promptB.length) return false + for (let i = 0; i < promptA.length; i++) { + if (!isPartEqual(promptA[i], promptB[i])) return false + } + return true +} + +function cloneSelection(selection?: FileSelection) { + if (!selection) return undefined + return { ...selection } +} + +function clonePart(part: ContentPart): ContentPart { + if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } + if (part.type === "agent") return { ...part } + return { + ...part, + selection: cloneSelection(part.selection), + } +} + +function clonePrompt(prompt: Prompt): Prompt { + return prompt.map(clonePart) +} + +function contextItemKey(item: ContextItem) { + if (item.type !== "file") return item.type + const start = item.selection?.startLine + const end = item.selection?.endLine + const key = `${item.type}:${item.path}:${start}:${end}` + + if (item.commentID) { + return `${key}:c=${item.commentID}` + } + + const comment = item.comment?.trim() + if (!comment) return key + const digest = checksum(comment) ?? comment + return `${key}:c=${digest.slice(0, 8)}` +} + +function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { + return item.type === "file" && !!item.comment?.trim() +} + +function createPromptActions( + setStore: SetStoreFunction<{ + prompt: Prompt + cursor?: number + context: { + items: (ContextItem & { key: string })[] + } + }>, +) { + return { + set(prompt: Prompt, cursorPosition?: number) { + const next = clonePrompt(prompt) + batch(() => { + setStore("prompt", next) + if (cursorPosition !== undefined) setStore("cursor", cursorPosition) + }) + }, + reset() { + batch(() => { + setStore("prompt", clonePrompt(DEFAULT_PROMPT)) + setStore("cursor", 0) + }) + }, + } +} const WORKSPACE_KEY = "__workspace__" const MAX_PROMPT_SESSIONS = 20 -export function selectPromptTab(tabs: Tab[], scope: PromptScope, server: ServerConnection.Key) { +type PromptSession = ReturnType + +type PromptStore = { + prompt: Prompt + cursor?: number + context: { + items: (ContextItem & { key: string })[] + } +} + +type Scope = { draftID: string } | { dir: string; id?: string } + +export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) { if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID) if (!scope.id) return return ( @@ -54,7 +189,7 @@ export function selectPromptTab(tabs: Tab[], scope: PromptScope, server: ServerC ) } -function scopeKey(scope: PromptScope) { +function scopeKey(scope: Scope) { if ("draftID" in scope) return `draft:${scope.draftID}` return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}` } @@ -64,6 +199,91 @@ type PromptCacheEntry = { dispose: VoidFunction } +function promptTarget(serverScope: ServerScope, scope: Scope) { + if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt") + const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` + return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]) +} + +export function createPromptSession(serverScope: ServerScope, scope: Scope) { + const [store, setStore, _, ready] = persisted( + promptTarget(serverScope, scope), + createStore(promptStore()), + ) + + return { ready, ...createPromptStateValue(store, setStore) } +} + +export function createPromptReady(session: Accessor) { + return Object.defineProperty(() => session().ready(), "promise", { + get: () => session().ready.promise, + }) as (() => boolean) & { readonly promise: Promise | undefined } +} + +function promptStore(): PromptStore { + return { + prompt: clonePrompt(DEFAULT_PROMPT), + cursor: undefined, + context: { + items: [], + }, + } +} + +function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction) { + const actions = createPromptActions(setStore) + + const value = { + current: () => store.prompt, + cursor: createMemo(() => store.cursor), + dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), + context: { + items: createMemo(() => store.context.items), + add(item: ContextItem) { + const key = contextItemKey(item) + if (store.context.items.find((x) => x.key === key)) return + setStore("context", "items", (items) => [...items, { key, ...item }]) + }, + remove(key: string) { + setStore("context", "items", (items) => items.filter((x) => x.key !== key)) + }, + removeComment(path: string, commentID: string) { + setStore("context", "items", (items) => + items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)), + ) + }, + updateComment(path: string, commentID: string, next: Partial & { comment?: string }) { + setStore("context", "items", (items) => + items.map((item) => { + if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item + const value = { ...item, ...next } + return { ...value, key: contextItemKey(value) } + }), + ) + }, + replaceComments(items: FileContextItem[]) { + setStore("context", "items", (current) => [ + ...current.filter((item) => !isCommentItem(item)), + ...items.map((item) => ({ ...item, key: contextItemKey(item) })), + ]) + }, + }, + set: actions.set, + reset: actions.reset, + capture: () => value, + } + return value +} + +export function createPromptState() { + const [store, setStore] = createStore(promptStore()) + const ready = Object.assign(() => true, { promise: Promise.resolve(true) }) + return { + ready, + ...createPromptStateValue(store, setStore), + } +} + export const createTabPromptState = ( tabs: ReturnType, tab: Tab, @@ -83,7 +303,9 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const cache = new Map() const disposeAll = () => { - for (const entry of cache.values()) entry.dispose() + for (const entry of cache.values()) { + entry.dispose() + } cache.clear() } @@ -102,11 +324,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const owner = getOwner() const serverKey = () => params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server) - const scope = (): PromptScope => + const scope = () => search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id } - const load = (scope: PromptScope) => { + const load = (scope: Scope) => { const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined - if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope) + if (current) { + return createTabPromptState(tabs, current, serverSDK().scope, scope) + } const key = scopeKey(scope) const existing = cache.get(key) @@ -130,32 +354,17 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( } const session = createMemo(() => load(scope())) - const pick = (scope?: PromptScope) => (scope ? load(scope) : session()) + const pick = (scope?: Scope) => (scope ? load(scope) : session()) const ready = createPromptReady(session) - const withSuspense = (cb: () => T): (() => T) => - createResource( - async () => { - const value = cb() - await session().ready.promise - return value - }, - cb, - { initialValue: cb() }, - )[0] - return { ready, - capture: (scope?: PromptScope) => pick(scope).capture(), - current: withSuspense(() => session().current()), - cursor: withSuspense(() => session().cursor()), - dirty: withSuspense(() => session().dirty()), - model: { - current: withSuspense(() => session().model.current()), - set: (model: PromptModel | undefined) => session().model.set(model), - }, + capture: (scope?: Scope) => pick(scope).capture(), + current: () => session().current(), + cursor: () => session().cursor(), + dirty: () => session().dirty(), context: { - items: withSuspense(() => session().context.items()), + items: () => session().context.items(), add: (item: ContextItem) => session().context.add(item), remove: (key: string) => session().context.remove(key), removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID), @@ -163,8 +372,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( session().context.updateComment(path, commentID, next), replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items), }, - set: (prompt: Prompt, cursorPosition?: number, scope?: PromptScope) => pick(scope).set(prompt, cursorPosition), - reset: (scope?: PromptScope) => pick(scope).reset(), + set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition), + reset: (scope?: Scope) => pick(scope).reset(), } }, }) diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index 57e1cd86f3..7b592178fa 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "bun:test" -import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" -import type { OpenCodeEvent } from "@opencode-ai/client/promise" +import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" import type { Event } from "@opencode-ai/sdk/v2/client" describe("resumeStreamAfterPageShow", () => { @@ -15,23 +14,6 @@ describe("resumeStreamAfterPageShow", () => { }) }) -describe("adaptServerEvent", () => { - test("preserves V2 events while adapting permission requests for existing consumers", () => { - const current = { - id: "evt_1", - created: 1, - type: "permission.v2.asked", - data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] }, - } as OpenCodeEvent - - expect(adaptServerEvent(current)).toMatchObject({ - type: "permission.asked", - properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] }, - current, - }) - }) -}) - describe("coalesceServerEvents", () => { const delta = (value: string, field = "text", partID = "part") => ({ directory: "/repo", @@ -52,24 +34,6 @@ describe("coalesceServerEvents", () => { expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } }) }) - test("merges adjacent current text deltas", () => { - const current = (id: string, value: string) => - adaptServerEvent({ - id, - created: 1, - type: "session.text.delta", - location: { directory: "/repo" }, - data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value }, - } as OpenCodeEvent) - const result = coalesceServerEvents([ - { directory: "/repo", payload: current("evt_1", "hello ") }, - { directory: "/repo", payload: current("evt_2", "world") }, - ]) - - expect(result).toHaveLength(1) - expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } }) - }) - test("preserves event boundaries and distinct fields", () => { const status = { directory: "/repo", diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 7dd2a6e59e..06597e56e7 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -1,60 +1,21 @@ -import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { Event } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { makeEventListener } from "@solid-primitives/event-listener" -import { type Accessor, batch, createMemo, createResource, onCleanup, onMount } from "solid-js" -import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server" +import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js" +import { createSdkForServer } from "@/utils/server" import { useLanguage } from "./language" import { usePlatform } from "./platform" import { ServerConnection, useServer } from "./server" import { createRefCountMap } from "@/utils/refcount" import { useGlobal } from "./global" import { ServerScope } from "@/utils/server-scope" -import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol" -import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat" const isAbortError = (error: unknown) => error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true -export type ServerEvent = Event & { current?: OpenCodeEvent } -type QueuedServerEvent = { directory: string; payload: ServerEvent } -type CurrentDelta = Extract< - OpenCodeEvent, - { type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" } -> - -export function adaptServerEvent(event: OpenCodeEvent): ServerEvent { - if (event.type === "permission.v2.asked") { - return { - id: event.id, - type: "permission.asked", - properties: { - id: event.data.id, - sessionID: event.data.sessionID, - permission: event.data.action, - patterns: event.data.resources, - always: event.data.save ?? [], - metadata: event.data.metadata ?? {}, - tool: - event.data.source?.type === "tool" - ? { messageID: event.data.source.messageID, callID: event.data.source.callID } - : undefined, - }, - current: event, - } as ServerEvent - } - if (event.type === "permission.v2.replied") - return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent - if (event.type === "question.v2.asked") - return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent - if (event.type === "question.v2.replied") - return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent - if (event.type === "question.v2.rejected") - return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent - return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent -} +type QueuedServerEvent = { directory: string; payload: Event } const coalescedKey = (event: QueuedServerEvent) => { if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}` @@ -79,34 +40,6 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ export function coalesceServerEvents(events: QueuedServerEvent[]) { const output: QueuedServerEvent[] = [] events.forEach((event) => { - const current = currentDelta(event.payload.current) - if (current) { - const previous = output[output.length - 1] - const prior = currentDelta(previous?.payload.current) - if ( - previous && - prior && - previous.directory === event.directory && - currentDeltaKey(prior) === currentDeltaKey(current) - ) { - const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current) - const data = - current.type === "session.compaction.delta" - ? { ...current.data, text: fragment } - : { ...current.data, delta: fragment } - output[output.length - 1] = { - directory: event.directory, - payload: { - ...event.payload, - properties: data, - current: { ...current, data } as CurrentDelta, - } as ServerEvent, - } - return - } - output.push(event) - return - } if (event.payload.type !== "message.part.delta") { output.push(event) return @@ -138,53 +71,12 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) { return output } -function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined { - if ( - event?.type === "session.text.delta" || - event?.type === "session.reasoning.delta" || - event?.type === "session.tool.input.delta" || - event?.type === "session.compaction.delta" - ) - return event -} - -function currentDeltaKey(event: CurrentDelta) { - if (event.type === "session.tool.input.delta") - return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}` - if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}` - return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}` -} - -function currentDeltaFragment(event: CurrentDelta) { - return event.type === "session.compaction.delta" ? event.data.text : event.data.delta -} - export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) { if (!event.persisted) return start() } -type ServerEventEmitter = ReturnType> -type ServerSDKBase = { - server: ServerConnection.Any - scope: ServerScope - protocol: Promise - protocolKind: Accessor - url: string - client: ReturnType - api: CompatibleApi - currentApi: ServerApi - event: { - on: ServerEventEmitter["on"] - listen: ServerEventEmitter["listen"] - start: () => Promise | undefined - } - createClient: ( - opts: Omit[0], "server" | "fetch">, - ) => ReturnType -} - -function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase { +function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) { const platform = usePlatform() const abort = new AbortController() @@ -199,19 +91,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } })() - const eventApi = createApiForServer({ server: server.http, fetch: eventFetch }) const eventSdk = createSdkForServer({ signal: abort.signal, fetch: eventFetch, server: server.http, }) - const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch) - const [protocolKind] = createResource( - () => protocol, - (value) => value, - ) const emitter = createGlobalEmitter<{ - [key: string]: ServerEvent + [key: string]: Event }>() type Queued = QueuedServerEvent @@ -256,6 +142,21 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS let run: Promise | undefined let started = false let generation = 0 + const HEARTBEAT_TIMEOUT_MS = 15_000 + let lastEventAt = Date.now() + let heartbeat: ReturnType | undefined + const resetHeartbeat = () => { + lastEventAt = Date.now() + if (heartbeat) clearTimeout(heartbeat) + heartbeat = setTimeout(() => { + attempt?.abort() + }, HEARTBEAT_TIMEOUT_MS) + } + const clearHeartbeat = () => { + if (!heartbeat) return + clearTimeout(heartbeat) + heartbeat = undefined + } const start = () => { if (started) return run @@ -267,24 +168,35 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit while (!abort.signal.aborted && started && generation === active) { attempt = new AbortController() + lastEventAt = Date.now() const onAbort = () => { attempt?.abort() } abort.signal.addEventListener("abort", onAbort) try { - const kind = await protocol - const events = - kind === "v1" - ? (await eventSdk.global.event({ signal: attempt.signal })).stream - : eventApi.event.subscribe({ signal: attempt.signal }) + const events = await eventSdk.global.event({ + signal: attempt.signal, + onSseError: (error) => { + if (isStreamClosed(error, attempt?.signal)) return + if (streamErrorLogged) return + streamErrorLogged = true + console.error("[global-sdk] event stream error", { + url: server.http.url, + fetch: eventFetch ? "platform" : "webview", + error, + }) + }, + }) let yielded = Date.now() - for await (const event of events) { + resetHeartbeat() + for await (const event of events.stream) { + resetHeartbeat() streamErrorLogged = false - const legacy = "payload" in event - if (legacy && event.payload.type === "sync") continue - const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global") - const payload = legacy ? (event.payload as Event) : adaptServerEvent(event) - if (enqueueServerEvent(queue, { directory, payload })) schedule() + if (event.payload.type !== "sync") { + const directory = event.directory ?? "global" + const payload = event.payload as Event + if (enqueueServerEvent(queue, { directory, payload })) schedule() + } if (Date.now() - yielded < STREAM_YIELD_MS) continue yielded = Date.now() @@ -302,6 +214,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } finally { abort.signal.removeEventListener("abort", onAbort) attempt = undefined + clearHeartbeat() } if (abort.signal.aborted || !started || generation !== active) return @@ -320,11 +233,18 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS started = false generation++ attempt?.abort() + clearHeartbeat() } onMount(() => { makeEventListener(window, "pagehide", stop) makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start)) + makeEventListener(document, "visibilitychange", () => { + if (document.visibilityState !== "visible") return + if (!started) return + if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return + attempt?.abort() + }) }) onCleanup(() => { @@ -338,25 +258,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS fetch: platform.fetch, throwOnError: true, }) - const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch }) - const legacy = (directory?: string) => - createSdkForServer({ - server: server.http, - fetch: platform.fetch, - throwOnError: true, - directory, - }) - const api = createCompatibleApi({ protocol, current: currentApi, legacy }) return { server, scope, - protocol, - protocolKind, url: server.http.url, client: sdk, - api, - currentApi, event: { on: emitter.on.bind(emitter), listen: emitter.listen.bind(emitter), @@ -372,6 +279,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } } +type ServerSDKBase = ReturnType export type ServerSDK = ServerSDKBase & { ensureDirSdkContext: (directory: string) => ReturnType } @@ -400,13 +308,8 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo }, }) -export function useServerProtocol() { - const serverSDK = useServerSDK() - return createMemo(() => serverSDK().protocolKind()) -} - type SDKEventMap = { - [key in Event["type"]]: Extract + [key in Event["type"]]: Extract } function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { @@ -424,15 +327,8 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { return { scope: serverSDK.scope, - protocol: serverSDK.protocol, directory, client, - api: createCompatibleApi({ - protocol: serverSDK.protocol, - current: serverSDK.currentApi, - legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }), - directory, - }), event: emitter, get url() { return serverSDK.url diff --git a/packages/app/src/context/server-session-v2-reducer.test.ts b/packages/app/src/context/server-session-v2-reducer.test.ts deleted file mode 100644 index 00cc37cf52..0000000000 --- a/packages/app/src/context/server-session-v2-reducer.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise" -import { createV2SessionReducer } from "./server-session-v2-reducer" - -const event = (input: object) => input as OpenCodeEvent -const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } } - -describe("v2 session reducer", () => { - test("projects promoted input and streaming assistant content", () => { - const reducer = createV2SessionReducer() - let messages: SessionMessageInfo[] = [] - const apply = (input: object) => { - const result = reducer.reduce(messages, event(input)) - if (result) messages = result.messages - return result - } - - apply({ - ...base, - id: "evt_admitted", - type: "session.input.admitted", - data: { - sessionID: "ses_1", - inputID: "msg_user", - input: { type: "user", delivery: "steer", data: { text: "hello" } }, - }, - }) - apply({ - ...base, - id: "evt_promoted", - type: "session.input.promoted", - data: { sessionID: "ses_1", inputID: "msg_user" }, - }) - apply({ - ...base, - id: "evt_step", - type: "session.step.started", - data: { - sessionID: "ses_1", - assistantMessageID: "msg_assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - }, - }) - apply({ - ...base, - id: "evt_text_start", - type: "session.text.started", - data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0 }, - }) - apply({ - ...base, - id: "evt_text_delta", - type: "session.text.delta", - data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, delta: "hel" }, - }) - apply({ - ...base, - id: "evt_text_end", - type: "session.text.ended", - data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, text: "hello" }, - }) - - expect(messages[0]).toMatchObject({ id: "msg_user", type: "user", text: "hello" }) - expect(messages[1]).toMatchObject({ - id: "msg_assistant", - type: "assistant", - content: [{ type: "text", text: "hello" }], - }) - }) - - test("folds tool, retry, and completion events", () => { - const reducer = createV2SessionReducer() - let messages: SessionMessageInfo[] = [] - const apply = (input: object) => { - const result = reducer.reduce(messages, event(input)) - if (result) messages = result.messages - } - - apply({ - ...base, - id: "evt_step", - type: "session.step.started", - data: { - sessionID: "ses_1", - assistantMessageID: "msg_assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - }, - }) - apply({ - ...base, - id: "evt_tool_start", - type: "session.tool.input.started", - data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", name: "bash" }, - }) - apply({ - ...base, - id: "evt_tool_delta", - type: "session.tool.input.delta", - data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", delta: "{}" }, - }) - apply({ - ...base, - id: "evt_tool_called", - type: "session.tool.called", - data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", input: {}, executed: true }, - }) - apply({ - ...base, - id: "evt_tool_success", - type: "session.tool.success", - data: { - sessionID: "ses_1", - assistantMessageID: "msg_assistant", - callID: "call_1", - metadata: {}, - content: [{ type: "text", text: "done" }], - executed: true, - }, - }) - apply({ - ...base, - id: "evt_retry", - type: "session.retry.scheduled", - data: { - sessionID: "ses_1", - assistantMessageID: "msg_assistant", - attempt: 2, - at: 10, - error: { type: "ProviderError", message: "retry" }, - }, - }) - apply({ ...base, id: "evt_done", type: "session.execution.succeeded", data: { sessionID: "ses_1" } }) - - expect(messages[0]).toMatchObject({ - type: "assistant", - retry: undefined, - content: [{ type: "tool", id: "call_1", state: { status: "completed", content: [{ text: "done" }] } }], - }) - }) - - test("requests hydration when promotion admission was missed", () => { - const result = createV2SessionReducer().reduce( - [], - event({ - ...base, - id: "evt_promoted", - type: "session.input.promoted", - data: { sessionID: "ses_1", inputID: "msg_user" }, - }), - ) - - expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] }) - }) -}) diff --git a/packages/app/src/context/server-session-v2-reducer.ts b/packages/app/src/context/server-session-v2-reducer.ts deleted file mode 100644 index b34ab3985e..0000000000 --- a/packages/app/src/context/server-session-v2-reducer.ts +++ /dev/null @@ -1,509 +0,0 @@ -import type { OpenCodeEvent, SessionMessageInfo, SessionPendingMessage } from "@opencode-ai/client/promise" - -type Assistant = Extract -type Compaction = Extract -type Shell = Extract - -export type V2SessionReduction = { - sessionID: string - messages: SessionMessageInfo[] - touched: string[] - missing?: string -} - -export function createV2SessionReducer() { - const pending = new Map() - - const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => { - if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return - const sessionID = event.data.sessionID - const result = (messages: SessionMessageInfo[], touched: string[] = []): V2SessionReduction => ({ - sessionID, - messages, - touched, - }) - const append = (message: SessionMessageInfo) => - result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id]) - - switch (event.type) { - case "session.input.admitted": - pending.set(key(sessionID, event.data.inputID), event.data.input) - return result([...source]) - case "session.input.promoted": { - const input = pending.get(key(sessionID, event.data.inputID)) - pending.delete(key(sessionID, event.data.inputID)) - if (!input) return { ...result([...source]), missing: event.data.inputID } - if (input.type === "user") - return append({ - id: event.data.inputID, - type: "user", - metadata: input.data.metadata, - text: input.data.text, - files: input.data.files, - agents: input.data.agents, - time: { created: event.created }, - }) - return append({ - id: event.data.inputID, - type: "synthetic", - metadata: input.data.metadata, - text: input.data.text, - description: input.data.description, - time: { created: event.created }, - }) - } - case "session.agent.selected": - return append({ - id: messageID(event.id), - type: "agent-switched", - metadata: event.metadata, - agent: event.data.agent, - time: { created: event.created }, - }) - case "session.model.selected": - return append({ - id: messageID(event.id), - type: "model-switched", - metadata: event.metadata, - model: event.data.model, - previous: source.findLast( - (item): item is Extract => - item.type === "model-switched" || item.type === "assistant", - )?.model, - time: { created: event.created }, - }) - case "session.synthetic": - return append({ - id: messageID(event.id), - type: "synthetic", - metadata: event.data.metadata, - text: event.data.text, - description: event.data.description, - time: { created: event.created }, - }) - case "session.skill.activated": - return append({ - id: messageID(event.id), - type: "skill", - metadata: event.metadata, - skill: event.data.id, - name: event.data.name, - text: event.data.text, - time: { created: event.created }, - }) - case "session.shell.started": - return append({ - id: messageID(event.id), - type: "shell", - metadata: event.metadata, - shellID: event.data.shell.id, - command: event.data.shell.command, - status: event.data.shell.status, - exit: event.data.shell.exit, - time: { created: event.created }, - }) - case "session.shell.ended": - return updateMessage( - source, - (item): item is Shell => item.type === "shell" && item.shellID === event.data.shell.id, - (item) => ({ - ...item, - status: event.data.shell.status, - exit: event.data.shell.exit, - output: event.data.output, - time: { ...item.time, completed: event.created }, - }), - sessionID, - ) - case "session.step.started": { - const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed) - const completed = - current && current.id !== event.data.assistantMessageID - ? update(source, current.id, (item) => - item.type === "assistant" - ? { ...item, retry: undefined, time: { ...item.time, completed: event.created } } - : item, - ) - : [...source] - const existing = completed.find((item) => item.id === event.data.assistantMessageID) - if (existing?.type === "assistant") - return result( - update(completed, existing.id, (item) => - item.type === "assistant" - ? { - ...item, - agent: event.data.agent, - model: event.data.model, - retry: undefined, - error: undefined, - finish: undefined, - snapshot: event.data.snapshot ? { ...item.snapshot, start: event.data.snapshot } : item.snapshot, - time: { ...item.time, completed: undefined }, - } - : item, - ), - current && current.id !== existing.id ? [current.id, existing.id] : [existing.id], - ) - return result( - [ - ...completed, - { - id: event.data.assistantMessageID, - type: "assistant", - metadata: event.metadata, - agent: event.data.agent, - model: event.data.model, - content: [], - snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, - time: { created: event.created }, - }, - ], - current ? [current.id, event.data.assistantMessageID] : [event.data.assistantMessageID], - ) - } - case "session.step.ended": - return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ - ...item, - finish: event.data.finish, - cost: event.data.cost, - tokens: event.data.tokens, - snapshot: - event.data.snapshot || event.data.files - ? { ...item.snapshot, end: event.data.snapshot, files: event.data.files } - : item.snapshot, - time: { ...item.time, completed: event.created }, - })) - case "session.step.failed": - return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ - ...item, - finish: "error", - error: event.data.error, - retry: undefined, - cost: event.data.cost ?? item.cost, - tokens: event.data.tokens ?? item.tokens, - snapshot: - event.data.snapshot || event.data.files - ? { ...item.snapshot, end: event.data.snapshot, files: event.data.files } - : item.snapshot, - time: { ...item.time, completed: event.created }, - })) - case "session.text.started": - return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ - ...item, - content: insertOrdinal(item.content, "text", event.data.ordinal, { type: "text", text: "" }), - })) - case "session.text.delta": - return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({ - ...item, - text: item.text + event.data.delta, - })) - case "session.text.ended": - return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({ - ...item, - text: event.data.text, - })) - case "session.reasoning.started": - return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ - ...item, - content: insertOrdinal(item.content, "reasoning", event.data.ordinal, { - type: "reasoning", - text: "", - state: event.data.state, - time: { created: event.created }, - }), - })) - case "session.reasoning.delta": - return updateContent( - source, - event.data.assistantMessageID, - sessionID, - "reasoning", - event.data.ordinal, - (item) => ({ - ...item, - text: item.text + event.data.delta, - }), - ) - case "session.reasoning.ended": - return updateContent( - source, - event.data.assistantMessageID, - sessionID, - "reasoning", - event.data.ordinal, - (item) => ({ - ...item, - text: event.data.text, - state: event.data.state ?? item.state, - time: { created: item.time?.created ?? event.created, completed: event.created }, - }), - ) - case "session.tool.input.started": - return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ - ...item, - content: item.content.some((content) => content.type === "tool" && content.id === event.data.callID) - ? item.content - : [ - ...item.content, - { - type: "tool", - id: event.data.callID, - name: event.data.name, - state: { status: "streaming", input: "" }, - time: { created: event.created }, - }, - ], - })) - case "session.tool.input.delta": - return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => - tool.state.status === "streaming" - ? { ...tool, state: { ...tool.state, input: tool.state.input + event.data.delta } } - : tool, - ) - case "session.tool.input.ended": - return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => - tool.state.status === "streaming" ? { ...tool, state: { ...tool.state, input: event.data.text } } : tool, - ) - case "session.tool.called": - return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => ({ - ...tool, - executed: event.data.executed, - providerState: event.data.state, - // structured: {}, content: [] - state: { status: "running", input: event.data.input, metadata: {} }, - time: { ...tool.time, ran: event.created }, - })) - case "session.tool.progress": - return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => - tool.state.status === "running" - ? { - ...tool, - // state: { ...tool.state, structured: event.data.structured, content: event.data.content }, - state: { ...tool.state, metadata: event.data.metadata }, - } - : tool, - ) - case "session.tool.success": - return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => { - if (tool.state.status !== "running") return tool - return { - ...tool, - executed: event.data.executed || tool.executed === true, - providerResultState: event.data.resultState, - state: { - status: "completed", - input: tool.state.input, - // structured: event.data.structured, - metadata: event.data.metadata, - content: event.data.content, - // result: event.data.result, - }, - time: { ...tool.time, completed: event.created }, - } - }) - case "session.tool.failed": - return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => { - if (tool.state.status !== "streaming" && tool.state.status !== "running") return tool - return { - ...tool, - executed: event.data.executed || tool.executed === true, - providerResultState: event.data.resultState, - state: { - status: "error", - input: typeof tool.state.input === "string" ? {} : tool.state.input, - // structured: tool.state.status === "running" ? tool.state.structured : {}, - metadata: event.data.metadata ?? (tool.state.status === "running" ? tool.state.metadata : {}), - content: event.data.content, - error: event.data.error, - // result: event.data.result, - }, - time: { ...tool.time, completed: event.created }, - } - }) - case "session.retry.scheduled": - return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ - ...item, - retry: { attempt: event.data.attempt, at: event.data.at, error: event.data.error }, - })) - case "session.execution.succeeded": - case "session.execution.failed": - case "session.execution.interrupted": { - const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed) - if (!current?.retry) return result([...source]) - return updateAssistant(source, current.id, sessionID, (item) => ({ ...item, retry: undefined })) - } - case "session.compaction.started": - return append({ - id: event.data.inputID ?? messageID(event.id), - type: "compaction", - status: "running", - metadata: event.metadata, - reason: event.data.reason, - summary: "", - recent: event.data.recent, - time: { created: event.created }, - }) - case "session.compaction.delta": - return updateMessage>( - source, - (item): item is Extract => - item.type === "compaction" && item.status === "running", - (item) => ({ - ...item, - summary: item.summary + event.data.text, - }), - sessionID, - ) - case "session.compaction.ended": { - const current = source.findLast( - (item): item is Extract => - item.type === "compaction" && item.status === "running", - ) - if (!current) - return append({ - id: messageID(event.id), - type: "compaction", - status: "completed", - metadata: event.metadata, - reason: event.data.reason, - summary: event.data.text, - recent: event.data.recent, - time: { created: event.created }, - }) - return result( - update(source, current.id, () => ({ - ...current, - status: "completed", - reason: event.data.reason, - summary: event.data.text, - recent: event.data.recent, - })), - [current.id], - ) - } - case "session.compaction.failed": { - const current = source.findLast( - (item): item is Extract => - item.type === "compaction" && item.status === "running", - ) - const failed: Extract = { - id: current?.id ?? event.data.inputID ?? messageID(event.id), - type: "compaction", - status: "failed", - metadata: current?.metadata ?? event.metadata, - reason: event.data.reason, - error: event.data.error, - time: current?.time ?? { created: event.created }, - } - if (!current) return append(failed) - return result( - update(source, current.id, () => failed), - [failed.id], - ) - } - default: - return - } - } - - return { - reduce, - clear(sessionID: string) { - for (const id of pending.keys()) { - if (id.startsWith(`${sessionID}:`)) pending.delete(id) - } - }, - } -} - -function key(sessionID: string, inputID: string) { - return `${sessionID}:${inputID}` -} - -function messageID(eventID: string) { - return eventID.replace(/^evt_/, "msg_") -} - -function update( - source: readonly SessionMessageInfo[], - id: string, - apply: (item: SessionMessageInfo) => SessionMessageInfo, -) { - return source.map((item) => (item.id === id ? apply(item) : item)) -} - -function updateMessage( - source: readonly SessionMessageInfo[], - matches: (item: SessionMessageInfo) => item is T, - apply: (item: T) => T, - sessionID: string, -): V2SessionReduction { - const current = source.findLast(matches) - if (!current) return { sessionID, messages: [...source], touched: [] } - return { - sessionID, - messages: update(source, current.id, (item) => (matches(item) ? apply(item) : item)), - touched: [current.id], - } -} - -function updateAssistant( - source: readonly SessionMessageInfo[], - id: string, - sessionID: string, - apply: (item: Assistant) => Assistant, -): V2SessionReduction { - return { - sessionID, - messages: update(source, id, (item) => (item.type === "assistant" ? apply(item) : item)), - touched: source.some((item) => item.id === id && item.type === "assistant") ? [id] : [], - } -} - -function updateContent( - source: readonly SessionMessageInfo[], - messageID: string, - sessionID: string, - type: T, - ordinal: number, - apply: ( - item: Extract, - ) => Extract, -) { - return updateAssistant(source, messageID, sessionID, (assistant) => { - let index = -1 - return { - ...assistant, - content: assistant.content.map((item) => { - if (item.type !== type || ++index !== ordinal) return item - return apply(item as Extract) - }), - } - }) -} - -function updateTool( - source: readonly SessionMessageInfo[], - messageID: string, - callID: string, - sessionID: string, - apply: ( - item: Extract, - ) => Extract, -) { - return updateAssistant(source, messageID, sessionID, (assistant) => ({ - ...assistant, - content: assistant.content.map((item) => (item.type === "tool" && item.id === callID ? apply(item) : item)), - })) -} - -function insertOrdinal( - source: Assistant["content"], - type: T, - ordinal: number, - item: Extract, -) { - const matches = source.filter((content) => content.type === type) - if (matches[ordinal]) return source - return [...source, item] -} diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index e9595e1376..01f5174789 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -1,11 +1,7 @@ import { describe, expect, test } from "bun:test" import type { retry } from "@opencode-ai/core/util/retry" -import type { OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise" import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client" import { createServerSession } from "./server-session" -import type { ServerApi } from "@/utils/server" - -type MessageApi = ServerApi["message"] const session = (id: string, parentID?: string): Session => ({ id, @@ -19,13 +15,11 @@ const session = (id: string, parentID?: string): Session => ({ }) type UserMessage = Extract -type AssistantMessage = Extract type TextPart = Extract type MessageResponse = { data: { info: Message; parts: Part[] }[] response: { headers: Headers } } -type SingleMessageResponse = { data: MessageResponse["data"][number] } const userMessage = (id: string, input: Partial = {}): UserMessage => ({ id, @@ -37,22 +31,6 @@ const userMessage = (id: string, input: Partial = {}): UserMessage ...input, }) -const assistantMessage = (id: string, parentID: string, input: Partial = {}): AssistantMessage => ({ - id, - sessionID: "child", - role: "assistant", - time: { created: Number(id.at(-1)), completed: Number(id.at(-1)) }, - parentID, - modelID: "model", - providerID: "provider", - mode: "build", - agent: "build", - path: { cwd: "/repo", root: "/repo" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - ...input, -}) - const textPart = (messageID: string, input: Partial = {}): TextPart => ({ id: "part", sessionID: "child", @@ -67,8 +45,6 @@ const response = (data: MessageResponse["data"] = [], cursor?: string): MessageR response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) }, }) -const singleResponse = (info: Message, parts: Part[] = []): SingleMessageResponse => ({ data: { info, parts } }) - const deferredResponse = () => Promise.withResolvers() function messageClient(...responses: Array>) { @@ -95,40 +71,6 @@ function messageClient(...responses: Array>, - roots: Array>, -) { - let pageIndex = 0 - let rootIndex = 0 - const requests: unknown[] = [] - const rootRequests: unknown[] = [] - const rootWaiting = new Map void>() - const client = { - session: { - get: async () => ({ data: session("child", "root") }), - messages: (input: unknown) => { - requests.push(input) - return pages[pageIndex++] - }, - message: (input: unknown) => { - rootRequests.push(input) - rootWaiting.get(rootRequests.length)?.() - rootWaiting.delete(rootRequests.length) - return roots[rootIndex++] - }, - }, - } as unknown as OpencodeClient - return Object.assign(client, { - requests, - rootRequests, - rootRequested(count: number) { - if (rootRequests.length >= count) return Promise.resolve() - return new Promise((resolve) => rootWaiting.set(count, resolve)) - }, - }) -} - const retryImmediately: typeof retry = async (task, options = {}) => { const attempts = options.attempts ?? 3 for (let attempt = 0; ; attempt++) { @@ -162,57 +104,6 @@ function setup(sessions: Record) { } describe("server session", () => { - test("projects V2 session events into current and legacy message state", () => { - const ctx = setup({ child: session("child") }) - ctx.store.remember(session("child")) - ctx.store.set("session_message", "child", [ - { - id: "msg_1_user", - type: "user", - text: "hello", - time: { created: 1 }, - }, - ]) - const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent) - - apply({ - id: "evt_step", - created: 2, - type: "session.step.started", - durable: { aggregateID: "child", seq: 1, version: 1 }, - location: { directory: "/repo" }, - data: { - sessionID: "child", - assistantMessageID: "msg_2_assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - }, - }) - apply({ - id: "evt_text_start", - created: 3, - type: "session.text.started", - durable: { aggregateID: "child", seq: 2, version: 1 }, - location: { directory: "/repo" }, - data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0 }, - }) - apply({ - id: "evt_text_delta", - created: 4, - type: "session.text.delta", - location: { directory: "/repo" }, - data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" }, - }) - - expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({ - id: "msg_2_assistant", - type: "assistant", - content: [{ type: "text", text: "world" }], - }) - expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"]) - expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }]) - }) - test("resolves lineage by session ID without directory", async () => { const ctx = setup({ child: session("child", "root"), root: session("root") }) @@ -229,395 +120,10 @@ describe("server session", () => { await ctx.store.sync("root") expect(ctx.get).toEqual([{ sessionID: "root" }]) - expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, before: undefined }]) + expect(ctx.messages).toEqual([{ sessionID: "root", limit: 2, before: undefined }]) expect(ctx.store.data.message.root).toEqual([]) }) - test("loads current session content through the current message API", async () => { - const requests: unknown[] = [] - const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } } - const assistant = { - id: "msg_a_assistant", - type: "assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [{ type: "text", text: "hi" }], - time: { created: 2, completed: 3 }, - } - const client = { - session: { - messages: () => { - throw new Error("legacy message endpoint called") - }, - }, - } as unknown as OpencodeClient - const messageApi = { - list: async (input: unknown) => { - requests.push(input) - return { data: [assistant, user], cursor: { previous: null, next: null } } - }, - } as unknown as MessageApi - const store = createServerSession(client, {} as SessionApi, messageApi) - store.remember(session("root")) - - await store.sync("root") - - expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }]) - expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) - }) - - test("extends a current page to include the user for split assistant turns", async () => { - const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const - const assistant = (id: string, created: number) => ({ - id, - type: "assistant" as const, - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [{ type: "text" as const, text: id }], - time: { created, completed: created }, - }) - const assistants = [ - assistant("msg_2_assistant", 2), - assistant("msg_3_assistant", 3), - assistant("msg_4_assistant", 4), - ] - const pages = [ - { data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } }, - { data: [assistants[0], user], cursor: { previous: null, next: null } }, - ] - const requests: unknown[] = [] - const messageApi = { - list: async (input: unknown) => { - requests.push(input) - return pages.shift()! - }, - } as unknown as MessageApi - const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi) - store.remember(session("root")) - - await store.sync("root") - - expect(requests).toEqual([ - { sessionID: "root", limit: 20, order: "desc" }, - { sessionID: "root", limit: 20, cursor: "older" }, - ]) - expect(store.data.message.root.map((message) => message.id)).toEqual([ - user.id, - ...assistants.map((item) => item.id), - ]) - expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"]) - }) - - test("indexes V1 messages for the current timeline projection", async () => { - const user = userMessage("message-1", { sessionID: "root" }) - const assistant = assistantMessage("message-2", user.id, { sessionID: "root" }) - const client = messageClient( - response([ - { info: user, parts: [textPart(user.id, { sessionID: "root" })] }, - { info: assistant, parts: [textPart(assistant.id, { sessionID: "root" })] }, - ]), - ) - const messageApi = { - list: () => { - throw new Error("current message endpoint called") - }, - } as unknown as MessageApi - const store = createServerSession(client, {} as SessionApi, messageApi, { - protocol: Promise.resolve("v1"), - }) - store.remember(session("root")) - - await store.sync("root") - - expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) - expect(store.data.session_message.root).toMatchObject([ - { id: user.id, type: "user", text: "text" }, - { id: assistant.id, type: "assistant" }, - ]) - - const next = userMessage("message-3", { sessionID: "root" }) - store.apply({ type: "message.updated", properties: { info: next } }) - expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id, next.id]) - - store.apply({ type: "message.removed", properties: { sessionID: "root", messageID: next.id } }) - expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) - }) - - test("backfills an assistant-only initial page through its user root", async () => { - const user = userMessage("message-1") - const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] - const client = rootMessageClient( - [ - response( - assistants.map((info) => ({ info, parts: [] })), - "older", - ), - ], - [singleResponse(user)], - ) - const store = createServerSession(client) - - await store.sync("child") - - expect(client.requests).toEqual([{ sessionID: "child", limit: 20, before: undefined }]) - expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }]) - expect(store.data.message.child).toEqual([user, ...assistants]) - expect(store.history.more("child")).toBe(true) - }) - - test("keeps assistant history when its deleted parent cannot be backfilled", async () => { - const missing = Promise.withResolvers() - const assistant = assistantMessage("message-2", "message-missing") - const client = rootMessageClient([response([{ info: assistant, parts: [] }], "older")], [missing.promise]) - const store = createServerSession(client) - const loading = store.sync("child") - await client.rootRequested(1) - - missing.reject(new Error("Message not found: message-missing", { cause: { status: 404 } })) - await loading - - expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }]) - expect(store.data.message.child).toEqual([assistant]) - expect(store.history.more("child")).toBe(true) - }) - - test("drops a cached parent when a forced refresh confirms it was deleted", async () => { - const missing = Promise.withResolvers() - const parent = userMessage("message-1") - const part = textPart(parent.id) - const assistant = assistantMessage("message-2", parent.id) - const client = rootMessageClient( - [ - response([ - { info: parent, parts: [part] }, - { info: assistant, parts: [] }, - ]), - response([{ info: assistant, parts: [] }], "older"), - ], - [missing.promise], - ) - const store = createServerSession(client) - await store.sync("child") - const loading = store.sync("child", { force: true }) - await client.rootRequested(1) - - missing.reject(new Error(`Message not found: ${parent.id}`, { cause: { status: 404 } })) - await loading - - expect(store.data.message.child).toEqual([assistant]) - expect(store.data.part[parent.id]).toBeUndefined() - }) - - test("does not let an optimistic user suppress initial root backfill", async () => { - const user = userMessage("message-1") - const part = textPart(user.id) - const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] - const client = rootMessageClient( - [ - response( - assistants.map((info) => ({ info, parts: [] })), - "older", - ), - ], - [singleResponse(user)], - ) - const store = createServerSession(client) - store.optimistic.add({ sessionID: "child", message: user, parts: [part] }) - - await store.sync("child") - store.optimistic.remove({ sessionID: "child", messageID: user.id }) - - expect(client.requests).toHaveLength(1) - expect(client.rootRequests).toHaveLength(1) - expect(store.data.message.child).toEqual([user, ...assistants]) - }) - - test("backfills the parent of fetched assistants when another user is cached", async () => { - const unrelated = userMessage("message-0", { time: { created: 0 } }) - const user = userMessage("message-1") - const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] - const client = rootMessageClient( - [ - response([{ info: unrelated, parts: [] }]), - response( - assistants.map((info) => ({ info, parts: [] })), - "older", - ), - ], - [singleResponse(user)], - ) - const store = createServerSession(client) - await store.sync("child") - - await store.sync("child", { force: true }) - - expect(client.requests).toHaveLength(2) - expect(client.rootRequests).toHaveLength(1) - expect(store.data.message.child).toEqual([unrelated, user, ...assistants]) - }) - - test("preserves cached history between an injected parent and the page boundary", async () => { - const user = userMessage("message-1") - const cached = userMessage("message-3", { time: { created: 3 } }) - const assistant = assistantMessage("message-4", user.id) - const client = rootMessageClient( - [response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }], "older")], - [singleResponse(user)], - ) - const store = createServerSession(client) - await store.sync("child") - - await store.sync("child", { force: true }) - - expect(store.data.message.child).toEqual([user, cached, assistant]) - }) - - test("refreshes a cached parent omitted by an assistant-only replacement page", async () => { - const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } }) - const fresh = { ...stale, summary: { title: "fresh", diffs: [] } } - const stalePart = textPart(stale.id, { text: "stale" }) - const freshPart = { ...stalePart, text: "fresh" } - const assistant = assistantMessage("message-2", stale.id) - const client = rootMessageClient( - [response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }], "older")], - [singleResponse(fresh, [freshPart])], - ) - const store = createServerSession(client) - await store.sync("child") - - await store.sync("child", { force: true }) - - expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }]) - expect(store.data.message.child).toEqual([fresh, assistant]) - expect(store.data.part[stale.id]).toEqual([freshPart]) - }) - - test("refreshes a confirmed optimistic parent while preserving pending parts", async () => { - const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } }) - const fresh = { ...stale, summary: { title: "fresh", diffs: [] } } - const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" }) - const refreshed = { ...confirmed, text: "fresh" } - const pending = textPart(stale.id, { id: "pending", text: "pending" }) - const assistant = assistantMessage("message-2", stale.id) - const client = rootMessageClient( - [response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }], "older")], - [singleResponse(fresh, [refreshed])], - ) - const store = createServerSession(client) - store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] }) - await store.sync("child") - - await store.sync("child", { force: true }) - - expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }]) - expect(store.data.message.child).toEqual([fresh, assistant]) - expect(store.data.part[stale.id]).toEqual([refreshed, pending]) - }) - - test("uses a parent received by SSE during the replacement load", async () => { - const pending = deferredResponse() - const user = userMessage("message-1") - const assistant = assistantMessage("message-2", user.id) - const client = rootMessageClient([pending.promise], []) - const store = createServerSession(client) - const loading = store.sync("child") - - store.apply({ type: "message.updated", properties: { info: user } }) - pending.resolve(response([{ info: assistant, parts: [] }], "older")) - await loading - - expect(client.rootRequests).toEqual([]) - expect(store.data.message.child).toEqual([user, assistant]) - }) - - test("uses a successful retry over events received by a failed backfill attempt", async () => { - const failed = deferredResponse() - const user = userMessage("message-1") - const live = { ...user, agent: "stale" } - const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] - const client = rootMessageClient( - [ - response( - assistants.map((info) => ({ info, parts: [] })), - "older", - ), - ], - [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], - ) - const store = createServerSession(client, { retry: retryImmediately }) - const loading = store.sync("child") - await client.rootRequested(1) - - store.apply({ type: "message.updated", properties: { info: live } }) - failed.reject(new Error("retry")) - await loading - - expect(client.requests).toHaveLength(1) - expect(client.rootRequests).toHaveLength(2) - expect(store.data.message.child).toEqual([user, ...assistants]) - }) - - test("preserves newer-page events across a failed parent retry", async () => { - const failed = deferredResponse() - const user = userMessage("message-1") - const assistant = assistantMessage("message-2", user.id) - const live = { ...assistant, cost: 1 } - const client = rootMessageClient( - [response([{ info: assistant, parts: [] }], "older")], - [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], - ) - const store = createServerSession(client, { retry: retryImmediately }) - const loading = store.sync("child") - await client.rootRequested(1) - - store.apply({ type: "message.updated", properties: { info: live } }) - failed.reject(new Error("retry")) - await loading - - expect(store.data.message.child).toEqual([user, live]) - }) - - test("preserves unrelated message events across a failed parent retry", async () => { - const failed = deferredResponse() - const user = userMessage("message-1") - const assistant = assistantMessage("message-2", user.id) - const live = userMessage("message-4", { time: { created: 4 } }) - const client = rootMessageClient( - [response([{ info: assistant, parts: [] }], "older")], - [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], - ) - const store = createServerSession(client, { retry: retryImmediately }) - const loading = store.sync("child") - await client.rootRequested(1) - - store.apply({ type: "message.updated", properties: { info: live } }) - failed.reject(new Error("retry")) - await loading - - expect(store.data.message.child).toEqual([user, assistant, live]) - }) - - test("preserves newer-page part events across a failed parent retry", async () => { - const failed = deferredResponse() - const user = userMessage("message-1") - const assistant = assistantMessage("message-2", user.id) - const stale = textPart(assistant.id, { text: "stale" }) - const live = { ...stale, text: "live" } - const client = rootMessageClient( - [response([{ info: assistant, parts: [stale] }], "older")], - [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], - ) - const store = createServerSession(client, { retry: retryImmediately }) - const loading = store.sync("child") - await client.rootRequested(1) - - store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } }) - failed.reject(new Error("retry")) - await loading - - expect(store.data.part[assistant.id]).toEqual([live]) - }) - test("merges live events into the initial page", async () => { const pending = deferredResponse() const user = userMessage("message-1") @@ -1399,26 +905,6 @@ describe("server session", () => { expect(store.data.message.child).toEqual([latest]) }) - test("does not scan cached messages for user roots during history prepend", async () => { - const guard = { active: false } - const latest = new Proxy(userMessage("message-2", { time: { created: 2 } }), { - get(target, property, receiver) { - if (guard.active && property === "role") throw new Error("cached role accessed") - return Reflect.get(target, property, receiver) - }, - }) - const older = userMessage("message-1") - const store = createServerSession( - messageClient(response([{ info: latest, parts: [] }], "older"), response([{ info: older, parts: [] }])), - ) - await store.sync("child") - guard.active = true - - await store.history.loadMore("child") - - expect(store.data.message.child).toEqual([older, latest]) - }) - test("preserves loaded history during an incomplete refresh", async () => { const older = userMessage("message-1") const latest = userMessage("message-2", { time: { created: 2 } }) diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 69bec61ba2..9ba712ef25 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -1,6 +1,5 @@ import { Binary } from "@opencode-ai/core/util/binary" import { retry } from "@opencode-ai/core/util/retry" -import type { OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise" import type { Message, OpencodeClient, @@ -9,41 +8,23 @@ import type { QuestionRequest, Session, SessionStatus, + SnapshotFileDiff, Todo, } from "@opencode-ai/sdk/v2/client" -import type { FileDiffInfo } from "@opencode-ai/client/promise" import { batch } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" -import { message as cleanMessage } from "@/utils/diffs" -import { sessionNotFoundError } from "@/utils/server-errors" +import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs" import { rootSession } from "@/utils/session-route" -import { normalizeSessionInfo } from "@/utils/session" -import { normalizeSessionMessages } from "@/utils/session-message" import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" -import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer" -import type { ServerApi } from "@/utils/server" - -type MessageApi = ServerApi["message"] const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) -const initialMessagePageSize = 20 +const initialMessagePageSize = 2 const historyMessagePageSize = 200 const sessionInfoLimit = 2_048 const emptyIDs: ReadonlySet = new Set() -function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) { - const boundary = source.find( - (message) => - message.type === "user" || - message.type === "shell" || - message.type === "assistant" || - (message.type === "synthetic" && message.description?.trim()), - ) - return boundary?.type === "assistant" -} - type OptimisticItem = { message: Message parts: Part[] @@ -54,37 +35,10 @@ type OptimisticItem = { type MessagePage = { session: Message[] part: { id: string; part: Part[] }[] - source?: SessionMessageInfo[] - sourceMode?: "latest" | "older" - projectSource?: boolean cursor?: string complete: boolean } -function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] { - return items - .slice() - .sort((a, b) => cmp(a.info.id, b.info.id)) - .map((item) => { - if (item.info.role === "user") { - return { - id: item.info.id, - type: "user" as const, - text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), - time: item.info.time, - } - } - return { - id: item.info.id, - type: "assistant" as const, - agent: item.info.agent ?? item.info.mode, - model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant }, - content: [], - time: item.info.time, - } - }) -} - // Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries. type MessageLoadState = { touchedMessages: Set @@ -97,14 +51,8 @@ type MessageLoadState = { optimisticParts: Map> orphanParents: Set clearedMessageParts: Set - touchedSource: Set } -type MessageLoadBaseline = Pick< - MessageLoadState, - "touchedMessages" | "retainedMessages" | "touchedParts" | "clearedMessageParts" -> - function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) { if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] } const session = [...page.session] @@ -156,7 +104,6 @@ function reconcileFetched( options: { touched?: ReadonlySet retained?: ReadonlySet - removed?: ReadonlySet preserveUnfetched?: boolean | ((item: T) => boolean) } = {}, ) { @@ -179,29 +126,18 @@ function reconcileFetched( if (item) result.set(id, item) if (!item) result.delete(id) } - for (const id of options.removed ?? emptyIDs) result.delete(id) return [...result.values()].sort((a, b) => cmp(a.id, b.id)) } -type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> } - -export function createServerSession( - client: OpencodeClient, - sessionApiOrOptions?: SessionApi | ServerSessionOptions, - messageApi?: MessageApi, - currentOptions?: ServerSessionOptions, -) { - const sessionApi = messageApi ? (sessionApiOrOptions as SessionApi) : undefined - const options = messageApi ? currentOptions : (sessionApiOrOptions as ServerSessionOptions | undefined) +export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) { const [data, setData] = createStore({ info: {} as Record, session_status: {} as Record, - session_diff: {} as Record, + session_diff: {} as Record, todo: {} as Record, permission: {} as Record, question: {} as Record, message: {} as Record, - session_message: {} as Record, part: {} as Record, part_text_accum_delta: {} as Record, session_working(id: string) { @@ -210,9 +146,9 @@ export function createServerSession( }) const requests = new Map>() const inflight = new Map>() + const inflightDiff = new Map>() const inflightTodo = new Map>() const optimistic = new Map>() - const v2 = createV2SessionReducer() const messageLoads = new Map() const pendingParts = new Map>>() const orphanParts = new Map>() @@ -247,16 +183,6 @@ export function createServerSession( at: {} as Record, }) - const indexLegacyMessage = (message: Message) => { - const current = data.session_message[message.sessionID] ?? [] - if (current.some((item) => item.id === message.id)) return - setData( - "session_message", - message.sessionID, - reconcile([...current, ...legacyMessageSource([{ info: message, parts: [] }])]), - ) - } - const remember = (session: Session) => { setData("info", session.id, reconcile(session)) infoSeen.delete(session.id) @@ -266,6 +192,7 @@ export function createServerSession( ...pinned.keys(), ...requests.keys(), ...inflight.keys(), + ...inflightDiff.keys(), ...inflightTodo.keys(), ...messageLoads.keys(), ...optimistic.keys(), @@ -307,31 +234,27 @@ export function createServerSession( const pending = requests.get(sessionID) if (pending) return pending const active = generation(sessionID) - const request = sessionApi - ? sessionApi.get({ sessionID }).then(normalizeSessionInfo) - : client.session.get({ sessionID }).then((result) => { - if (!result.data) throw sessionNotFoundError(sessionID) - return result.data - }) - const resolved = request.then((result) => { - if (generations.get(sessionID) !== active) return result - return remember(result) + const request = client.session.get({ sessionID }).then((result) => { + if (!result.data) throw new Error(`Session not found: ${sessionID}`) + if (generations.get(sessionID) !== active) return result.data + return remember(result.data) }) - requests.set(sessionID, resolved) + requests.set(sessionID, request) const cleanup = () => { - if (requests.get(sessionID) === resolved) requests.delete(sessionID) + if (requests.get(sessionID) === request) requests.delete(sessionID) if ( generations.get(sessionID) === active && !data.info[sessionID] && !requests.has(sessionID) && !messageLoads.has(sessionID) && !inflight.has(sessionID) && + !inflightDiff.has(sessionID) && !inflightTodo.has(sessionID) ) generations.delete(sessionID) } - void resolved.then(cleanup, cleanup) - return resolved + void request.then(cleanup, cleanup) + return request } const peekLineage = (sessionID: string) => { @@ -423,7 +346,7 @@ export function createServerSession( load.touchedParts.set(messageID, new Set([partID])) } - const resetMessageLoad = (sessionID: string, load: MessageLoadState, baseline?: MessageLoadBaseline) => { + const resetMessageLoad = (sessionID: string, load: MessageLoadState) => { load.touchedMessages.clear() load.retainedMessages.clear() load.touchedParts.clear() @@ -456,27 +379,8 @@ export function createServerSession( parts.forEach((partID) => touched.add(partID)) load.touchedParts.set(messageID, touched) } - baseline?.touchedMessages.forEach((messageID) => load.touchedMessages.add(messageID)) - baseline?.retainedMessages.forEach((messageID) => load.retainedMessages.add(messageID)) - baseline?.clearedMessageParts.forEach((messageID) => load.clearedMessageParts.add(messageID)) - baseline?.touchedParts.forEach((parts, messageID) => { - const touched = load.touchedParts.get(messageID) ?? new Set() - parts.forEach((partID) => touched.add(partID)) - load.touchedParts.set(messageID, touched) - }) } - const messageLoadBaseline = (load: MessageLoadState, exclude: string): MessageLoadBaseline => ({ - touchedMessages: new Set([...load.touchedMessages].filter((messageID) => messageID !== exclude)), - retainedMessages: new Set([...load.retainedMessages].filter((messageID) => messageID !== exclude)), - touchedParts: new Map( - [...load.touchedParts] - .filter(([messageID]) => messageID !== exclude) - .map(([messageID, parts]) => [messageID, new Set(parts)]), - ), - clearedMessageParts: new Set([...load.clearedMessageParts].filter((messageID) => messageID !== exclude)), - }) - const evict = (sessionIDs: string[]) => { if (sessionIDs.length === 0) return const evicted = new Set(sessionIDs) @@ -488,9 +392,9 @@ export function createServerSession( clearOptimistic(sessionID) requests.delete(sessionID) inflight.delete(sessionID) + inflightDiff.delete(sessionID) inflightTodo.delete(sessionID) messageLoads.delete(sessionID) - v2.clear(sessionID) pendingParts.delete(sessionID) orphanParts.delete(sessionID) removedMessages.delete(sessionID) @@ -518,6 +422,7 @@ export function createServerSession( ...pinned.keys(), ...requests.keys(), ...inflight.keys(), + ...inflightDiff.keys(), ...inflightTodo.keys(), ...messageLoads.keys(), ...optimistic.keys(), @@ -538,34 +443,6 @@ export function createServerSession( ) const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => { - if (messageApi && (await options?.protocol) !== "v1") { - const request = (cursor?: string) => - (options?.retry ?? retry)(() => { - onAttempt?.() - return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" }) - }) - const first = await request(before) - const pages = [first] - while (pages.at(-1)?.cursor.next && needsOlderTurnRoot(pages.flatMap((page) => page.data).toReversed())) { - const response = await request(pages.at(-1)!.cursor.next ?? undefined) - pages.push(response) - if (!response.data.length) break - } - const response = pages.at(-1)! - const source = pages.flatMap((page) => page.data).toReversed() - const normalized = normalizeSessionMessages(sessionID, source) - return { - session: normalized.messages.sort((a, b) => cmp(a.id, b.id)), - part: [...normalized.parts.entries()] - .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) })) - .sort((a, b) => cmp(a.id, b.id)), - source, - sourceMode: before ? ("older" as const) : ("latest" as const), - projectSource: true, - cursor: response.cursor.next ?? undefined, - complete: response.data.length === 0, - } - } const response = await (options?.retry ?? retry)(() => { onAttempt?.() return client.session.messages({ sessionID, limit, before }) @@ -577,35 +454,11 @@ export function createServerSession( id: item.info.id, part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), })), - source: legacyMessageSource(items), - sourceMode: before ? ("older" as const) : ("latest" as const), cursor: response.response.headers.get("x-next-cursor") ?? undefined, complete: !response.response.headers.get("x-next-cursor"), } } - const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => { - if (sessionApi && (await options?.protocol) !== "v1") { - const response = await (options?.retry ?? retry)(() => { - onAttempt?.() - return sessionApi.message({ sessionID, messageID }) - }) - const normalized = normalizeSessionMessages(sessionID, [response]) - const message = normalized.messages[0] - if (!message) throw new Error(`Message not found: ${messageID}`) - return { message, parts: normalized.parts.get(messageID) ?? [] } - } - const response = await (options?.retry ?? retry)(() => { - onAttempt?.() - return client.session.message({ sessionID, messageID }) - }) - if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`) - return { - message: cleanMessage(response.data.info), - parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), - } - } - const replaceMessages = (sessionID: string, messages: Message[]) => { const messageIDs = new Set(messages.map((message) => message.id)) const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id)) @@ -679,31 +532,7 @@ export function createServerSession( preserveUnfetched: boolean | ((message: Message) => boolean), cleanupOrphans: boolean, ) => { - const source = page.source - ? (() => { - const incoming = new Map(page.source.map((message) => [message.id, message])) - const existing = data.session_message[sessionID] ?? [] - const current = existing.filter((message) => !incoming.has(message.id)) - const live = new Map(existing.map((message) => [message.id, message])) - return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map( - (message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message), - ) - })() - : undefined - const projected = - page.projectSource && source - ? (() => { - const normalized = normalizeSessionMessages(sessionID, source) - return { - ...page, - session: normalized.messages.sort((a, b) => cmp(a.id, b.id)), - part: [...normalized.parts.entries()] - .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) })) - .sort((a, b) => cmp(a.id, b.id)), - } - })() - : page - const merged = mergeOptimisticPage(projected, [...(optimistic.get(sessionID)?.values() ?? [])]) + const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])]) merged.observed.forEach((item) => { if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts) }) @@ -711,11 +540,9 @@ export function createServerSession( const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], { touched: touchedMessages, retained: load?.retainedMessages, - removed: load?.removedMessages, preserveUnfetched, }) batch(() => { - if (source) setData("session_message", sessionID, reconcile(source)) const messageIDs = replaceMessages(sessionID, messages) replaceParts(sessionID, merged.part, messageIDs, load) const orphans = orphanParts.get(sessionID) @@ -746,92 +573,40 @@ export function createServerSession( optimisticParts: new Map(), orphanParents: new Set(), clearedMessageParts: new Set(), - touchedSource: new Set(), } messageLoads.set(sessionID, load) setMeta("loading", sessionID, true) let applied = false - try { - const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load)) - const first = page.session.reduce( - (oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest), - undefined, - ) - if (generations.get(sessionID) !== active) return - - const parents = [] as Awaited>[] - if (mode !== "prepend") { - const users = new Set([ - ...page.session.filter((message) => message.role === "user").map((message) => message.id), - ...(data.message[sessionID] ?? []) - .filter((message) => { - if (message.role !== "user") return false - const item = optimistic.get(sessionID)?.get(message.id) - return load.touchedMessages.has(message.id) && (!item || item.confirmedMessage === true) - }) - .map((message) => message.id), - ]) - const parentIDs = [ - ...new Set( - page.session.flatMap((message) => - message.role === "assistant" && !users.has(message.parentID) ? [message.parentID] : [], - ), - ), - ] - for (const parentID of parentIDs) { - if (generations.get(sessionID) !== active) break - const parent = await fetchMessage(sessionID, parentID, () => - resetMessageLoad(sessionID, load, messageLoadBaseline(load, parentID)), - ).catch((error) => { - const cause = error instanceof Error && typeof error.cause === "object" ? error.cause : undefined - if (cause && "status" in cause && cause.status === 404) { - load.removedMessages.add(parentID) - return - } - throw error - }) - if (!parent) continue - if (parent.message.role !== "user") throw new Error(`Assistant parent is not a user message: ${parentID}`) - parents.push(parent) + await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load)) + .then((page) => { + if (generations.get(sessionID) !== active) return + const first = page.session.reduce( + (oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest), + undefined, + ) + const preserveUnfetched = + mode === "prepend" || (!page.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0))) + applyMessagePage( + sessionID, + page, + messageLoads.get(sessionID) === load ? load : undefined, + preserveUnfetched, + mode !== "prepend", + ) + applied = true + }) + .finally(() => { + if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) { + for (const messageID of load.orphanParents) { + if (!orphanParts.get(sessionID)?.has(messageID)) continue + setData(produce((draft) => deleteMessageParts(draft, messageID))) + orphanParts.get(sessionID)?.delete(messageID) + } + if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID) } - } - if (generations.get(sessionID) !== active) return - const result = - mode === "prepend" - ? page - : { - ...page, - session: merge( - page.session, - parents.map((parent) => parent.message), - ), - part: merge( - page.part, - parents.map((parent) => ({ id: parent.message.id, part: parent.parts })), - ), - } - const preserveUnfetched = - mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0))) - applyMessagePage( - sessionID, - result, - messageLoads.get(sessionID) === load ? load : undefined, - preserveUnfetched, - mode !== "prepend", - ) - applied = true - } finally { - if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) { - for (const messageID of load.orphanParents) { - if (!orphanParts.get(sessionID)?.has(messageID)) continue - setData(produce((draft) => deleteMessageParts(draft, messageID))) - orphanParts.get(sessionID)?.delete(messageID) - } - if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID) - } - if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID) - if (generations.get(sessionID) === active) setMeta("loading", sessionID, false) - } + if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID) + if (generations.get(sessionID) === active) setMeta("loading", sessionID, false) + }) } const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => { @@ -881,109 +656,6 @@ export function createServerSession( return properties.part.sessionID } - const projectV2 = (reduction: V2SessionReduction) => { - reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID)) - setData("session_message", reduction.sessionID, reconcile(reduction.messages)) - if (reduction.touched.length === 0) return - - const touched = new Set(reduction.touched) - let parentID: string | undefined - for (const message of reduction.messages) { - if (message.type === "user" || (message.type === "synthetic" && message.description?.trim())) - parentID = message.id - if (message.type === "shell") { - if (touched.has(message.id)) touched.add(`${message.id}:assistant`) - parentID = undefined - } - if (message.type === "assistant" && touched.has(message.id) && parentID) touched.add(parentID) - if (message.type === "compaction" && touched.has(message.id) && parentID) touched.add(parentID) - } - - const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages) - batch(() => { - for (const message of normalized.messages) { - if (!touched.has(message.id)) continue - apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } }) - } - for (const messageID of touched) { - const next = normalized.parts.get(messageID) ?? [] - const nextIDs = new Set(next.map((part) => part.id)) - for (const part of next) { - apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } }) - } - for (const part of data.part[messageID] ?? []) { - if (nextIDs.has(part.id)) continue - apply({ - type: "message.part.removed", - properties: { sessionID: reduction.sessionID, messageID, partID: part.id }, - }) - } - } - }) - } - - const hydrateV2Message = (sessionID: string, messageID: string) => { - if (!sessionApi) return - void sessionApi - .message({ sessionID, messageID }) - .then((message) => { - const current = data.session_message[sessionID] ?? [] - const messages = [...current.filter((item) => item.id !== message.id), message].sort((a, b) => cmp(a.id, b.id)) - projectV2({ sessionID, messages, touched: [message.id] }) - }) - .catch(() => {}) - } - - const applyV2 = (event: OpenCodeEvent) => { - if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return - const sessionID = event.data.sessionID - const reduction = v2.reduce(data.session_message[sessionID] ?? [], event) - if (reduction) { - projectV2(reduction) - if (reduction.missing) hydrateV2Message(sessionID, reduction.missing) - } - - const info = data.info[sessionID] - if (event.type === "session.renamed" && info) - remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } }) - if (event.type === "session.moved" && info) - remember({ - ...info, - projectID: event.data.projectID ?? info.projectID, - workspaceID: event.data.location.workspaceID, - directory: event.data.location.directory, - path: event.data.subpath, - time: { ...info.time, updated: event.created }, - }) - if (event.type === "session.usage.updated" && info) - remember({ ...info, cost: event.data.cost, tokens: event.data.tokens }) - // if (event.type === "session.archived") { - // if (info) remember({ ...info, time: { ...info.time, archived: event.created, updated: event.created } }) - // evict([sessionID]) - // } - if (event.type === "session.execution.started") setData("session_status", sessionID, { type: "busy" }) - if ( - event.type === "session.execution.succeeded" || - event.type === "session.execution.failed" || - event.type === "session.execution.interrupted" - ) - setData("session_status", sessionID, { type: "idle" }) - if (event.type === "session.retry.scheduled") - setData("session_status", sessionID, { - type: "retry", - attempt: event.data.attempt, - message: event.data.error.message, - next: event.data.at, - }) - if (event.type === "session.forked") void resolve(sessionID, { force: true }).catch(() => {}) - if ( - event.type === "session.revert.staged" || - event.type === "session.revert.cleared" || - event.type === "session.revert.committed" - ) - void resolve(sessionID, { force: true }).catch(() => {}) - } - const apply = (event: { type: string; properties?: unknown }) => { const eventID = eventSessionID(event) if (eventID) { @@ -1007,9 +679,7 @@ export function createServerSession( return } case "session.deleted": { - const properties = event.properties as { sessionID?: string; info?: Session } - const sessionID = properties.info?.id ?? properties.sessionID - if (!sessionID) return + const sessionID = (event.properties as { info: Session }).info.id infoSeen.delete(sessionID) setData( "info", @@ -1018,6 +688,11 @@ export function createServerSession( evict([sessionID]) return } + case "session.diff": { + const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } + setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" })) + return + } case "todo.updated": { const props = event.properties as { sessionID: string; todos: Todo[] } setData("todo", props.sessionID, reconcile(props.todos, { key: "id" })) @@ -1030,7 +705,6 @@ export function createServerSession( } case "message.updated": { const info = cleanMessage((event.properties as { info: Message }).info) - indexLegacyMessage(info) const load = messageLoads.get(info.sessionID) load?.touchedMessages.add(info.id) load?.removedMessages.delete(info.id) @@ -1063,9 +737,6 @@ export function createServerSession( } case "message.removed": { const props = event.properties as { sessionID: string; messageID: string } - setData("session_message", props.sessionID, (messages) => - messages?.filter((message) => message.id !== props.messageID), - ) const load = messageLoads.get(props.sessionID) load?.touchedMessages.add(props.messageID) load?.removedMessages.add(props.messageID) @@ -1378,16 +1049,23 @@ export function createServerSession( setData(produce((draft) => deleteMessageParts(draft, input.messageID))) }, }, - async todo(sessionID: string, request?: { force?: boolean }) { + diff(sessionID: string, options?: { force?: boolean }) { touch(sessionID) - if (data.todo[sessionID] !== undefined && !request?.force) return - if ((await options?.protocol) === "v2") { - setData("todo", sessionID, []) - return - } + if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve() + return runInflight(inflightDiff, sessionID, () => { + const active = generation(sessionID) + return retry(() => client.session.diff({ sessionID })).then((result) => { + if (generations.get(sessionID) !== active) return + setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" })) + }) + }) + }, + todo(sessionID: string, options?: { force?: boolean }) { + touch(sessionID) + if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve() return runInflight(inflightTodo, sessionID, () => { const active = generation(sessionID) - return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => { + return retry(() => client.session.todo({ sessionID })).then((result) => { if (generations.get(sessionID) !== active) return setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" })) }) @@ -1421,7 +1099,6 @@ export function createServerSession( if (count && count > 1) pinned.set(sessionID, count - 1) }, apply, - applyV2, } } diff --git a/packages/app/src/context/server-sync.test.ts b/packages/app/src/context/server-sync.test.ts index 7821a09e20..93e9c41755 100644 --- a/packages/app/src/context/server-sync.test.ts +++ b/packages/app/src/context/server-sync.test.ts @@ -1,106 +1,6 @@ import { describe, expect, test } from "bun:test" -import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" -import type { - McpListInput, - McpResourceCatalogInput, - SessionApi, - SessionInfo, - SessionListInput, -} from "@opencode-ai/client/promise" -import { QueryClient } from "@tanstack/solid-query" import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction" -import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load" -import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync" -import { ServerScope } from "@/utils/server-scope" -import { createServerSession } from "./server-session" -import type { ServerApi } from "@/utils/server" - -type McpApi = ServerApi["mcp"] - -describe("MCP queries", () => { - test("loads current servers for the requested location", async () => { - const calls: unknown[] = [] - const queryClient = new QueryClient() - const result = await queryClient.fetchQuery( - loadMcpQuery(ServerScope.local, "/project", { - list: async (input: McpListInput = {}) => { - calls.push(input) - return { - location: { directory: "/project", project: { id: "project", directory: "/project" } }, - data: [ - { name: "docs", status: { status: "connected" } }, - { name: "search", status: { status: "pending" } }, - ], - } - }, - } as unknown as McpApi), - ) - - expect(calls).toEqual([{ location: { directory: "/project" } }]) - expect(result).toEqual({ docs: { status: "connected" }, search: { status: "pending" } }) - }) - - test("loads and keys the current resource catalog", async () => { - const calls: unknown[] = [] - const queryClient = new QueryClient() - const result = await queryClient.fetchQuery( - loadMcpResourcesQuery(ServerScope.local, "/project", { - resource: { - catalog: async (input: McpResourceCatalogInput = {}) => { - calls.push(input) - return { - location: { directory: "/project", project: { id: "project", directory: "/project" } }, - data: { - resources: [{ server: "docs", name: "Guide", uri: "docs://guide" }], - templates: [], - }, - } - }, - }, - } as unknown as McpApi), - ) - - expect(calls).toEqual([{ location: { directory: "/project" } }]) - expect(result).toEqual({ "docs:docs://guide": { server: "docs", name: "Guide", uri: "docs://guide" } }) - }) -}) - -describe("active session query", () => { - test("loads active sessions immediately and once per server cache", async () => { - let calls = 0 - const queryClient = new QueryClient() - const options = loadActiveSessionsQuery(ServerScope.local, { - active: async () => { - calls++ - return { ses_running: { type: "running" } } - }, - }) - - expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } }) - expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } }) - expect(calls).toBe(1) - expect(options.enabled).toBe(true) - expect([...options.queryKey]).toEqual([ServerScope.local, "activeSessions"]) - }) - - test("does not overwrite statuses already written by events", () => { - const session = createServerSession({} as OpencodeClient) - session.set("session_status", "ses_retry", { type: "retry", attempt: 2, message: "retrying", next: 10 }) - - seedActiveSessionStatuses(session, { - ses_running: { type: "running" }, - ses_retry: { type: "running" }, - }) - - expect(session.data.session_status.ses_running).toEqual({ type: "busy" }) - expect(session.data.session_status.ses_retry).toEqual({ - type: "retry", - attempt: 2, - message: "retrying", - next: 10, - }) - }) -}) +import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" describe("pickDirectoriesToEvict", () => { test("keeps pinned stores and evicts idle stores", () => { @@ -123,57 +23,46 @@ describe("pickDirectoriesToEvict", () => { }) }) -describe("loadRootSessions", () => { - test("loads and normalizes a limited page of root sessions", async () => { - const calls: SessionListInput[] = [] +describe("loadRootSessionsWithFallback", () => { + test("uses limited roots query when supported", async () => { + const calls: Array<{ directory: string; roots: true; limit?: number }> = [] - const result = await loadRootSessions({ - api: { - list: async (query = {}) => { - calls.push(query) - return { data: [sessionInfo("session-1")], cursor: {} } - }, - } satisfies Pick, + const result = await loadRootSessionsWithFallback({ directory: "dir", limit: 10, + list: async (query) => { + calls.push(query) + return { data: [] } + }, }) - expect(result.data).toEqual([ - expect.objectContaining({ id: "session-1", directory: "dir", slug: "session-1", version: "" }), - ]) + expect(result.data).toEqual([]) expect(result.limited).toBe(true) - expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }]) + expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }]) }) - test("propagates list failures", () => { - expect( - loadRootSessions({ - api: { - list: async () => { - throw new Error("failed") - }, - } satisfies Pick, - directory: "dir", - limit: 25, - }), - ).rejects.toThrow("failed") + test("falls back to full roots query on limited-query failure", async () => { + const calls: Array<{ directory: string; roots: true; limit?: number }> = [] + + const result = await loadRootSessionsWithFallback({ + directory: "dir", + limit: 25, + list: async (query) => { + calls.push(query) + if (query.limit) throw new Error("unsupported") + return { data: [] } + }, + }) + + expect(result.data).toEqual([]) + expect(result.limited).toBe(false) + expect(calls).toEqual([ + { directory: "dir", roots: true, limit: 25 }, + { directory: "dir", roots: true }, + ]) }) }) -function sessionInfo(id: string) { - return { - id, - projectID: "project-1", - agent: "build", - model: { id: "model-1", providerID: "provider-1" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: 1 }, - title: id, - location: { directory: "dir" }, - } as SessionInfo -} - describe("estimateRootSessionTotal", () => { test("keeps exact total for full fetches", () => { expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 13a0b74bc6..9b337aa076 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -1,10 +1,10 @@ import type { Config, + McpResource, OpencodeClient, Path, Project, ProviderAuthResponse, - SessionStatus, } from "@opencode-ai/sdk/v2/client" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" @@ -18,7 +18,6 @@ import { bootstrapGlobal, clearProviderRev, loadAgentsQuery, - loadCommands, loadGlobalConfigQuery, loadPathQuery, loadProjectsQuery, @@ -27,13 +26,12 @@ import { } from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" -import { estimateRootSessionTotal, loadRootSessions, loadRootSessionsV1 } from "./global-sync/session-load" +import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { formatServerError } from "@/utils/server-errors" import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" -import type { SolidQueryOptions } from "@tanstack/solid-query" import { createRefreshQueue } from "./global-sync/queue" import { directoryKey } from "./global-sync/utils" import { PathKey } from "@/utils/path-key" @@ -45,20 +43,9 @@ import { useGlobal } from "./global" import { ServerConnection, useServer } from "./server" import { retry } from "@opencode-ai/core/util/retry" import type { ServerScope } from "@/utils/server-scope" -import { createHomeSessionIndexCache } from "./global-sync/home-session-index" import { persisted } from "@/utils/persist" -import type { ServerApi } from "@/utils/server" -import type { - McpListInput, - McpListOutput, - McpResource, - McpResourceCatalogInput, - McpResourceCatalogOutput, - McpServer, - SessionActiveOutput, -} from "@opencode-ai/client/promise" import { toggleMcp } from "./global-sync/mcp" -import { createServerSession, type ServerSession } from "./server-session" +import { createServerSession } from "./server-session" type GlobalStore = { ready: boolean @@ -71,76 +58,16 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } -type McpListApi = { - readonly list: (input?: McpListInput) => Promise -} - -type McpResourceApi = { - readonly resource: { - readonly catalog: (input?: McpResourceCatalogInput) => Promise - } -} - -type ApiQueryOptions = SolidQueryOptions & { - initialData?: undefined - queryKey: K -} - -type SessionActiveApi = { - readonly active: () => Promise -} - -export const loadMcpQuery = ( - scope: ServerScope, - directory: string, - api: McpListApi, - legacy?: OpencodeClient, - protocol?: Promise<"v1" | "v2">, -): ApiQueryOptions, readonly [ServerScope, string, "mcp"]> => - queryOptions< - Record, - Error, - Record, - readonly [ServerScope, string, "mcp"] - >({ +export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => + queryOptions({ queryKey: [scope, directory, "mcp"] as const, - queryFn: async () => { - if ((await protocol) === "v1" && legacy) return (await legacy.mcp.status()).data ?? {} - return api - .list({ location: { directory } }) - .then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status]))) - }, + queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}), }) -export const loadMcpResourcesQuery = ( - scope: ServerScope, - directory: string, - api: McpResourceApi, - legacy?: OpencodeClient, - protocol?: Promise<"v1" | "v2">, -): ApiQueryOptions, readonly [ServerScope, string, "mcpResources"]> => - queryOptions< - Record, - Error, - Record, - readonly [ServerScope, string, "mcpResources"] - >({ +export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => + queryOptions>({ queryKey: [scope, directory, "mcpResources"] as const, - queryFn: async () => { - if ((await protocol) === "v1" && legacy) { - return Object.fromEntries( - Object.entries((await legacy.experimental.resource.list()).data ?? {}).map(([key, resource]) => [ - key, - { ...resource, server: resource.client }, - ]), - ) - } - return api.resource - .catalog({ location: { directory } }) - .then((result) => - Object.fromEntries(result.data.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])), - ) - }, + queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}), placeholderData: {}, }) @@ -150,52 +77,22 @@ export const loadLspQuery = (scope: ServerScope, directory: string, sdk: Opencod queryFn: () => sdk.lsp.status().then((r) => r.data ?? []), }) -export const loadActiveSessionsQuery = ( - scope: ServerScope, - api: SessionActiveApi, -): ApiQueryOptions => - queryOptions({ - queryKey: [scope, "activeSessions"] as const, - queryFn: () => api.active(), - enabled: true, - staleTime: Number.POSITIVE_INFINITY, - gcTime: Number.POSITIVE_INFINITY, - refetchOnMount: false, - refetchOnReconnect: false, - refetchOnWindowFocus: false, - }) - -export function seedActiveSessionStatuses( - session: Pick, - active: SessionActiveOutput | Record, -) { - for (const sessionID of Object.keys(active)) { - if (session.data.session_status[sessionID] !== undefined) continue - const status = active[sessionID] - session.set("session_status", sessionID, status?.type === "running" ? { type: "busy" } : status) - } -} - function makeQueryOptionsApi( scope: ServerScope, serverSDK: () => OpencodeClient, - serverAPI: ServerApi, sdkFor: (dir: PathKey) => OpencodeClient, - protocol: Promise<"v1" | "v2">, ) { return { - globalConfig: () => loadGlobalConfigQuery(scope, serverSDK(), protocol), - projects: () => loadProjectsQuery(scope, serverAPI.project), + globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()), + projects: () => loadProjectsQuery(scope, serverSDK()), providers: (directory: PathKey | null) => - loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol), + loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)), path: (directory: PathKey | null) => - loadPathQuery(scope, directory, directory ? sdkFor(directory) : serverSDK(), protocol), - agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol), - references: (directory: PathKey) => - loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol), - mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol), - mcpResources: (directory: PathKey) => - loadMcpResourcesQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol), + loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)), + agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)), + references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)), + mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)), + mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)), lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)), sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }), } @@ -224,44 +121,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { return sdk } - const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, { - protocol: serverSDK.protocol, - }) - const queryOptionsApi = makeQueryOptionsApi( - serverSDK.scope, - () => serverSDK.client, - serverSDK.api, - sdkFor, - serverSDK.protocol, - ) + const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor) const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)], })) - const activeSessionsQuery = useQuery(() => - loadActiveSessionsQuery(serverSDK.scope, { - active: async () => { - if ((await serverSDK.protocol) === "v1") { - const statuses = (await serverSDK.client.session.status()).data ?? {} - seedActiveSessionStatuses(session, statuses) - for (const sessionID of Object.keys(statuses)) { - void session.resolve(sessionID).catch(() => undefined) - } - return Object.fromEntries( - Object.entries(statuses).flatMap(([sessionID, status]) => - status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]], - ), - ) - } - const active = await serverSDK.api.session.active() - seedActiveSessionStatuses(session, active) - for (const sessionID of Object.keys(active)) { - void session.resolve(sessionID).catch(() => undefined) - } - return active - }, - }), - ) const [globalStore, setGlobalStore] = createStore({ get ready() { @@ -289,11 +153,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { }) const queryClient = useQueryClient() - const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server)) - const refreshProviders = () => - queryClient.refetchQueries({ - predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers", - }) let bootedAt = 0 let bootingRoot = false @@ -322,8 +181,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { queryFn: async () => { await bootstrapGlobal({ serverSDK: serverSDK.client, - serverAPI: serverSDK.api, - protocol: serverSDK.protocol, scope: serverSDK.scope, requestFailedTitle: language.t("common.requestFailed"), translate: language.t, @@ -353,6 +210,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { bootstrapInstance, }) + const session = createServerSession(serverSDK.client) + const children = createChildStoreManager({ owner, scope: serverSDK.scope, @@ -363,15 +222,17 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { void bootstrapInstance(directory) }, onMcp: (directory, setStore) => { - void loadCommands(directory, serverSDK.api.command, sdkFor(directory), serverSDK.protocol) - .then((commands) => setStore("command", commands)) - .catch((err) => { - showToast({ - variant: "error", - title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }), - description: formatServerError(err, language.t), - }) + void retry(() => + sdkFor(directory) + .command.list() + .then((x) => setStore("command", x.data ?? [])), + ).catch((err) => { + showToast({ + variant: "error", + title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }), + description: formatServerError(err, language.t), }) + }) }, onDispose: (directory) => { const key = directoryKey(directory) @@ -416,12 +277,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { .fetchQuery({ ...queryOptionsApi.sessions(key), queryFn: () => - serverSDK.protocol - .then((protocol) => - protocol === "v1" - ? loadRootSessionsV1({ client: sdkFor(directory), directory, limit }) - : loadRootSessions({ api: serverSDK.api.session, directory, limit }), - ) + loadRootSessionsWithFallback({ + directory, + limit, + list: (query) => serverSDK.client.session.list(query), + }) .then((x) => { const nonArchived = (x.data ?? []) .filter((s) => !!s?.id) @@ -491,7 +351,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { provider: globalStore.provider, }, sdk, - api: serverSDK.api, store: child[0], setStore: child[1], vcsCache: cache, @@ -499,7 +358,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { translate: language.t, queryClient, session, - protocol: serverSDK.protocol, }) }) @@ -511,41 +369,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { return promise } - const indexSession = (info: Parameters[0]) => { - const key = directoryKey(info.directory) - const existing = children.children[key] - if (!existing) return - applyDirectoryEvent({ - event: { type: "session.created", properties: { info } }, - directory: key, - store: existing[0], - setStore: existing[1], - push: queue.push, - retainedLimit: sessionMeta.get(key)?.limit, - sessionContent: false, - permission: session.data.permission, - loadLsp() {}, - }) - } - const unsub = serverSDK.event.listen((e) => { const directory = e.name const key = directoryKey(directory) const event = e.details - const eventType: string = event.type const recent = bootingRoot || Date.now() - bootedAt < 1500 - if (event.current) session.applyV2(event.current) session.apply(event) - if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") { - homeSessions.apply(event) - } - homeSessions.refresh(event.type) - if (eventType === "integration.connection.updated") void refreshProviders() if (directory === "global") { - if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching) - void activeSessionsQuery.refetch() applyGlobalEvent({ event, project: globalStore.project, @@ -555,66 +387,33 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { }, setGlobalProject: setProjects, }) - if ( - eventType === "config.updated" || - eventType === "catalog.updated" || - eventType === "agent.updated" || - eventType === "project.directories.updated" - ) - bootstrap.refetch() - if (eventType === "server.connected" || eventType === "global.disposed") { + if (event.type === "server.connected" || event.type === "global.disposed") { if (recent) return for (const directory of Object.keys(children.children)) { - if (!children.active(directory)) continue queue.push(directory) } } return } - if (event.current?.type === "session.moved") { - const info = session.get(event.current.data.sessionID) - if (info) indexSession(info) - } - if (event.current?.type === "session.forked") - void session - .resolve(event.current.data.sessionID, { force: true }) - .then(indexSession) - .catch(() => {}) - const existing = children.children[key] if (!existing) return children.mark(key) - if ( - event.current?.type === "session.moved" || - // event.current?.type === "session.archived" || - event.current?.type === "session.forked" || - eventType === "command.updated" || - eventType === "config.updated" || - eventType === "agent.updated" - ) - queue.push(key) - if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key)) - if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key)) const [store, setStore] = existing applyDirectoryEvent({ event, directory, store, setStore, - push: (directory) => { - if (children.active(directory)) queue.push(directory) - }, + push: queue.push, retainedLimit: sessionMeta.get(key)?.limit, sessionContent: false, permission: session.data.permission, vcsCache: children.vcsCache.get(key), loadLsp: () => { - if (!children.active(key)) return void queryClient.fetchQuery(queryOptionsApi.lsp(key)) }, loadReferences: () => { - if (!children.active(key)) return void queryClient.fetchQuery(queryOptionsApi.references(key)) }, }) @@ -683,33 +482,22 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { peek: children.peek, disableMcp: children.disableMcp, queryOptions: queryOptionsApi, - refreshProviders, // bootstrap, updateConfig: updateConfigMutation.mutateAsync, project: projectApi, session, - homeSessions, mcp: { toggle: async (directory: string, name: string) => { const key = directoryKey(directory) const sdk = sdkFor(key) - const status = children.child(key, { bootstrap: false })[0].mcp[name]?.status - if (!status) return + const status = children.child(key, { bootstrap: false })[0].mcp[name].status await toggleMcp({ status, connect: async () => { - if ((await serverSDK.protocol) === "v1") { - await sdk.mcp.connect({ name }) - return - } - await serverSDK.api.mcp.connect({ server: name, location: { directory: key } }) + await sdk.mcp.connect({ name }) }, disconnect: async () => { - if ((await serverSDK.protocol) === "v1") { - await sdk.mcp.disconnect({ name }) - return - } - await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } }) + await sdk.mcp.disconnect({ name }) }, authenticate: async () => { await sdk.mcp.auth.authenticate({ name }) diff --git a/packages/app/src/context/server.test.ts b/packages/app/src/context/server.test.ts index d78880ecf1..72352fc5d5 100644 --- a/packages/app/src/context/server.test.ts +++ b/packages/app/src/context/server.test.ts @@ -99,7 +99,7 @@ describe("createServerProjects", () => { test("keeps active and explicit server buckets in one reactive store", () => { createRoot((dispose) => { const [scope] = createSignal(ServerScope.local) - const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} }) + const [store, setStore] = createStore({ projects: {}, lastProject: {} }) const active = createServerProjects({ scope, store, setStore }) const remote = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore }) @@ -115,88 +115,6 @@ describe("createServerProjects", () => { dispose() }) }) - - test("tracks recently closed projects and drops them when reopened", () => { - createRoot((dispose) => { - const [scope] = createSignal(ServerScope.local) - const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} }) - const projects = createServerProjects({ scope, store, setStore }) - - projects.open("/a") - projects.open("/b") - projects.close("/a") - expect(projects.recentlyClosed()).toEqual(["/a"]) - - projects.close("/b") - expect(projects.recentlyClosed()).toEqual(["/b", "/a"]) - - projects.open("/a") - expect(projects.recentlyClosed()).toEqual(["/b"]) - expect(projects.list()).toEqual([{ worktree: "/a", expanded: true }]) - dispose() - }) - }) - - test("remove drops a project without recording it as recently closed", () => { - createRoot((dispose) => { - const [scope] = createSignal(ServerScope.local) - const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} }) - const projects = createServerProjects({ scope, store, setStore }) - - projects.open("/repo/subdir") - projects.remove("/repo/subdir") - expect(projects.list()).toEqual([]) - expect(projects.recentlyClosed()).toEqual([]) - dispose() - }) - }) - - test("retains recently closed history beyond the visible display limit", () => { - createRoot((dispose) => { - const [scope] = createSignal(ServerScope.local) - const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} }) - const projects = createServerProjects({ scope, store, setStore }) - - // Closing 6 projects keeps all 6 in the store even though only 5 are displayed; - // this prevents display-filtered entries from evicting still-visible ones. - for (const dir of ["/1", "/2", "/3", "/4", "/5", "/6"]) { - projects.open(dir) - projects.close(dir) - } - expect(projects.recentlyClosed()).toEqual(["/6", "/5", "/4", "/3", "/2", "/1"]) - dispose() - }) - }) - - test("caps recently closed history at the store limit", () => { - createRoot((dispose) => { - const [scope] = createSignal(ServerScope.local) - const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} }) - const projects = createServerProjects({ scope, store, setStore }) - - for (let i = 1; i <= 20; i++) { - projects.open(`/p${i}`) - projects.close(`/p${i}`) - } - expect(projects.recentlyClosed()).toHaveLength(16) - expect(projects.recentlyClosed()[0]).toBe("/p20") - expect(projects.recentlyClosed().at(-1)).toBe("/p5") - dispose() - }) - }) - - test("dedupes recently closed entries by normalized path", () => { - createRoot((dispose) => { - const [scope] = createSignal(ServerScope.local) - const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} }) - const projects = createServerProjects({ scope, store, setStore }) - - projects.close("/repo") - projects.close("/repo/") - expect(projects.recentlyClosed()).toEqual(["/repo/"]) - dispose() - }) - }) }) describe("migrateCanonicalLocalServerState", () => { diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 450129f458..c7957bab8e 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -2,23 +2,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { type Accessor, batch, createMemo } from "solid-js" import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" -import { pathKey } from "@/utils/path-key" import { ServerScope } from "@/utils/server-scope" type StoredProject = { worktree: string; expanded: boolean } type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http -type ServerProjectState = { - projects: Record - lastProject: Record - recentlyClosed: Record -} +type ServerProjectState = { projects: Record; lastProject: Record } const HEALTH_POLL_INTERVAL_MS = 10_000 -// The store retains more history than is displayed. Consumers filter recently closed entries -// against the live project list (dropping deleted projects) and then cap the visible count via -// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily -// filtered out do not evict still-visible ones from the persisted store. -const RECENTLY_CLOSED_HISTORY_LIMIT = 16 -export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5 export function normalizeServerUrl(input: string) { const trimmed = input.trim() @@ -83,42 +72,19 @@ export function createServerProjects(input: { }) { const setStore = input.setStore as unknown as SetStoreFunction const current = () => input.store.projects[input.scope()] ?? [] - const currentClosed = () => input.store.recentlyClosed?.[input.scope()] ?? [] - const remove = (directory: string) => { - setStore( - "projects", - input.scope(), - current().filter((project) => project.worktree !== directory), - ) - } return { list: current, - recentlyClosed: currentClosed, - remove, open(directory: string) { const scope = input.scope() - const key = pathKey(directory) - const closed = currentClosed() - if (closed.some((worktree) => pathKey(worktree) === key)) { - setStore( - "recentlyClosed", - scope, - closed.filter((worktree) => pathKey(worktree) !== key), - ) - } if (current().some((project) => project.worktree === directory)) return setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()]) }, - // User-initiated close: removes the project and records it in recently closed. - // Internal, non-user removals (e.g. sandbox/worktree normalization) should use remove(). close(directory: string) { - remove(directory) - const key = pathKey(directory) - const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice( - 0, - RECENTLY_CLOSED_HISTORY_LIMIT, + setStore( + "projects", + input.scope(), + current().filter((project) => project.worktree !== directory), ) - setStore("recentlyClosed", input.scope(), closed) }, expand(directory: string) { const index = current().findIndex((project) => project.worktree === directory) @@ -269,7 +235,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( list: [] as StoredServer[], projects: {} as Record, lastProject: {} as Record, - recentlyClosed: {} as Record, }), ) @@ -312,10 +277,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( }) } - const isReady = Object.assign( - createMemo(() => ready() && !!state.active), - { promise: ready.promise }, - ) + const isReady = createMemo(() => ready() && !!state.active) const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer) const projects = createServerProjects({ scope, store, setStore }) diff --git a/packages/app/src/context/settings.test.ts b/packages/app/src/context/settings.test.ts deleted file mode 100644 index 51b35eacbb..0000000000 --- a/packages/app/src/context/settings.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { - hasExistingWebState, - initialAgentVisibility, - isAppUpgrade, - layoutTransitionState, - maximumSunsetTimeout, - newLayoutDesignsDefault, - nextSunsetCheckDelay, - resolveNewLayoutDesigns, - shouldDisplayTabsToast, - shouldEnableNewLayout, -} from "./settings" - -describe("agent visibility", () => { - test("shows the picker for existing profiles and hides it for first-time installs", () => { - expect(initialAgentVisibility(undefined, true)).toBe(true) - expect(initialAgentVisibility(undefined, false)).toBe(false) - }) - - test("shows the picker when updating from a recent release", () => { - expect(initialAgentVisibility(undefined, false, "1.18.8")).toBe(true) - }) - - test("preserves the preference after initialization", () => { - expect(initialAgentVisibility(true, true, "1.18.8")).toBeUndefined() - expect(initialAgentVisibility(true, false)).toBeUndefined() - }) -}) - -describe("layout transition", () => { - test("blank profiles default to the new layout", () => { - expect(newLayoutDesignsDefault).toBe(true) - }) - - test("hides the transition until a sunset is scheduled", () => { - expect(layoutTransitionState(false, true, false, false)).toEqual({ available: false, notice: false }) - }) - - test("existing profiles can switch before sunset", () => { - expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false }) - }) - - test("classifies web profiles from existing settings or a recorded version", () => { - expect(hasExistingWebState("{}", undefined)).toBe(true) - expect(hasExistingWebState(null, "1.17.19")).toBe(true) - expect(hasExistingWebState(null, undefined)).toBe(false) - }) - - test("preserves explicit and default layout preferences", () => { - expect(resolveNewLayoutDesigns(false, false, true)).toBe(false) - expect(resolveNewLayoutDesigns(false, undefined, false)).toBe(false) - expect(resolveNewLayoutDesigns(false, undefined, true)).toBe(true) - }) - - test("sunset replaces the toggle with a dismissible notice", () => { - expect(layoutTransitionState(true, true, true, false)).toEqual({ available: false, notice: true }) - expect(layoutTransitionState(true, true, true, true)).toEqual({ available: false, notice: false }) - expect(resolveNewLayoutDesigns(true, false)).toBe(true) - }) - - test("caps checks for sunsets beyond the browser timeout limit", () => { - expect(nextSunsetCheckDelay(maximumSunsetTimeout + 1_000, 0)).toBe(maximumSunsetTimeout) - expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000) - expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0) - }) - - test("enables the new layout when upgrading from 1.17.19 or earlier", () => { - expect(shouldEnableNewLayout("v1.17.19", "1.17.20")).toBe(true) - expect(shouldEnableNewLayout("1.16.9", "2.0.0")).toBe(true) - }) - - test("enables the new layout when no previous version was recorded", () => { - expect(shouldEnableNewLayout(undefined, "1.17.20")).toBe(true) - }) - - test("detects upgrades only when a previous version is older", () => { - expect(isAppUpgrade("1.17.19", "1.17.20")).toBe(true) - expect(isAppUpgrade(undefined, "1.17.20")).toBe(false) - expect(isAppUpgrade("1.17.20", "1.17.20")).toBe(false) - expect(isAppUpgrade("1.17.21", "1.17.20")).toBe(false) - }) - - test("shows the tabs toast for upgrades and existing installs without a recorded version", () => { - expect(shouldDisplayTabsToast("1.17.19", "1.17.20", false)).toBe(true) - expect(shouldDisplayTabsToast(undefined, "1.17.20", true)).toBe(true) - expect(shouldDisplayTabsToast(undefined, "1.17.20", false)).toBe(false) - }) - - test("does not enable the new layout without a qualifying upgrade", () => { - expect(shouldEnableNewLayout("1.17.19", "1.17.19")).toBe(false) - expect(shouldEnableNewLayout("1.17.20", "1.17.21")).toBe(false) - expect(shouldEnableNewLayout(undefined, "1.17.19")).toBe(false) - expect(shouldEnableNewLayout("dev", "1.17.20")).toBe(false) - }) -}) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 1a118b6544..8d4f61079c 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -1,8 +1,7 @@ import { createStore, reconcile } from "solid-js/store" -import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { createEffect, createMemo } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { persisted } from "@/utils/persist" -import { usePlatform } from "@/context/platform" export interface NotificationSettings { agent: boolean @@ -35,10 +34,6 @@ export interface Settings { showCustomAgents: boolean mobileTitlebarPosition: "top" | "bottom" newLayoutDesigns?: boolean - layoutTransitionEligible?: boolean - agentVisibilityInitialized?: boolean - newInterfaceNoticeDismissed?: boolean - shouldDisplayTabsToast?: boolean } appearance: { fontSize: number @@ -57,79 +52,7 @@ export interface Settings { export const monoDefault = "System Mono" export const sansDefault = "System Sans" export const terminalDefault = "JetBrainsMono Nerd Font Mono" -const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" -export const newLayoutDesignsDefault = true -// Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show. -export const oldInterfaceSunset = new Date(2026, 8, 14) -const newLayoutDesignsUpgradeCutoff = "1.17.19" - -function compareVersions(a: string, b: string) { - const parse = (version: string) => { - const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i.exec(version.trim()) - if (!match) return - return match.slice(1).map(Number) - } - const left = parse(a) - const right = parse(b) - if (!left || !right) return - const index = left.findIndex((part, index) => part !== right[index]) - return index === -1 ? 0 : left[index]! - right[index]! -} - -export function isAppUpgrade(previous: string | undefined, current: string | undefined) { - if (!previous || !current) return false - const comparison = compareVersions(current, previous) - return comparison !== undefined && comparison > 0 -} - -export function shouldDisplayTabsToast( - previous: string | undefined, - current: string | undefined, - existingInstall: boolean, -) { - return isAppUpgrade(previous, current) || (!previous && existingInstall) -} - -export function hasExistingWebState(settings: Promise | string | null, previousVersion: string | undefined) { - return settings !== null || previousVersion !== undefined -} - -export function initialAgentVisibility(initialized: boolean | undefined, existing: boolean, previousVersion?: string) { - if (initialized === true) return - return existing || previousVersion !== undefined -} - -export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) { - if (!current) return false - const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff) - if (!previous) return currentComparison !== undefined && currentComparison > 0 - if (!isAppUpgrade(previous, current)) return false - const previousComparison = compareVersions(previous, newLayoutDesignsUpgradeCutoff) - return ( - previousComparison !== undefined && - currentComparison !== undefined && - previousComparison <= 0 && - currentComparison > 0 - ) -} - -export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) { - return { - available: scheduled && eligible && !retired, - notice: scheduled && eligible && retired && !dismissed, - } -} - -export const maximumSunsetTimeout = 2_147_483_647 - -export function nextSunsetCheckDelay(sunset: number, now: number) { - return Math.min(Math.max(0, sunset - now), maximumSunsetTimeout) -} - -export function resolveNewLayoutDesigns(retired: boolean, preference: boolean | undefined, fallback = true) { - if (retired) return true - return preference ?? fallback -} +export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" const monoFallback = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' @@ -229,17 +152,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont name: "Settings", gate: false, init: () => { - const platform = usePlatform() - const [store, setStore, settingsInit, ready] = persisted("settings.v3", createStore(defaultSettings)) - const [launch, setLaunch, , launchReady] = persisted( - "app-version.v1", - createStore<{ version?: string }>({ version: undefined }), - ) - const [launchState, setLaunchState] = createStore({ - classified: false, - migrationApplied: false, - previous: undefined as string | undefined, - }) + const [store, setStore, _, ready] = persisted("settings.v3", createStore(defaultSettings)) const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree) const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch) const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus) @@ -247,101 +160,8 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont () => store.general?.showCustomAgents, defaultSettings.general.showCustomAgents, ) - const sunset = oldInterfaceSunset - const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false) - const layoutTransitionClassified = createMemo(() => typeof store.general?.layoutTransitionEligible === "boolean") - const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false) - const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false) - const layoutUpgrade = createMemo(() => - launchState.classified && !launchState.migrationApplied - ? shouldEnableNewLayout(launchState.previous, platform.version) - : false, - ) - const layoutTransition = createMemo(() => - layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()), - ) - const newLayoutDesigns = createMemo(() => { - if (layoutUpgrade()) return true - if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault - if (!layoutTransitionClassified()) { - return resolveNewLayoutDesigns( - oldInterfaceRetired(), - store.general?.newLayoutDesigns, - legacyNewLayoutDesignsDefault, - ) - } - return resolveNewLayoutDesigns( - oldInterfaceRetired(), - store.general?.newLayoutDesigns, - layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault, - ) - }) + const newLayoutDesigns = withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault) const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference()) - const initializeAgentVisibility = (existing: boolean) => { - const initial = initialAgentVisibility(store.general?.agentVisibilityInitialized, existing, launchState.previous) - if (initial === undefined) return - batch(() => { - setStore("general", "showCustomAgents", initial) - setStore("general", "agentVisibilityInitialized", true) - }) - } - - if (sunset && !oldInterfaceRetired()) { - const timeout = { current: undefined as ReturnType | undefined } - const checkSunset = () => { - if (Date.now() >= sunset.getTime()) { - setOldInterfaceRetired(true) - return - } - timeout.current = setTimeout(checkSunset, nextSunsetCheckDelay(sunset.getTime(), Date.now())) - } - checkSunset() - onCleanup(() => { - if (timeout.current !== undefined) clearTimeout(timeout.current) - }) - } - - createEffect(() => { - if (!launchReady() || launchState.classified) return - setLaunchState({ - classified: true, - previous: launch.version, - }) - if (!platform.version || launch.version === platform.version) return - setLaunch("version", platform.version) - }) - - createEffect(() => { - if (!ready() || !launchState.classified || platform.platform !== "web") return - const existing = hasExistingWebState(settingsInit, launchState.previous) - if (!layoutTransitionClassified()) setStore("general", "layoutTransitionEligible", existing) - initializeAgentVisibility(existing) - }) - - createEffect(() => { - if (!ready() || !launchState.classified || launchState.migrationApplied) return - if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) { - setStore("general", "newLayoutDesigns", true) - } - setLaunchState("migrationApplied", true) - }) - - createEffect(() => { - if (!ready() || !launchState.classified) return - if (typeof store.general?.shouldDisplayTabsToast === "boolean") return - if (!launchState.previous && !layoutTransitionClassified()) return - setStore( - "general", - "shouldDisplayTabsToast", - shouldDisplayTabsToast(launchState.previous, platform.version, layoutTransitionEligible()), - ) - }) - - createEffect(() => { - if (!ready() || !oldInterfaceRetired()) return - if (store.general?.newLayoutDesigns === true) return - setStore("general", "newLayoutDesigns", true) - }) createEffect(() => { if (typeof document === "undefined") return @@ -430,26 +250,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont }, newLayoutDesigns, setNewLayoutDesigns(value: boolean) { - const next = oldInterfaceRetired() ? true : value - if (newLayoutDesigns() === next) return - setStore("general", "newLayoutDesigns", next) - if (typeof window !== "undefined") setTimeout(() => window.location.reload()) - }, - layoutTransitionClassified, - setOldLayoutEligible(eligible: boolean) { - const current = store.general?.layoutTransitionEligible - if (typeof current === "boolean") return - setStore("general", "layoutTransitionEligible", eligible) - }, - initializeAgentVisibility, - layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available), - newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice), - dismissNewInterfaceNotice() { - setStore("general", "newInterfaceNoticeDismissed", true) - }, - shouldDisplayTabsToast: withFallback(() => store.general?.shouldDisplayTabsToast, false), - dismissTabsToast() { - setStore("general", "shouldDisplayTabsToast", false) + setStore("general", "newLayoutDesigns", value) }, }, visibility: { diff --git a/packages/app/src/context/tab-memory.ts b/packages/app/src/context/tab-memory.ts index 70bb987c7b..de8050f379 100644 --- a/packages/app/src/context/tab-memory.ts +++ b/packages/app/src/context/tab-memory.ts @@ -16,9 +16,6 @@ export function createTabMemory(owner: Owner | null) { } return { - get(key: string, name: string) { - return entries.get(key)?.get(name)?.value as T | undefined - }, ensure(key: string, name: string, init: () => T) { const state = entries.get(key) ?? new Map() if (!entries.has(key)) entries.set(key, state) diff --git a/packages/app/src/context/tab-migration.ts b/packages/app/src/context/tab-migration.ts deleted file mode 100644 index 04c1cb6804..0000000000 --- a/packages/app/src/context/tab-migration.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ServerConnection } from "./server" -import type { Tab } from "./tabs" - -export function migrateTabs(value: unknown, fallback: ServerConnection.Key): Tab[] { - if (!Array.isArray(value)) return [] - return value.flatMap((tab) => { - if (!tab || typeof tab !== "object") return [] - if ("server" in tab && typeof tab.server !== "string") return [] - const server = ("server" in tab ? tab.server : fallback) as ServerConnection.Key - if (tab.type === "session" && typeof tab.sessionId === "string") { - return [{ type: tab.type, server, sessionId: tab.sessionId }] - } - if ( - tab.type === "draft" && - typeof tab.draftID === "string" && - typeof tab.directory === "string" && - (tab.worktree === undefined || typeof tab.worktree === "string") - ) { - return [{ type: tab.type, server, draftID: tab.draftID, directory: tab.directory, worktree: tab.worktree }] - } - return [] - }) -} diff --git a/packages/app/src/context/tabs.test.ts b/packages/app/src/context/tabs.test.ts index abd62a4dae..27525af63d 100644 --- a/packages/app/src/context/tabs.test.ts +++ b/packages/app/src/context/tabs.test.ts @@ -1,33 +1,6 @@ import { describe, expect, test } from "bun:test" import { createRoot, getOwner, onCleanup } from "solid-js" import { createTabMemory } from "./tab-memory" -import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs" -import type { SessionTab, Tab } from "./tabs" -import { migrateTabs } from "./tab-migration" -import type { ServerConnection } from "./server" - -const server = "local\nhttp://localhost:4096" as ServerConnection.Key - -function sessionTab(sessionId: string): SessionTab { - return { type: "session", server, sessionId } -} - -describe("tab migration", () => { - test("drops null and malformed persisted tabs", () => { - expect( - migrateTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"], server), - ).toEqual([sessionTab("a")]) - }) - - test("adds the fallback server to valid legacy tabs", () => { - expect(migrateTabs([{ type: "session", sessionId: "a", dirBase64: "legacy" }], server)).toEqual([sessionTab("a")]) - }) - - test("replaces invalid top-level persisted data", () => { - expect(migrateTabs(null, server)).toEqual([]) - expect(migrateTabs({}, server)).toEqual([]) - }) -}) describe("tab memory", () => { test("keeps state until its tab is removed", () => { @@ -40,8 +13,6 @@ describe("tab memory", () => { }) expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first) - expect(memory.get("tab", "prompt")).toBe(first) - expect(memory.get("missing", "prompt")).toBeUndefined() expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first) memory.remove("tab") @@ -51,75 +22,3 @@ describe("tab memory", () => { }) }) }) - -describe("closed tab stack", () => { - test("records session tabs with their index", () => { - const stack = pushClosedTab([], sessionTab("a"), 2) - - expect(stack).toEqual([{ tab: sessionTab("a"), index: 2 }]) - }) - - test("ignores draft tabs", () => { - const draft: Tab = { type: "draft", draftID: "d1", server, directory: "/tmp" } - - expect(pushClosedTab([], draft, 0)).toEqual([]) - }) - - test("caps the stack size", () => { - const stack = Array.from({ length: 30 }, (_, i) => i).reduce( - (acc, i) => pushClosedTab(acc, sessionTab(`s${i}`), i), - [], - ) - - expect(stack).toHaveLength(25) - expect(stack[0]?.tab.sessionId).toBe("s5") - expect(stack.at(-1)?.tab.sessionId).toBe("s29") - }) - - test("pops the most recently closed tab", () => { - const stack = [ - { tab: sessionTab("a"), index: 0 }, - { tab: sessionTab("b"), index: 1 }, - ] - const result = takeClosedTab(stack, []) - - expect(result.entry?.tab.sessionId).toBe("b") - expect(result.stack).toEqual([{ tab: sessionTab("a"), index: 0 }]) - }) - - test("skips entries whose tab is already open", () => { - const stack = [ - { tab: sessionTab("a"), index: 0 }, - { tab: sessionTab("b"), index: 1 }, - ] - const result = takeClosedTab(stack, [sessionTab("b")]) - - expect(result.entry?.tab.sessionId).toBe("a") - expect(result.stack).toEqual([]) - }) - - test("returns no entry when everything is open or empty", () => { - expect(takeClosedTab([], []).entry).toBeUndefined() - - const result = takeClosedTab([{ tab: sessionTab("a"), index: 0 }], [sessionTab("a")]) - expect(result.entry).toBeUndefined() - expect(result.stack).toEqual([]) - }) - - test("purges removed sessions", () => { - const stack = [ - { tab: sessionTab("a"), index: 0 }, - { tab: sessionTab("b"), index: 1 }, - ] - - expect(removeClosedTabs(stack, server, ["a"])).toEqual([{ tab: sessionTab("b"), index: 1 }]) - }) - - test("does not navigate when a background tab closes", () => { - const tabs = [sessionTab("a"), sessionTab("b"), sessionTab("c")] - - expect(nextTabAfterClose(tabs, 1, false)).toBeUndefined() - expect(nextTabAfterClose(tabs, 1, true)).toEqual(sessionTab("c")) - expect(nextTabAfterClose([sessionTab("a")], 0, true)).toBeNull() - }) -}) diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index b6608e8681..6a73d7736d 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -10,9 +10,6 @@ import { uuid } from "@/utils/uuid" import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" import { sessionHref } from "@/utils/session-route" import { createTabMemory } from "./tab-memory" -import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs" -import { createDraftPromptSession, type PromptModel } from "./prompt-state" -import { migrateTabs } from "./tab-migration" export type SessionTab = { type: "session" @@ -30,11 +27,6 @@ export type DraftTab = { export type Tab = SessionTab | DraftTab -export type TabInfo = { - title?: string - directory?: string -} - type RecentTab = { key?: string } @@ -60,13 +52,17 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const [store, setStore, _, ready] = persisted( { ...Persist.window("tabs"), - migrate: (value: unknown) => migrateTabs(value, fallback), + migrate: (value: unknown) => { + if (!Array.isArray(value)) return value + return value.map((tab) => { + if (!tab || typeof tab !== "object" || "server" in tab) return tab + return { ...tab, server: fallback } + }) + }, }, createStore([]), ) const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore({})) - const [info, setInfo] = persisted(Persist.window("tabs.info"), createStore>({})) - const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore([])) const params = useParams() const navigate = useNavigate() @@ -91,29 +87,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }) } - const updateClosed = (update: (stack: ClosedTab[]) => ClosedTab[]) => { - const apply = () => setClosed((stack) => update(stack)) - if (closedReady()) { - apply() - return - } - void closedReady.promise?.then(apply) - } - const removeDraftPersisted = (draftID: string) => { - for (const key of draftPersistedKeys()) { - const target = Persist.draft(draftID, key) - removePersisted(key === "prompt" ? Persist.prompt(target) : target, platform) - } - } - - const removeInfo = (key: string) => { - if (!info[key]) return - setInfo( - produce((draft) => { - delete draft[key] - }), - ) + for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform) } onCleanup(memory.dispose) @@ -124,26 +99,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const next = store.filter((tab) => servers.has(tab.server)) if (next.length !== store.length) { for (const tab of store) { - if (!servers.has(tab.server)) { - const key = tabKey(tab) - memory.remove(key) - removeInfo(key) - } + if (!servers.has(tab.server)) memory.remove(tabKey(tab)) } setStore(() => next) } if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined) - const keys = new Set(next.map(tabKey)) - for (const key of Object.keys(info)) { - if (!keys.has(key)) removeInfo(key) - } - }) - - createEffect(() => { - if (!closedReady()) return - const servers = new Set(server.list.map(ServerConnection.key)) - const next = closed.filter((entry) => servers.has(entry.tab.server)) - if (next.length !== closed.length) setClosed(() => next) }) const navigateTab = (tab: Tab) => { @@ -157,7 +117,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ if (!tab) return const key = tabKey(tab) const draftID = tab.type === "draft" ? tab.draftID : undefined - const nextTab = nextTabAfterClose(store, index, recentKey() === key && location.pathname !== "/") + const nextTab = store[index + 1] ?? store[index - 1] closing.add(key) void startTransition(() => { setStore( @@ -165,14 +125,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ tabs.splice(index, 1) }), ) - if (nextTab === null) { - setRecentKey(undefined) - navigate("/") - } + if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab)) if (nextTab) navigateTab(nextTab) + else navigate("/") }).finally(() => closing.delete(key)) memory.remove(key) - removeInfo(key) if (draftID) removeDraftPersisted(draftID) } @@ -206,19 +163,16 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`) return tab }, - async newDraft(draft: Omit, prompt?: string, model?: PromptModel) { + newDraft(draft: Omit, prompt?: string) { const draftID = uuid() - const tab = { type: "draft" as const, draftID, ...draft } - memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model })) - await startTransition(() => { + void startTransition(() => { setStore( produce((tabs) => { - tabs.push(tab) + tabs.push({ type: "draft", draftID, ...draft }) }), ) - navigate(draftHref(draftID)) + navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID)) }) - return tab }, updateDraft(draftID: string, draft: Partial>) { void startTransition(() => { @@ -247,57 +201,23 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ removeDraftPersisted(draftID) }, removeTab, - // User-initiated close: records the tab so it can be reopened. - // Cleanup paths (missing sessions, archive, server removal) go through - // removeTab and friends directly and are not recorded. - closeTab(index: number) { - const tab = store[index] - if (!tab) return - if (tab.type === "session") updateClosed((stack) => pushClosedTab(stack, tab, index)) - removeTab(index) - }, - reopenClosedTab() { - if (!closedReady()) { - void closedReady.promise?.then(() => actions.reopenClosedTab()) - return - } - const result = takeClosedTab(closed, store) - if (result.stack.length === closed.length) return - setClosed(() => result.stack) - const entry = result.entry - if (!entry) return - const index = Math.min(entry.index, store.length) - void startTransition(() => { - setStore( - produce((tabs) => { - if (tabs.some((item) => tabKey(item) === tabKey(entry.tab))) return - tabs.splice(index, 0, entry.tab) - }), - ) - navigateTab(entry.tab) - }) - }, removeSessionTab(input: Omit) { - updateClosed((stack) => removeClosedTabs(stack, input.server, [input.sessionId])) const index = store.findIndex( (tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId, ) if (index !== -1) removeTab(index) }, removeServer(key: ServerConnection.Key) { - updateClosed((stack) => stack.filter((entry) => entry.tab.server !== key)) const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : [])) const removed = store.filter((tab) => tab.server === key).map(tabKey) setStore((tabs) => tabs.filter((tab) => tab.server !== key)) for (const key of removed) memory.remove(key) - for (const key of removed) removeInfo(key) if (recent.key && removed.includes(recent.key)) setRecentKey(undefined) for (const draftID of drafts) removeDraftPersisted(draftID) if (server.key === key) navigate("/") }, removeSessions: (input: SessionTabsRemovedDetail) => { const targetServer = input.server ?? server.key - updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs)) const removed = store .filter( (tab) => tab.type === "session" && tab.server === targetServer && input.sessionIDs.includes(tab.sessionId), @@ -345,14 +265,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ if (recent.key && removed.includes(recent.key)) setRecentKey(undefined) }) for (const key of removed) memory.remove(key) - for (const key of removed) removeInfo(key) - }, - rememberSessionInfo(tab: SessionTab, session: Session) { - const key = tabKey(tab) - const next = { title: session.title, directory: session.directory } - const current = info[key] - if (current?.title === next.title && current.directory === next.directory) return - setInfo(key, next) }, select: navigateTab, remember(tab: Tab) { @@ -375,11 +287,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ state(tab: Tab, name: string, init: () => T) { return memory.ensure(tabKey(tab), name, init) }, - stateValue(tab: Tab, name: string) { - return memory.get(tabKey(tab), name) - }, } - return { ...actions, store, info, ready, recentReady } + return { ...actions, store, ready, recentReady } }, }) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index df575b71f7..a9c66c4833 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -149,7 +149,6 @@ function createWorkspaceTerminalSession( scope: ServerScopeValue, legacySessionID?: string, ) { - const location = { directory: sdk.directory } const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : [] const [store, setStore, _, ready] = persisted( @@ -164,43 +163,6 @@ function createWorkspaceTerminalSession( all: [], }), ) - const [ui, setUi] = createStore({ - focus: undefined as { request: number; id?: string; pending: boolean } | undefined, - }) - const focus = { request: 0 } - - const requestFocus = (id?: string, pending = false) => { - focus.request += 1 - setUi("focus", { request: focus.request, id, pending }) - return focus.request - } - - const focusRequested = (id?: string) => { - if (!id) return false - if (!ui.focus || ui.focus.pending) return false - return !ui.focus.id || ui.focus.id === id - } - - const consumeFocus = (id: string) => { - if (!focusRequested(id)) return - setUi("focus", undefined) - } - - const cancelFocus = (request?: number) => { - if (request !== undefined && ui.focus?.request !== request) return - setUi("focus", undefined) - } - - if (typeof document !== "undefined") { - const cancelOnOutsideFocus = (event: FocusEvent) => { - if (!ui.focus) return - if (!(event.target instanceof Element)) return - if (event.target.closest("#terminal-panel")) return - cancelFocus() - } - document.addEventListener("focusin", cancelOnOutsideFocus) - onCleanup(() => document.removeEventListener("focusin", cancelOnOutsideFocus)) - } const pickNextTerminalNumber = () => { const existingTitleNumbers = new Set( @@ -241,63 +203,47 @@ function createWorkspaceTerminalSession( }) onCleanup(unsub) - const update = (pty: Partial & { id: string }) => { + const update = (client: DirectorySDK["client"], pty: Partial & { id: string }) => { const index = store.all.findIndex((x) => x.id === pty.id) const previous = index >= 0 ? store.all[index] : undefined if (index >= 0) { setStore("all", index, (item) => ({ ...item, ...pty })) } - const doUpdate = async () => { - if ((await sdk.protocol) === "v1") { - await sdk.client.pty.update({ - ptyID: pty.id, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) - } else { - await sdk.api.pty.update({ - ptyID: pty.id, - location, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) - } - } - doUpdate().catch((error: unknown) => { - if (previous) { - const currentIndex = store.all.findIndex((item) => item.id === pty.id) - if (currentIndex >= 0) setStore("all", currentIndex, previous) - } - console.error("Failed to update terminal", error) - }) + client.pty + .update({ + ptyID: pty.id, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + .catch((error: unknown) => { + if (previous) { + const currentIndex = store.all.findIndex((item) => item.id === pty.id) + if (currentIndex >= 0) setStore("all", currentIndex, previous) + } + console.error("Failed to update terminal", error) + }) } - const clone = async (id: string) => { + const clone = async (client: DirectorySDK["client"], id: string) => { const index = store.all.findIndex((x) => x.id === id) const pty = store.all[index] if (!pty) return - const data = await (async () => { - if ((await sdk.protocol) === "v1") { - return (await sdk.client.pty.create({ title: pty.title })).data - } - return ( - await sdk.api.pty.create({ - location, - title: pty.title, - }) - ).data - })().catch((error: unknown) => { - console.error("Failed to clone terminal", error) - return undefined - }) - if (!data?.id) return + const next = await client.pty + .create({ + title: pty.title, + }) + .catch((error: unknown) => { + console.error("Failed to clone terminal", error) + return undefined + }) + if (!next?.data) return const active = store.active === pty.id batch(() => { setStore("all", index, { - id: data.id, - title: data.title ?? pty.title, + id: next.data.id, + title: next.data.title ?? pty.title, titleNumber: pty.titleNumber, buffer: undefined, cursor: undefined, @@ -306,7 +252,7 @@ function createWorkspaceTerminalSession( cols: undefined, }) if (active) { - setStore("active", data.id) + setStore("active", next.data.id) } }) } @@ -321,43 +267,28 @@ function createWorkspaceTerminalSession( setStore("all", []) }) }, - new(options?: { focus?: boolean }) { + new() { const nextNumber = pickNextTerminalNumber() - const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined - const doCreate = async () => { - if ((await sdk.protocol) === "v1") { - return (await sdk.client.pty.create({ title: defaultTitle(nextNumber) })).data - } - return (await sdk.api.pty.create({ location, title: defaultTitle(nextNumber) })).data - } - doCreate() - .then((data) => { - const id = data?.id - if (!id) { - if (focusRequest !== undefined) cancelFocus(focusRequest) - return - } + sdk.client.pty + .create({ title: defaultTitle(nextNumber) }) + .then((pty: { data?: { id?: string; title?: string } }) => { + const id = pty.data?.id + if (!id) return const newTerminal = { id, - title: data?.title ?? defaultTitle(nextNumber), + title: pty.data?.title ?? defaultTitle(nextNumber), titleNumber: nextNumber, } - batch(() => { - setStore("all", store.all.length, newTerminal) - setStore("active", id) - if (focusRequest !== undefined && ui.focus?.request === focusRequest) { - setUi("focus", { request: focusRequest, id, pending: false }) - } - }) + setStore("all", store.all.length, newTerminal) + setStore("active", id) }) .catch((error: unknown) => { - if (focusRequest !== undefined) cancelFocus(focusRequest) console.error("Failed to create terminal", error) }) }, update(pty: Partial & { id: string }) { - update(pty) + update(sdk.client, pty) }, trim(id: string) { const index = store.all.findIndex((x) => x.id === id) @@ -372,9 +303,10 @@ function createWorkspaceTerminalSession( }) }, async clone(id: string) { - await clone(id) + await clone(sdk.client, id) }, bind() { + const client = sdk.client return { trim(id: string) { const index = store.all.findIndex((x) => x.id === id) @@ -382,28 +314,16 @@ function createWorkspaceTerminalSession( setStore("all", index, (pty) => trimTerminal(pty)) }, update(pty: Partial & { id: string }) { - update(pty) + update(client, pty) }, async clone(id: string) { - await clone(id) + await clone(client, id) }, } }, open(id: string) { setStore("active", id) }, - requestFocus(id?: string) { - requestFocus(id) - }, - focusRequested(id?: string) { - return focusRequested(id) - }, - consumeFocus(id: string) { - consumeFocus(id) - }, - cancelFocus() { - cancelFocus() - }, next() { const index = store.all.findIndex((x) => x.id === store.active) if (index === -1) return @@ -433,11 +353,7 @@ function createWorkspaceTerminalSession( }) } - const removePromise = - (await sdk.protocol) === "v1" - ? sdk.client.pty.remove({ ptyID: id }) - : sdk.api.pty.remove({ ptyID: id, location }) - await removePromise.catch((error: unknown) => { + await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => { console.error("Failed to close terminal", error) }) }, @@ -526,17 +442,13 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont ready: () => workspace().ready(), all: () => workspace().all(), active: () => workspace().active(), - new: (options?: { focus?: boolean }) => workspace().new(options), + new: () => workspace().new(), update: (pty: Partial & { id: string }) => workspace().update(pty), trim: (id: string) => workspace().trim(id), trimAll: () => workspace().trimAll(), clone: (id: string) => workspace().clone(id), bind: () => workspace(), open: (id: string) => workspace().open(id), - requestFocus: (id?: string) => workspace().requestFocus(id), - focusRequested: (id?: string) => workspace().focusRequested(id), - consumeFocus: (id: string) => workspace().consumeFocus(id), - cancelFocus: () => workspace().cancelFocus(), close: (id: string) => workspace().close(id), move: (id: string, to: number) => workspace().move(id, to), next: () => workspace().next(), diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 9b71d6567e..c41c1bb4b6 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -4,9 +4,9 @@ import * as Sentry from "@sentry/solid" import { render } from "solid-js/web" import { AppBaseProviders, AppInterface } from "@/app" import { type Platform, PlatformProvider } from "@/context/platform" -import { createBrowserDraftStore } from "@/utils/draft-store" import { dict as en } from "@/i18n/en" import { dict as zh } from "@/i18n/zh" +import { handleNotificationClick } from "@/utils/notification-click" import { authFromToken } from "@/utils/server" import pkg from "../package.json" import { ServerConnection } from "./context/server" @@ -54,7 +54,7 @@ const setStorage = (key: string, value: string | null) => { const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY) const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url) -const notify: Platform["notify"] = async (title, description, onClick) => { +const notify: Platform["notify"] = async (title, description, href) => { if (!("Notification" in window)) return const permission = @@ -73,17 +73,21 @@ const notify: Platform["notify"] = async (title, description, onClick) => { }) notification.onclick = () => { - window.focus() - onClick?.() + handleNotificationClick(href) notification.close() } } -const openExternal: Platform["openExternal"] = (value) => { - if (!URL.canParse(value)) return - const url = new URL(value) - if (url.protocol !== "http:" && url.protocol !== "https:" && url.protocol !== "mailto:") return - window.open(url.href, "_blank", "noopener,noreferrer") +const openLink: Platform["openLink"] = (url) => { + window.open(url, "_blank") +} + +const back: Platform["back"] = () => { + window.history.back() +} + +const forward: Platform["forward"] = () => { + window.history.forward() } const restart: Platform["restart"] = async () => { @@ -117,9 +121,10 @@ const clearAuthToken = () => { const platform: Platform = { platform: "web", - draftStore: createBrowserDraftStore(), version: pkg.version, - openExternal, + openLink, + back, + forward, restart, notify, getDefaultServer: async () => { diff --git a/packages/app/src/env.d.ts b/packages/app/src/env.d.ts index 5754df7683..39f827eb64 100644 --- a/packages/app/src/env.d.ts +++ b/packages/app/src/env.d.ts @@ -12,16 +12,6 @@ interface ImportMeta { readonly env: ImportMetaEnv } -declare module "*.png" { - const src: string - export default src -} - -declare module "*.mp4" { - const src: string - export default src -} - export declare module "solid-js" { namespace JSX { interface Directives { diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index 60ad01c867..982c26ca97 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -2,7 +2,7 @@ import { useServerSync } from "@/context/server-sync" import { decode64 } from "@/utils/base64" import { useParams } from "@solidjs/router" import { Iterable, pipe } from "effect" -import { createEffect, createMemo, type Accessor } from "solid-js" +import type { Accessor } from "solid-js" import { selectProviderCatalog } from "./provider-catalog" export const popularProviders = [ @@ -17,14 +17,14 @@ export const popularProviders = [ ] const popularProviderSet = new Set(popularProviders) -export function useProviders(directory: Accessor) { +export function useProviders(directory?: Accessor) { const serverSync = useServerSync() const params = useParams() const dir = () => (directory ? directory() : decode64(params.dir)) const providers = () => { const value = dir() const projectStore = value ? serverSync().child(value)[0] : undefined - if (value) + if (directory) return selectProviderCatalog({ explicit: true, directory: value, @@ -37,7 +37,6 @@ export function useProviders(directory: Accessor) { global: serverSync().data.provider, }) } - return { all: () => providers().all, default: () => providers().default, @@ -59,7 +58,7 @@ export function useProviders(directory: Accessor) { }, paid: () => { const connected = new Set(providers().connected) - const paid = [ + return [ ...Iterable.filter( providers().all, ([id]) => @@ -67,7 +66,6 @@ export function useProviders(directory: Accessor) { (id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)), ), ] - return paid }, } } diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 89fa394637..a5174a5900 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -21,9 +21,6 @@ export const dict = { "theme.scheme.dark": "داكن", "command.sidebar.toggle": "تبديل الشريط الجانبي", "command.project.open": "فتح مشروع", - "command.project.previous": "المشروع السابق", - "command.project.next": "المشروع التالي", - "command.project.index": "التبديل إلى المشروع {{index}}", "command.provider.connect": "اتصال بموفر", "command.server.switch": "تبديل الخادم", "command.settings.open": "فتح الإعدادات", @@ -42,7 +39,6 @@ export const dict = { "command.session.new": "جلسة جديدة", "command.file.open": "فتح ملف", "command.tab.close": "إغلاق علامة التبويب", - "command.tab.reopenClosed": "إعادة فتح علامة التبويب المغلقة", "command.context.addSelection": "إضافة التحديد إلى السياق", "command.context.addSelection.description": "إضافة الأسطر المحددة من الملف الحالي", "command.input.focus": "التركيز على حقل الإدخال", @@ -86,7 +82,6 @@ export const dict = { "command.session.unshare": "إلغاء مشاركة الجلسة", "command.session.unshare.description": "إيقاف مشاركة هذه الجلسة", "palette.search.placeholder": "البحث في الملفات والأوامر والجلسات", - "palette.search.placeholder.home": "البحث في الأوامر والجلسات", "palette.empty": "لا توجد نتائج", "palette.group.commands": "الأوامر", "palette.group.files": "الملفات", @@ -198,11 +193,6 @@ export const dict = { "model.input.video": "فيديو", "model.input.pdf": "pdf", "model.tooltip.allows": "يسمح: {{inputs}}", - "model.tooltip.context.label": "السياق", - "model.tooltip.inputs": "المدخلات", - "model.tooltip.model": "النموذج", - "model.tooltip.provider": "الموفر", - "model.tooltip.reasoning": "الاستنتاج", "model.tooltip.reasoning.allowed": "يسمح بالاستنتاج", "model.tooltip.reasoning.none": "بدون استنتاج", "model.tooltip.context": "حد السياق {{limit}}", @@ -212,7 +202,6 @@ export const dict = { "common.loading": "جارٍ التحميل", "common.loading.ellipsis": "...", "common.cancel": "إلغاء", - "common.clear": "مسح", "common.connect": "اتصال", "common.disconnect": "قطع الاتصال", "common.continue": "إرسال", @@ -229,8 +218,6 @@ export const dict = { "prompt.mode.shell": "Shell", "prompt.mode.normal": "Prompt", "prompt.mode.shell.exit": "esc للخروج", - "session.child.promptDisabled": "لا يمكن إرسال موجّهات إلى جلسات الوكلاء الفرعيين.", - "session.child.backToParent": "العودة إلى الجلسة الرئيسية.", "prompt.example.1": "إصلاح TODO في قاعدة التعليمات البرمجية", "prompt.example.2": "ما هو المكدس التقني لهذا المشروع؟", "prompt.example.3": "إصلاح الاختبارات المعطلة", @@ -268,16 +255,10 @@ export const dict = { "prompt.context.removeActiveFile": "إزالة الملف النشط من السياق", "prompt.context.removeFile": "إزالة الملف من السياق", "prompt.action.attachFile": "إرفاق ملف", - "prompt.menu.addImagesAndFiles": "إضافة ملفات والمزيد", - "prompt.menu.imagesAndFiles": "الصور والملفات", - "prompt.menu.commands": "الأوامر", - "prompt.menu.context": "السياق", - "prompt.menu.shellCommand": "أمر shell", "prompt.attachment.remove": "إزالة المرفق", "prompt.action.send": "إرسال", "prompt.action.stop": "توقف", "prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم", - "prompt.toast.attachmentDuplicate.title": "تم تحميل هذا الملف بالفعل", "prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.", "prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً", "prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.", @@ -333,74 +314,6 @@ export const dict = { "dialog.server.menu.delete": "حذف", "dialog.server.current": "الخادم الحالي", "dialog.server.status.default": "افتراضي", - "wsl.server.add": "إضافة خادم WSL", - "wsl.server.addShort": "إضافة WSL", - "wsl.server.label": "WSL", - "wsl.server.menu.label": "خادم WSL", - "wsl.server.retryStart": "إعادة محاولة التشغيل", - "wsl.server.updating": "جارٍ التحديث...", - "wsl.onboarding.step.distro": "اختر توزيعة", - "wsl.onboarding.step.opencode": "OpenCode", - "wsl.onboarding.checkingRuntime": "جارٍ التحقق من WSL...", - "wsl.onboarding.restartRequired": "يحتاج Windows إلى إعادة التشغيل لإكمال تثبيت WSL.", - "wsl.onboarding.ready": "WSL جاهز.", - "wsl.onboarding.required": "يلزم WSL للمتابعة.", - "wsl.onboarding.checkingDistros": "جارٍ التحقق من التوزيعات...", - "wsl.onboarding.installingDistro": "جارٍ تثبيت {{distro}}...", - "wsl.onboarding.checkingDistro": "جارٍ التحقق من {{distro}}...", - "wsl.onboarding.listingDistros": "جارٍ سرد التوزيعات...", - "wsl.onboarding.distroReady": "{{distro}} جاهزة.", - "wsl.onboarding.distroNotInstalled": "لم يتم تثبيت {{distro}} بعد.", - "wsl.onboarding.openDistroOnce": "افتح {{distro}} مرة واحدة لإكمال الإعداد.", - "wsl.onboarding.finishingDistro": "جارٍ إكمال إعداد {{distro}}.", - "wsl.onboarding.pickDistro": "اختر توزيعة أو ثبّت واحدة أدناه.", - "wsl.onboarding.checkingOpencode": "جارٍ التحقق من OpenCode...", - "wsl.onboarding.checkingOpencodeIn": "جارٍ التحقق من OpenCode في {{distro}}...", - "wsl.onboarding.updatingOpencode": "جارٍ تحديث OpenCode...", - "wsl.onboarding.updatingOpencodeIn": "جارٍ تحديث OpenCode في {{distro}}...", - "wsl.onboarding.updateOpencodeIn": "حدّث OpenCode في {{distro}}.", - "wsl.onboarding.updateOpencode": "تحديث OpenCode", - "wsl.onboarding.opencodeReadyIn": "OpenCode جاهز في {{distro}}.", - "wsl.onboarding.opencodeReady": "OpenCode جاهز.", - "wsl.onboarding.installOpencodeIn": "ثبّت OpenCode في {{distro}}.", - "wsl.onboarding.installOpencode": "تثبيت OpenCode", - "wsl.onboarding.chooseDistroFirst": "اختر توزيعة أولاً.", - "wsl.onboarding.loadFailed": "فشل تحميل حالة WSL.", - "wsl.onboarding.loading": "جارٍ التحميل...", - "wsl.onboarding.installedDistros": "التوزيعات المثبتة", - "wsl.onboarding.checkAgain": "تحقق مجددًا", - "wsl.onboarding.distroStatus.ready": "جاهزة", - "wsl.onboarding.distroStatus.checking": "جارٍ التحقق...", - "wsl.onboarding.distroStatus.opencodeMissing": "OpenCode غير مثبت", - "wsl.onboarding.distroStatus.missingTools": "bash و curl مفقودان", - "wsl.onboarding.distroStatus.unsupported": "غير مدعومة · استخدم WSL 2", - "wsl.onboarding.needAnotherDistro": "هل تحتاج إلى توزيعة أخرى؟", - "wsl.onboarding.needAnotherDistroHint": "ثبّت توزيعة Linux من كتالوج WSL", - "wsl.onboarding.wslNotInstalled.title": "WSL غير مثبت", - "wsl.onboarding.wslNotInstalled.description": - "يلزم WSL (نظام Windows الفرعي لـ Linux) قبل أن يتمكن OpenCode من إضافة خادم WSL", - "wsl.onboarding.wslUnavailable.title": "WSL غير متاح", - "wsl.onboarding.wslUnavailable.description": "تعذر على OpenCode التحقق من WSL على هذا الجهاز.", - "wsl.onboarding.installWsl": "تثبيت WSL", - "wsl.onboarding.windowsRestartRequired": "أعد تشغيل Windows لإكمال تثبيت WSL، ثم أعد فتح OpenCode.", - "wsl.onboarding.next": "التالي", - "wsl.onboarding.refresh": "تحديث", - "wsl.onboarding.allDistrosAdded": "تمت إضافة جميع التوزيعات المثبتة بالفعل.", - "wsl.onboarding.noDistros": "لم يتم اكتشاف أي توزيعات بعد.", - "wsl.onboarding.install": "تثبيت", - "wsl.onboarding.installing": "جارٍ التثبيت...", - "wsl.onboarding.installDistro": "تثبيت توزيعة", - "wsl.onboarding.searchDistros": "البحث عن توزيعات", - "wsl.onboarding.wsl2Required": "يلزم WSL 2.", - "wsl.onboarding.toolsRequired": "تحتاج هذه التوزيعة إلى bash و curl.", - "wsl.onboarding.openTerminal": "فتح المحطة الطرفية", - "wsl.onboarding.path": "المسار: {{path}}", - "wsl.onboarding.notFound": "غير موجود", - "wsl.onboarding.version": "الإصدار: {{version}}", - "wsl.onboarding.unknown": "غير معروف", - "wsl.onboarding.desktopVersion": "سطح المكتب {{version}}", - "wsl.onboarding.versionMismatch": "لا يتطابق الإصدار المثبت مع إصدار تطبيق سطح المكتب.", - "wsl.onboarding.adding": "جارٍ الإضافة...", "dialog.project.edit.title": "تحرير المشروع", "dialog.project.edit.name": "الاسم", "dialog.project.edit.icon": "أيقونة", @@ -457,7 +370,6 @@ export const dict = { "language.no": "Norsk", "language.br": "Português (Brasil)", "language.bs": "Bosanski", - "language.uk": "Українська", "language.th": "ไทย", "language.tr": "Türkçe", "toast.language.title": "لغة", @@ -494,12 +406,10 @@ export const dict = { "toast.update.action.notYet": "ليس الآن", "error.page.title": "حدث خطأ ما", "error.page.description": "حدث خطأ أثناء تحميل التطبيق.", - "error.page.description.localServerStartup": "حدث خطأ أثناء بدء تشغيل الخادم المحلي.", "error.page.details.label": "تفاصيل الخطأ", "error.page.action.restart": "إعادة تشغيل", "error.page.action.report": "الإبلاغ عن الخطأ", "error.page.action.reported": "تم الإبلاغ عن الخطأ", - "error.page.action.exportLogs": "تصدير السجلات", "error.page.action.checking": "جارٍ التحقق...", "error.page.action.checkUpdates": "التحقق من وجود تحديثات", "error.page.action.updateTo": "تحديث إلى {{version}}", @@ -540,39 +450,16 @@ export const dict = { "home.recentProjects": "المشاريع الحديثة", "home.empty.title": "لا توجد مشاريع حديثة", "home.empty.description": "ابدأ بفتح مشروع محلي", - "home.title": "الرئيسية", - "home.projects": "المشاريع", - "home.project.add": "إضافة مشروع", - "home.recentlyClosed": "المغلقة مؤخرًا", - "home.server.collapse": "طي مشاريع الخادم", - "home.server.expand": "توسيع مشاريع الخادم", - "home.sessions.search.placeholder": "البحث في الجلسات", - "home.sessions.search.placeholder.scoped": "البحث في جلسات {{scope}}", - "home.sessions.search.sessions": "الجلسات", - "home.sessions.search.noResults": "لم يتم العثور على جلسات لـ {{query}}", - "home.sessions.empty": "لا يوجد شيء هنا بعد", - "home.sessions.empty.description": "أنشئ جلسة للبدء", - "home.sessions.group.today": "اليوم", - "home.sessions.group.yesterday": "أمس", - "home.sessions.group.older": "الأقدم", - "home.providerTip": "اتصل بأكثر من 75 مزودًا لاستخدام نماذج أخرى، بما فيها Claude وGPT وGemini وغيرها", "session.tab.session": "جلسة", "session.tab.review": "مراجعة", "session.tab.context": "سياق", - "session.tab.unknown": "جلسة غير معروفة", "session.panel.reviewAndFiles": "المراجعة والملفات", - "session.error.notFound": "لا يمكن العثور على هذه الجلسة", - "session.error.notFound.description": "يشير هذا التبويب إلى جلسة لم تعد موجودة على هذا الخادم.", - "session.error.notFound.closeTab": "إغلاق علامة التبويب", - "session.error.serverConnection": "تعذر الاتصال بهذا الخادم", "session.review.filesChanged": "تم تغيير {{count}} ملفات", "session.review.change.one": "تغيير", "session.review.change.other": "تغييرات", "session.review.loadingChanges": "جارٍ تحميل التغييرات...", "session.review.empty": "لا توجد تغييرات في هذه الجلسة بعد", "session.review.noChanges": "لا توجد تغييرات", - "session.review.noUncommittedChanges": "لا توجد تغييرات غير مُودَعة بعد", - "session.review.noBranchChanges": "لا توجد تغييرات في الفرع بعد", "session.review.noVcs": "لم يتم اكتشاف نظام التحكم في الإصدار Git، لن يتم عرض التغييرات", "session.review.noSnapshot": "تم تعطيل تتبع اللقطات في التكوين، لذا فإن تغييرات الجلسة غير متوفرة", "session.files.selectToOpen": "اختر ملفًا لفتحه", @@ -588,10 +475,6 @@ export const dict = { "session.todo.title": "المهام", "session.todo.collapse": "طي", "session.todo.expand": "توسيع", - "session.question.minimize": "تصغير السؤال", - "session.question.restore": "استعادة السؤال", - "session.question.pending.one": "{{count}} سؤال معلق", - "session.question.pending.other": "الأسئلة المعلقة: {{count}}", "session.followupDock.summary.one": "{{count}} رسالة في الانتظار", "session.followupDock.summary.other": "{{count}} رسائل في الانتظار", "session.followupDock.sendNow": "إرسال الآن", @@ -604,17 +487,9 @@ export const dict = { "session.revertDock.expand": "توسيع الرسائل التي تم التراجع عنها", "session.revertDock.restore": "استعادة الرسالة", "session.new.title": "ابنِ أي شيء", - "session.new.project.new": "مشروع جديد", - "session.new.project.search": "البحث عن المشاريع", - "session.new.project.add": "إضافة مشروع", "session.new.worktree.main": "الفرع الرئيسي", "session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})", "session.new.worktree.create": "إنشاء شجرة عمل جديدة", - "session.new.workspace.runIn": "تشغيل الجلسة في", - "session.new.workspace.triggerLocal": "محلي", - "session.new.workspace.local": "المستودع المحلي", - "session.new.workspace.existing": "مساحة عمل…", - "session.new.git.none": "لا يوجد Git", "session.new.lastModified": "آخر تعديل", "session.header.search.placeholder": "بحث {{project}}", "session.header.searchFiles": "بحث عن الملفات", @@ -675,8 +550,6 @@ export const dict = { "sidebar.project.recentSessions": "الجلسات الحديثة", "sidebar.project.viewAllSessions": "عرض جميع الجلسات", "sidebar.project.clearNotifications": "مسح الإشعارات", - "sidebar.empty.title": "لا توجد مشاريع مفتوحة", - "sidebar.empty.description": "افتح مشروعًا للبدء", "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "سطح المكتب", "settings.section.server": "الخادم", @@ -686,7 +559,6 @@ export const dict = { "settings.desktop.wsl.title": "تكامل WSL", "settings.desktop.wsl.description": "تشغيل خادم OpenCode داخل WSL على Windows.", "settings.general.section.appearance": "المظهر", - "settings.general.section.advanced": "متقدم", "settings.general.section.notifications": "إشعارات النظام", "settings.general.section.updates": "التحديثات", "settings.general.section.sounds": "المؤثرات الصوتية", @@ -694,11 +566,6 @@ export const dict = { "settings.general.section.display": "شاشة العرض", "settings.general.row.language.title": "اللغة", "settings.general.row.language.description": "تغيير لغة العرض لـ OpenCode", - "settings.general.row.shell.title": "Shell المحطة الطرفية", - "settings.general.row.shell.description": - "اختر shell المستخدم في المحطة الطرفية. تُستخدم واجهات shell المتوافقة أيضًا في استدعاءات أدوات الوكيل.", - "settings.general.row.shell.autoDefault": "تلقائي (افتراضي)", - "settings.general.row.shell.terminalOnly": "المحطة الطرفية فقط", "settings.general.row.appearance.title": "المظهر", "settings.general.row.appearance.description": "تخصيص كيفية ظهور OpenCode على جهازك", "settings.general.row.colorScheme.title": "مخطط الألوان", @@ -707,29 +574,14 @@ export const dict = { "settings.general.row.theme.description": "تخصيص سمة OpenCode.", "settings.general.row.font.title": "خط الكود", "settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية", - "settings.general.row.terminalFont.title": "خط الطرفية", - "settings.general.row.terminalFont.description": "خصّص الخط المستخدم في الطرفية", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "خط الواجهة", "settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها", "settings.general.row.followup.title": "سلوك المتابعة", "settings.general.row.followup.description": "اختر ما إذا كانت طلبات المتابعة توجه فورًا أو تنتظر في قائمة انتظار", "settings.general.row.followup.option.queue": "قائمة انتظار", "settings.general.row.followup.option.steer": "توجيه", - "settings.general.row.showFileTree.title": "شجرة الملفات", - "settings.general.row.showFileTree.description": "إظهار لوحة شجرة الملفات في الجلسات", - "settings.general.row.showNavigation.title": "عناصر التحكم في التنقل", - "settings.general.row.showNavigation.description": "إظهار زري الرجوع والتقدم في شريط عنوان سطح المكتب", - "settings.general.row.showSearch.title": "لوحة الأوامر", - "settings.general.row.showSearch.description": "إظهار زر البحث ولوحة الأوامر في شريط العنوان", - "settings.general.row.showTerminal.title": "المحطة الطرفية", - "settings.general.row.showTerminal.description": "إظهار زر المحطة الطرفية في شريط عنوان سطح المكتب", - "settings.general.row.showStatus.title": "حالة الخادم", - "settings.general.row.showStatus.description": "إظهار زر حالة الخادم في شريط العنوان", - "settings.general.row.mobileTitlebarBottom.title": "التنقل السفلي", - "settings.general.row.mobileTitlebarBottom.description": - "وضع شريط العنوان وعلامات تبويب الجلسات أسفل الشاشة على الأجهزة المحمولة", - "settings.general.row.showCustomAgents.title": "الوكلاء المخصصون", - "settings.general.row.showCustomAgents.description": "إظهار محدد الوكلاء في محرر الرسائل", "settings.general.row.reasoningSummaries.title": "إظهار ملخصات الاستنتاج", "settings.general.row.reasoningSummaries.description": "عرض ملخصات استنتاج النموذج في الشريط الزمني", "settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell", @@ -738,15 +590,6 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit", "settings.general.row.editToolPartsExpanded.description": "إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني", - "settings.general.row.newInterface.title": "التخطيط الجديد", - "settings.general.row.newInterface.badge": "جديد", - "settings.general.row.newInterface.description": - "استخدم علامات التبويب الجديدة وتخطيط الصفحة الرئيسية. يمكنك التبديل بين التخطيطات لفترة محدودة.", - "settings.general.row.newInterfaceNotice.title": "أنت تستخدم الآن التخطيط الجديد", - "settings.general.row.newInterfaceNotice.description": "التخطيط السابق لم يعد متاحًا", - "settings.general.row.newInterfaceNotice.dismiss": "رفض", - "settings.general.row.pinchZoom.title": "التكبير بإيماءة القرص", - "settings.general.row.pinchZoom.description": "السماح بإيماءة القرص على لوحة اللمس وإيماءة Ctrl-scroll للتكبير", "settings.general.row.wayland.title": "استخدام Wayland الأصلي", "settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.", "settings.general.row.wayland.tooltip": @@ -759,8 +602,6 @@ export const dict = { "settings.updates.row.check.description": "التحقق يدويًا من التحديثات وتثبيتها إذا كانت متاحة", "settings.updates.action.checkNow": "تحقق الآن", "settings.updates.action.checking": "جارٍ التحقق...", - "settings.updates.action.downloading": "جارٍ التنزيل...", - "settings.updates.action.installing": "جارٍ التثبيت...", "settings.updates.toast.latest.title": "أنت على آخر إصدار", "settings.updates.toast.latest.description": "أنت تستخدم أحدث إصدار من OpenCode.", "sound.option.none": "بلا", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index f6e5d8df9d..2e53bb1af0 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -21,9 +21,6 @@ export const dict = { "theme.scheme.dark": "Escuro", "command.sidebar.toggle": "Alternar barra lateral", "command.project.open": "Abrir projeto", - "command.project.previous": "Projeto anterior", - "command.project.next": "Próximo projeto", - "command.project.index": "Alternar para o projeto {{index}}", "command.provider.connect": "Conectar provedor", "command.server.switch": "Trocar servidor", "command.settings.open": "Abrir configurações", @@ -42,7 +39,6 @@ export const dict = { "command.session.new": "Nova sessão", "command.file.open": "Abrir arquivo", "command.tab.close": "Fechar aba", - "command.tab.reopenClosed": "Reabrir aba fechada", "command.context.addSelection": "Adicionar seleção ao contexto", "command.context.addSelection.description": "Adicionar as linhas selecionadas do arquivo atual", "command.input.focus": "Focar entrada", @@ -86,7 +82,6 @@ export const dict = { "command.session.unshare": "Parar de compartilhar sessão", "command.session.unshare.description": "Parar de compartilhar esta sessão", "palette.search.placeholder": "Buscar arquivos, comandos e sessões", - "palette.search.placeholder.home": "Buscar comandos e sessões", "palette.empty": "Nenhum resultado encontrado", "palette.group.commands": "Comandos", "palette.group.files": "Arquivos", @@ -198,11 +193,6 @@ export const dict = { "model.input.video": "vídeo", "model.input.pdf": "pdf", "model.tooltip.allows": "Permite: {{inputs}}", - "model.tooltip.context.label": "Contexto", - "model.tooltip.inputs": "Entradas", - "model.tooltip.model": "Modelo", - "model.tooltip.provider": "Provedor", - "model.tooltip.reasoning": "Raciocínio", "model.tooltip.reasoning.allowed": "Permite raciocínio", "model.tooltip.reasoning.none": "Sem raciocínio", "model.tooltip.context": "Limite de contexto {{limit}}", @@ -212,7 +202,6 @@ export const dict = { "common.loading": "Carregando", "common.loading.ellipsis": "...", "common.cancel": "Cancelar", - "common.clear": "Limpar", "common.connect": "Conectar", "common.disconnect": "Desconectar", "common.continue": "Enviar", @@ -229,8 +218,6 @@ export const dict = { "prompt.mode.shell": "Shell", "prompt.mode.normal": "Prompt", "prompt.mode.shell.exit": "esc para sair", - "session.child.promptDisabled": "Não é possível enviar prompts em sessões de subagentes.", - "session.child.backToParent": "Voltar à sessão principal.", "prompt.example.1": "Corrigir um TODO no código", "prompt.example.2": "Qual é a stack tecnológica deste projeto?", "prompt.example.3": "Corrigir testes quebrados", @@ -268,16 +255,10 @@ export const dict = { "prompt.context.removeActiveFile": "Remover arquivo ativo do contexto", "prompt.context.removeFile": "Remover arquivo do contexto", "prompt.action.attachFile": "Anexar arquivo", - "prompt.menu.addImagesAndFiles": "Adicionar arquivos e mais", - "prompt.menu.imagesAndFiles": "Imagens e arquivos", - "prompt.menu.commands": "Comandos", - "prompt.menu.context": "Contexto", - "prompt.menu.shellCommand": "Comando shell", "prompt.attachment.remove": "Remover anexo", "prompt.action.send": "Enviar", "prompt.action.stop": "Parar", "prompt.toast.pasteUnsupported.title": "Anexo não suportado", - "prompt.toast.attachmentDuplicate.title": "Este arquivo já foi enviado", "prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.", "prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo", "prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.", @@ -333,75 +314,6 @@ export const dict = { "dialog.server.menu.delete": "Excluir", "dialog.server.current": "Servidor atual", "dialog.server.status.default": "Padrão", - "wsl.server.add": "Adicionar servidor WSL", - "wsl.server.addShort": "Adicionar WSL", - "wsl.server.label": "WSL", - "wsl.server.menu.label": "Servidor WSL", - "wsl.server.retryStart": "Tentar iniciar novamente", - "wsl.server.updating": "Atualizando...", - "wsl.onboarding.step.distro": "Escolher distribuição", - "wsl.onboarding.step.opencode": "OpenCode", - "wsl.onboarding.checkingRuntime": "Verificando o WSL...", - "wsl.onboarding.restartRequired": "O Windows precisa ser reiniciado para concluir a instalação do WSL.", - "wsl.onboarding.ready": "O WSL está pronto.", - "wsl.onboarding.required": "O WSL é necessário para continuar.", - "wsl.onboarding.checkingDistros": "Verificando distribuições...", - "wsl.onboarding.installingDistro": "Instalando {{distro}}...", - "wsl.onboarding.checkingDistro": "Verificando {{distro}}...", - "wsl.onboarding.listingDistros": "Listando distribuições...", - "wsl.onboarding.distroReady": "{{distro}} está pronta.", - "wsl.onboarding.distroNotInstalled": "{{distro}} ainda não está instalada.", - "wsl.onboarding.openDistroOnce": "Abra {{distro}} uma vez para concluir a configuração.", - "wsl.onboarding.finishingDistro": "Concluindo a configuração de {{distro}}.", - "wsl.onboarding.pickDistro": "Escolha uma distribuição ou instale uma abaixo.", - "wsl.onboarding.checkingOpencode": "Verificando o OpenCode...", - "wsl.onboarding.checkingOpencodeIn": "Verificando o OpenCode em {{distro}}...", - "wsl.onboarding.updatingOpencode": "Atualizando o OpenCode...", - "wsl.onboarding.updatingOpencodeIn": "Atualizando o OpenCode em {{distro}}...", - "wsl.onboarding.updateOpencodeIn": "Atualize o OpenCode em {{distro}}.", - "wsl.onboarding.updateOpencode": "Atualizar o OpenCode", - "wsl.onboarding.opencodeReadyIn": "O OpenCode está pronto em {{distro}}.", - "wsl.onboarding.opencodeReady": "O OpenCode está pronto.", - "wsl.onboarding.installOpencodeIn": "Instale o OpenCode em {{distro}}.", - "wsl.onboarding.installOpencode": "Instalar o OpenCode", - "wsl.onboarding.chooseDistroFirst": "Escolha primeiro uma distribuição.", - "wsl.onboarding.loadFailed": "Falha ao carregar o estado do WSL.", - "wsl.onboarding.loading": "Carregando...", - "wsl.onboarding.installedDistros": "Distribuições instaladas", - "wsl.onboarding.checkAgain": "Verificar novamente", - "wsl.onboarding.distroStatus.ready": "Pronta", - "wsl.onboarding.distroStatus.checking": "Verificando...", - "wsl.onboarding.distroStatus.opencodeMissing": "OpenCode não instalado", - "wsl.onboarding.distroStatus.missingTools": "bash e curl ausentes", - "wsl.onboarding.distroStatus.unsupported": "Sem suporte · Use o WSL 2", - "wsl.onboarding.needAnotherDistro": "Precisa de outra distribuição?", - "wsl.onboarding.needAnotherDistroHint": "Instale uma distribuição Linux pelo catálogo do WSL", - "wsl.onboarding.wslNotInstalled.title": "WSL não instalado", - "wsl.onboarding.wslNotInstalled.description": - "O WSL (Windows Subsystem for Linux) é necessário para que o OpenCode possa adicionar um servidor WSL", - "wsl.onboarding.wslUnavailable.title": "WSL indisponível", - "wsl.onboarding.wslUnavailable.description": "O OpenCode não conseguiu verificar o WSL neste computador.", - "wsl.onboarding.installWsl": "Instalar o WSL", - "wsl.onboarding.windowsRestartRequired": - "Reinicie o Windows para concluir a instalação do WSL e abra o OpenCode novamente.", - "wsl.onboarding.next": "Próximo", - "wsl.onboarding.refresh": "Atualizar", - "wsl.onboarding.allDistrosAdded": "Todas as distribuições instaladas já foram adicionadas.", - "wsl.onboarding.noDistros": "Nenhuma distribuição detectada ainda.", - "wsl.onboarding.install": "Instalar", - "wsl.onboarding.installing": "Instalando...", - "wsl.onboarding.installDistro": "Instalar distribuição", - "wsl.onboarding.searchDistros": "Buscar distribuições", - "wsl.onboarding.wsl2Required": "O WSL 2 é necessário.", - "wsl.onboarding.toolsRequired": "Esta distribuição precisa do bash e do curl.", - "wsl.onboarding.openTerminal": "Abrir terminal", - "wsl.onboarding.path": "Caminho: {{path}}", - "wsl.onboarding.notFound": "não encontrado", - "wsl.onboarding.version": "Versão: {{version}}", - "wsl.onboarding.unknown": "desconhecido", - "wsl.onboarding.desktopVersion": "desktop {{version}}", - "wsl.onboarding.versionMismatch": "A versão instalada não corresponde à versão do aplicativo para desktop.", - "wsl.onboarding.adding": "Adicionando...", "dialog.project.edit.title": "Editar projeto", "dialog.project.edit.name": "Nome", "dialog.project.edit.icon": "Ícone", @@ -459,7 +371,6 @@ export const dict = { "language.no": "Norsk", "language.br": "Português (Brasil)", "language.bs": "Bosanski", - "language.uk": "Українська", "language.th": "ไทย", "language.tr": "Türkçe", "toast.language.title": "Idioma", @@ -496,12 +407,10 @@ export const dict = { "toast.update.action.notYet": "Agora não", "error.page.title": "Algo deu errado", "error.page.description": "Ocorreu um erro ao carregar a aplicação.", - "error.page.description.localServerStartup": "Ocorreu um erro ao iniciar o servidor local.", "error.page.details.label": "Detalhes do Erro", "error.page.action.restart": "Reiniciar", "error.page.action.report": "Reportar erro", "error.page.action.reported": "Erro reportado", - "error.page.action.exportLogs": "Exportar logs", "error.page.action.checking": "Verificando...", "error.page.action.checkUpdates": "Verificar atualizações", "error.page.action.updateTo": "Atualizar para {{version}}", @@ -544,32 +453,10 @@ export const dict = { "home.recentProjects": "Projetos recentes", "home.empty.title": "Nenhum projeto recente", "home.empty.description": "Comece abrindo um projeto local", - "home.title": "Início", - "home.projects": "Projetos", - "home.project.add": "Adicionar projeto", - "home.recentlyClosed": "Projetos fechados recentemente", - "home.server.collapse": "Recolher projetos do servidor", - "home.server.expand": "Expandir projetos do servidor", - "home.sessions.search.placeholder": "Buscar sessões", - "home.sessions.search.placeholder.scoped": "Buscar sessões em {{scope}}", - "home.sessions.search.sessions": "Sessões", - "home.sessions.search.noResults": "Nenhuma sessão encontrada para {{query}}", - "home.sessions.empty": "Nada por aqui ainda", - "home.sessions.empty.description": "Crie uma sessão para começar", - "home.sessions.group.today": "Hoje", - "home.sessions.group.yesterday": "Ontem", - "home.sessions.group.older": "Mais antigas", - "home.providerTip": - "Conecte-se a mais de 75 provedores para usar outros modelos, incluindo Claude, GPT, Gemini e muito mais", "session.tab.session": "Sessão", "session.tab.review": "Revisão", "session.tab.context": "Contexto", - "session.tab.unknown": "Sessão desconhecida", "session.panel.reviewAndFiles": "Revisão e arquivos", - "session.error.notFound": "Esta sessão não foi encontrada", - "session.error.notFound.description": "Esta aba aponta para uma sessão que não existe mais neste servidor.", - "session.error.notFound.closeTab": "Fechar aba", - "session.error.serverConnection": "Não é possível conectar a este servidor", "session.review.filesChanged": "{{count}} Arquivos Alterados", "session.review.change.one": "Alteração", "session.review.change.other": "Alterações", @@ -579,8 +466,6 @@ export const dict = { "session.review.noSnapshot": "O rastreamento de snapshot está desabilitado na configuração, então as alterações da sessão estão indisponíveis", "session.review.noChanges": "Sem alterações", - "session.review.noUncommittedChanges": "Ainda não há alterações sem commit", - "session.review.noBranchChanges": "Ainda não há alterações na branch", "session.files.selectToOpen": "Selecione um arquivo para abrir", "session.files.all": "Todos os arquivos", "session.files.empty": "Nenhum arquivo", @@ -594,10 +479,6 @@ export const dict = { "session.todo.title": "Tarefas", "session.todo.collapse": "Recolher", "session.todo.expand": "Expandir", - "session.question.minimize": "Minimizar pergunta", - "session.question.restore": "Restaurar pergunta", - "session.question.pending.one": "{{count}} pergunta pendente", - "session.question.pending.other": "{{count}} perguntas pendentes", "session.followupDock.summary.one": "{{count}} mensagem na fila", "session.followupDock.summary.other": "{{count}} mensagens na fila", "session.followupDock.sendNow": "Enviar agora", @@ -610,17 +491,9 @@ export const dict = { "session.revertDock.expand": "Expandir mensagens revertidas", "session.revertDock.restore": "Restaurar mensagem", "session.new.title": "Crie qualquer coisa", - "session.new.project.new": "Novo projeto", - "session.new.project.search": "Buscar projetos", - "session.new.project.add": "Adicionar projeto", "session.new.worktree.main": "Branch principal", "session.new.worktree.mainWithBranch": "Branch principal ({{branch}})", "session.new.worktree.create": "Criar novo worktree", - "session.new.workspace.runIn": "Executar sessão em", - "session.new.workspace.triggerLocal": "Local", - "session.new.workspace.local": "Repositório local", - "session.new.workspace.existing": "Espaço de trabalho…", - "session.new.git.none": "Sem Git", "session.new.lastModified": "Última modificação", "session.header.search.placeholder": "Buscar {{project}}", "session.header.searchFiles": "Buscar arquivos", @@ -684,18 +557,15 @@ export const dict = { "sidebar.project.recentSessions": "Sessões recentes", "sidebar.project.viewAllSessions": "Ver todas as sessões", "sidebar.project.clearNotifications": "Limpar notificações", - "sidebar.empty.title": "Nenhum projeto aberto", - "sidebar.empty.description": "Abra um projeto para começar", "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Desktop", "settings.section.server": "Servidor", "settings.tab.general": "Geral", "settings.tab.shortcuts": "Atalhos", "settings.desktop.section.wsl": "WSL", - "settings.desktop.wsl.title": "Integração com WSL", + "settings.desktop.wsl.title": "WSL integration", "settings.desktop.wsl.description": "Executar o servidor OpenCode dentro do WSL no Windows.", "settings.general.section.appearance": "Aparência", - "settings.general.section.advanced": "Avançado", "settings.general.section.notifications": "Notificações do sistema", "settings.general.section.updates": "Atualizações", "settings.general.section.sounds": "Efeitos sonoros", @@ -703,11 +573,6 @@ export const dict = { "settings.general.section.display": "Tela", "settings.general.row.language.title": "Idioma", "settings.general.row.language.description": "Alterar o idioma de exibição do OpenCode", - "settings.general.row.shell.title": "Shell do terminal", - "settings.general.row.shell.description": - "Escolha o shell usado no terminal. Os shells compatíveis também são usados nas chamadas de ferramentas do agente.", - "settings.general.row.shell.autoDefault": "Automático (Padrão)", - "settings.general.row.shell.terminalOnly": "somente no terminal", "settings.general.row.appearance.title": "Aparência", "settings.general.row.appearance.description": "Personalize como o OpenCode aparece no seu dispositivo", "settings.general.row.colorScheme.title": "Esquema de cores", @@ -716,8 +581,8 @@ export const dict = { "settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.", "settings.general.row.font.title": "Fonte de código", "settings.general.row.font.description": "Personalize a fonte usada em blocos de código", - "settings.general.row.terminalFont.title": "Fonte do terminal", - "settings.general.row.terminalFont.description": "Personalize a fonte usada no terminal", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "Fonte da interface", "settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface", "settings.general.row.followup.title": "Comportamento de acompanhamento", @@ -725,22 +590,6 @@ export const dict = { "Escolha se os prompts de acompanhamento orientam imediatamente ou esperam na fila", "settings.general.row.followup.option.queue": "Fila", "settings.general.row.followup.option.steer": "Orientar", - "settings.general.row.showFileTree.title": "Árvore de arquivos", - "settings.general.row.showFileTree.description": "Mostrar o painel da árvore de arquivos nas sessões", - "settings.general.row.showNavigation.title": "Controles de navegação", - "settings.general.row.showNavigation.description": - "Mostrar os botões de voltar e avançar na barra de título do desktop", - "settings.general.row.showSearch.title": "Paleta de comandos", - "settings.general.row.showSearch.description": "Mostrar o botão de busca e paleta de comandos na barra de título", - "settings.general.row.showTerminal.title": "Terminal", - "settings.general.row.showTerminal.description": "Mostrar o botão do terminal na barra de título do desktop", - "settings.general.row.showStatus.title": "Status do servidor", - "settings.general.row.showStatus.description": "Mostrar o botão de status do servidor na barra de título", - "settings.general.row.mobileTitlebarBottom.title": "Navegação inferior", - "settings.general.row.mobileTitlebarBottom.description": - "Posicionar a barra de título e as abas da sessão na parte inferior da tela em dispositivos móveis", - "settings.general.row.showCustomAgents.title": "Agentes personalizados", - "settings.general.row.showCustomAgents.description": "Mostrar o seletor de agentes na área de composição", "settings.general.row.reasoningSummaries.title": "Mostrar resumos de raciocínio", "settings.general.row.reasoningSummaries.description": "Exibir resumos de raciocínio do modelo na linha do tempo", "settings.general.row.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell", @@ -749,16 +598,6 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição", "settings.general.row.editToolPartsExpanded.description": "Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo", - "settings.general.row.newInterface.title": "Novo layout", - "settings.general.row.newInterface.badge": "Novo", - "settings.general.row.newInterface.description": - "Use as novas abas e o layout da página inicial. Alterne entre os layouts por tempo limitado.", - "settings.general.row.newInterfaceNotice.title": "Agora você está usando o novo layout", - "settings.general.row.newInterfaceNotice.description": "O layout anterior não está mais disponível", - "settings.general.row.newInterfaceNotice.dismiss": "Descartar", - "settings.general.row.pinchZoom.title": "Zoom com gesto de pinça", - "settings.general.row.pinchZoom.description": - "Permitir gestos de pinça no trackpad e de Ctrl+rolagem para aplicar zoom", "settings.general.row.wayland.title": "Usar Wayland nativo", "settings.general.row.wayland.description": "Desabilitar fallback X11 no Wayland. Requer reinicialização.", "settings.general.row.wayland.tooltip": @@ -771,8 +610,6 @@ export const dict = { "settings.updates.row.check.description": "Verificar atualizações manualmente e instalar se houver", "settings.updates.action.checkNow": "Verificar agora", "settings.updates.action.checking": "Verificando...", - "settings.updates.action.downloading": "Baixando...", - "settings.updates.action.installing": "Instalando...", "settings.updates.toast.latest.title": "Você está atualizado", "settings.updates.toast.latest.description": "Você está usando a versão mais recente do OpenCode.", "sound.option.none": "Nenhum", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 3343df7a2b..c5b9e1a6d8 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -23,9 +23,6 @@ export const dict = { "command.sidebar.toggle": "Prikaži/sakrij bočnu traku", "command.project.open": "Otvori projekat", - "command.project.previous": "Prethodni projekat", - "command.project.next": "Sljedeći projekat", - "command.project.index": "Prebaci na projekat {{index}}", "command.provider.connect": "Poveži provajdera", "command.server.switch": "Promijeni server", "command.settings.open": "Otvori postavke", @@ -48,7 +45,6 @@ export const dict = { "command.session.new": "Nova sesija", "command.file.open": "Otvori datoteku", "command.tab.close": "Zatvori karticu", - "command.tab.reopenClosed": "Ponovo otvori zatvorenu karticu", "command.context.addSelection": "Dodaj odabir u kontekst", "command.context.addSelection.description": "Dodaj odabrane linije iz trenutne datoteke", "command.input.focus": "Fokusiraj polje za unos", @@ -93,7 +89,6 @@ export const dict = { "command.session.unshare.description": "Zaustavi dijeljenje ove sesije", "palette.search.placeholder": "Pretraži datoteke, komande i sesije", - "palette.search.placeholder.home": "Pretraži komande i sesije", "palette.empty": "Nema rezultata", "palette.group.commands": "Komande", "palette.group.files": "Datoteke", @@ -214,11 +209,6 @@ export const dict = { "model.input.video": "video", "model.input.pdf": "pdf", "model.tooltip.allows": "Podržava: {{inputs}}", - "model.tooltip.context.label": "Kontekst", - "model.tooltip.inputs": "Ulazi", - "model.tooltip.model": "Model", - "model.tooltip.provider": "Provajder", - "model.tooltip.reasoning": "Rasuđivanje", "model.tooltip.reasoning.allowed": "Podržava rasuđivanje", "model.tooltip.reasoning.none": "Bez rasuđivanja", "model.tooltip.context": "Limit konteksta {{limit}}", @@ -229,7 +219,6 @@ export const dict = { "common.loading": "Učitavanje", "common.loading.ellipsis": "...", "common.cancel": "Otkaži", - "common.clear": "Očisti", "common.connect": "Poveži", "common.disconnect": "Prekini vezu", "common.continue": "Pošalji", @@ -247,8 +236,6 @@ export const dict = { "prompt.mode.shell": "Shell", "prompt.mode.normal": "Prompt", "prompt.mode.shell.exit": "esc za izlaz", - "session.child.promptDisabled": "Sesijama podagenta nije moguće slati upite.", - "session.child.backToParent": "Nazad na glavnu sesiju.", "prompt.example.1": "Popravi TODO u bazi koda", "prompt.example.2": "Koji je tehnološki stack ovog projekta?", @@ -288,17 +275,11 @@ export const dict = { "prompt.context.removeActiveFile": "Ukloni aktivnu datoteku iz konteksta", "prompt.context.removeFile": "Ukloni datoteku iz konteksta", "prompt.action.attachFile": "Priloži datoteku", - "prompt.menu.addImagesAndFiles": "Dodaj datoteke i više", - "prompt.menu.imagesAndFiles": "Slike i datoteke", - "prompt.menu.commands": "Komande", - "prompt.menu.context": "Kontekst", - "prompt.menu.shellCommand": "Shell naredba", "prompt.attachment.remove": "Ukloni prilog", "prompt.action.send": "Pošalji", "prompt.action.stop": "Zaustavi", "prompt.toast.pasteUnsupported.title": "Nepodržan prilog", - "prompt.toast.attachmentDuplicate.title": "Ova datoteka je već učitana", "prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.", "prompt.toast.modelAgentRequired.title": "Odaberi agenta i model", "prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.", @@ -361,75 +342,6 @@ export const dict = { "dialog.server.menu.delete": "Izbriši", "dialog.server.current": "Trenutni server", "dialog.server.status.default": "Podrazumijevano", - "wsl.server.add": "Dodaj WSL server", - "wsl.server.addShort": "Dodaj WSL", - "wsl.server.label": "WSL", - "wsl.server.menu.label": "WSL server", - "wsl.server.retryStart": "Ponovi pokretanje", - "wsl.server.updating": "Ažuriranje...", - "wsl.onboarding.step.distro": "Odaberi distribuciju", - "wsl.onboarding.step.opencode": "OpenCode", - "wsl.onboarding.checkingRuntime": "Provjera WSL-a...", - "wsl.onboarding.restartRequired": "Potrebno je ponovo pokrenuti Windows da bi se završila instalacija WSL-a.", - "wsl.onboarding.ready": "WSL je spreman.", - "wsl.onboarding.required": "WSL je potreban za nastavak.", - "wsl.onboarding.checkingDistros": "Provjera distribucija...", - "wsl.onboarding.installingDistro": "Instaliranje {{distro}}...", - "wsl.onboarding.checkingDistro": "Provjera {{distro}}...", - "wsl.onboarding.listingDistros": "Učitavanje liste distribucija...", - "wsl.onboarding.distroReady": "Distribucija {{distro}} je spremna.", - "wsl.onboarding.distroNotInstalled": "Distribucija {{distro}} još nije instalirana.", - "wsl.onboarding.openDistroOnce": "Otvori {{distro}} jednom da završiš postavljanje.", - "wsl.onboarding.finishingDistro": "Završavanje postavljanja za {{distro}}.", - "wsl.onboarding.pickDistro": "Odaberi distribuciju ili instaliraj jednu ispod.", - "wsl.onboarding.checkingOpencode": "Provjera OpenCode...", - "wsl.onboarding.checkingOpencodeIn": "Provjera OpenCode u distribuciji {{distro}}...", - "wsl.onboarding.updatingOpencode": "Ažuriranje OpenCode...", - "wsl.onboarding.updatingOpencodeIn": "Ažuriranje OpenCode u distribuciji {{distro}}...", - "wsl.onboarding.updateOpencodeIn": "Ažuriraj OpenCode u distribuciji {{distro}}.", - "wsl.onboarding.updateOpencode": "Ažuriraj OpenCode", - "wsl.onboarding.opencodeReadyIn": "OpenCode je spreman u distribuciji {{distro}}.", - "wsl.onboarding.opencodeReady": "OpenCode je spreman.", - "wsl.onboarding.installOpencodeIn": "Instaliraj OpenCode u distribuciji {{distro}}.", - "wsl.onboarding.installOpencode": "Instaliraj OpenCode", - "wsl.onboarding.chooseDistroFirst": "Prvo odaberi distribuciju.", - "wsl.onboarding.loadFailed": "Nije uspjelo učitavanje WSL stanja.", - "wsl.onboarding.loading": "Učitavanje...", - "wsl.onboarding.installedDistros": "Instalirane distribucije", - "wsl.onboarding.checkAgain": "Provjeri ponovo", - "wsl.onboarding.distroStatus.ready": "Spremno", - "wsl.onboarding.distroStatus.checking": "Provjera...", - "wsl.onboarding.distroStatus.opencodeMissing": "OpenCode nije instaliran", - "wsl.onboarding.distroStatus.missingTools": "Nedostaju bash i curl", - "wsl.onboarding.distroStatus.unsupported": "Nije podržano · Koristi WSL 2", - "wsl.onboarding.needAnotherDistro": "Treba ti druga distribucija?", - "wsl.onboarding.needAnotherDistroHint": "Instaliraj Linux distribuciju iz WSL kataloga", - "wsl.onboarding.wslNotInstalled.title": "WSL nije instaliran", - "wsl.onboarding.wslNotInstalled.description": - "WSL (Windows Subsystem for Linux) je potreban da bi OpenCode mogao dodati WSL server", - "wsl.onboarding.wslUnavailable.title": "WSL nije dostupan", - "wsl.onboarding.wslUnavailable.description": "OpenCode nije mogao provjeriti WSL na ovom uređaju.", - "wsl.onboarding.installWsl": "Instaliraj WSL", - "wsl.onboarding.windowsRestartRequired": - "Ponovo pokreni Windows da završiš instalaciju WSL-a, zatim ponovo otvori OpenCode.", - "wsl.onboarding.next": "Dalje", - "wsl.onboarding.refresh": "Osvježi", - "wsl.onboarding.allDistrosAdded": "Sve instalirane distribucije su već dodane.", - "wsl.onboarding.noDistros": "Još nema otkrivenih distribucija.", - "wsl.onboarding.install": "Instaliraj", - "wsl.onboarding.installing": "Instaliranje...", - "wsl.onboarding.installDistro": "Instaliraj distribuciju", - "wsl.onboarding.searchDistros": "Pretraži distribucije", - "wsl.onboarding.wsl2Required": "Potreban je WSL 2.", - "wsl.onboarding.toolsRequired": "Ovoj distribuciji su potrebni bash i curl.", - "wsl.onboarding.openTerminal": "Otvori terminal", - "wsl.onboarding.path": "Putanja: {{path}}", - "wsl.onboarding.notFound": "nije pronađeno", - "wsl.onboarding.version": "Verzija: {{version}}", - "wsl.onboarding.unknown": "nepoznato", - "wsl.onboarding.desktopVersion": "desktop {{version}}", - "wsl.onboarding.versionMismatch": "Instalirana verzija ne odgovara verziji desktop aplikacije.", - "wsl.onboarding.adding": "Dodavanje...", "dialog.project.edit.title": "Uredi projekat", "dialog.project.edit.name": "Naziv", @@ -493,7 +405,6 @@ export const dict = { "language.no": "Norsk", "language.br": "Português (Brasil)", "language.bs": "Bosanski", - "language.uk": "Українська", "language.th": "ไทย", "language.tr": "Türkçe", @@ -542,12 +453,10 @@ export const dict = { "error.page.title": "Nešto je pošlo po zlu", "error.page.description": "Došlo je do greške prilikom učitavanja aplikacije.", - "error.page.description.localServerStartup": "Došlo je do greške pri pokretanju lokalnog servera.", "error.page.details.label": "Detalji greške", "error.page.action.restart": "Restartuj", "error.page.action.report": "Prijavi grešku", "error.page.action.reported": "Greška prijavljena", - "error.page.action.exportLogs": "Izvezi logove", "error.page.action.checking": "Provjera...", "error.page.action.checkUpdates": "Provjeri ažuriranja", "error.page.action.updateTo": "Ažuriraj na {{version}}", @@ -595,33 +504,11 @@ export const dict = { "home.recentProjects": "Nedavni projekti", "home.empty.title": "Nema nedavnih projekata", "home.empty.description": "Kreni tako što ćeš otvoriti lokalni projekat", - "home.title": "Početna", - "home.projects": "Projekti", - "home.project.add": "Dodaj projekat", - "home.recentlyClosed": "Nedavno zatvoreni projekti", - "home.server.collapse": "Sažmi projekte servera", - "home.server.expand": "Proširi projekte servera", - "home.sessions.search.placeholder": "Pretraži sesije", - "home.sessions.search.placeholder.scoped": "Pretraži sesije u {{scope}}", - "home.sessions.search.sessions": "Sesije", - "home.sessions.search.noResults": "Nema pronađenih sesija za {{query}}", - "home.sessions.empty": "Ovdje još nema ničega", - "home.sessions.empty.description": "Kreiraj sesiju za početak", - "home.sessions.group.today": "Danas", - "home.sessions.group.yesterday": "Jučer", - "home.sessions.group.older": "Starije", - "home.providerTip": - "Povežite se s više od 75 pružalaca usluga kako biste koristili druge modele, uključujući Claude, GPT, Gemini i druge", "session.tab.session": "Sesija", "session.tab.review": "Pregled", "session.tab.context": "Kontekst", - "session.tab.unknown": "Nepoznata sesija", "session.panel.reviewAndFiles": "Pregled i datoteke", - "session.error.notFound": "Ova sesija nije pronađena", - "session.error.notFound.description": "Ova kartica upućuje na sesiju koja više ne postoji na ovom serveru.", - "session.error.notFound.closeTab": "Zatvori karticu", - "session.error.serverConnection": "Nije moguće povezati se s ovim serverom", "session.review.filesChanged": "Izmijenjeno {{count}} datoteka", "session.review.change.one": "Izmjena", "session.review.change.other": "Izmjene", @@ -631,8 +518,6 @@ export const dict = { "session.review.noSnapshot": "Praćenje snimaka (snapshot) je onemogućeno u konfiguraciji, pa promjene sesije nisu dostupne", "session.review.noChanges": "Nema izmjena", - "session.review.noUncommittedChanges": "Još nema nepotvrđenih promjena", - "session.review.noBranchChanges": "Još nema promjena na grani", "session.files.selectToOpen": "Odaberi datoteku za otvaranje", "session.files.all": "Sve datoteke", @@ -649,10 +534,6 @@ export const dict = { "session.todo.title": "Zadaci", "session.todo.collapse": "Sažmi", "session.todo.expand": "Proširi", - "session.question.minimize": "Minimiziraj pitanje", - "session.question.restore": "Vrati pitanje", - "session.question.pending.one": "{{count}} pitanje na čekanju", - "session.question.pending.other": "{{count}} pitanja na čekanju", "session.followupDock.summary.one": "{{count}} poruka na čekanju", "session.followupDock.summary.other": "{{count}} poruka na čekanju", "session.followupDock.sendNow": "Pošalji sada", @@ -666,17 +547,9 @@ export const dict = { "session.revertDock.restore": "Vrati poruku", "session.new.title": "Napravi bilo šta", - "session.new.project.new": "Novi projekat", - "session.new.project.search": "Pretraži projekte", - "session.new.project.add": "Dodaj projekat", "session.new.worktree.main": "Glavna grana", "session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})", "session.new.worktree.create": "Kreiraj novi worktree", - "session.new.workspace.runIn": "Pokreni sesiju u", - "session.new.workspace.triggerLocal": "Lokalno", - "session.new.workspace.local": "Lokalni repozitorij", - "session.new.workspace.existing": "Radni prostor…", - "session.new.git.none": "Nema Gita", "session.new.lastModified": "Posljednja izmjena", "session.header.search.placeholder": "Pretraži {{project}}", @@ -745,8 +618,6 @@ export const dict = { "sidebar.project.recentSessions": "Nedavne sesije", "sidebar.project.viewAllSessions": "Prikaži sve sesije", "sidebar.project.clearNotifications": "Očisti obavijesti", - "sidebar.empty.title": "Nema otvorenih projekata", - "sidebar.empty.description": "Otvori projekat za početak", "app.name.desktop": "OpenCode Desktop", @@ -759,7 +630,6 @@ export const dict = { "settings.desktop.wsl.description": "Pokreni OpenCode server unutar WSL-a na Windowsu.", "settings.general.section.appearance": "Izgled", - "settings.general.section.advanced": "Napredno", "settings.general.section.notifications": "Sistemske obavijesti", "settings.general.section.updates": "Ažuriranja", "settings.general.section.sounds": "Zvučni efekti", @@ -768,11 +638,6 @@ export const dict = { "settings.general.row.language.title": "Jezik", "settings.general.row.language.description": "Promijeni jezik prikaza u OpenCode-u", - "settings.general.row.shell.title": "Shell terminala", - "settings.general.row.shell.description": - "Odaberi shell koji se koristi za terminal. Kompatibilni shellovi koriste se i za pozive alata agenta.", - "settings.general.row.shell.autoDefault": "Automatski (podrazumijevano)", - "settings.general.row.shell.terminalOnly": "samo terminal", "settings.general.row.appearance.title": "Izgled", "settings.general.row.appearance.description": "Prilagodi kako OpenCode izgleda na tvom uređaju", "settings.general.row.colorScheme.title": "Šema boja", @@ -781,30 +646,14 @@ export const dict = { "settings.general.row.theme.description": "Prilagodi temu OpenCode-a.", "settings.general.row.font.title": "Font za kod", "settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda", - "settings.general.row.terminalFont.title": "Font terminala", - "settings.general.row.terminalFont.description": "Prilagodite font koji se koristi u terminalu", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "UI font", "settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu", "settings.general.row.followup.title": "Ponašanje nadovezivanja", "settings.general.row.followup.description": "Odaberi da li upiti nadovezivanja usmjeravaju odmah ili čekaju u redu", "settings.general.row.followup.option.queue": "Red čekanja", "settings.general.row.followup.option.steer": "Usmjeri", - "settings.general.row.showFileTree.title": "Stablo datoteka", - "settings.general.row.showFileTree.description": "Prikaži stablo datoteka u sesijama", - "settings.general.row.showNavigation.title": "Kontrole navigacije", - "settings.general.row.showNavigation.description": - "Prikaži dugmad za nazad i naprijed u naslovnoj traci desktop aplikacije", - "settings.general.row.showSearch.title": "Paleta komandi", - "settings.general.row.showSearch.description": "Prikaži dugme za pretragu i paletu komandi u naslovnoj traci", - "settings.general.row.showTerminal.title": "Terminal", - "settings.general.row.showTerminal.description": "Prikaži dugme terminala u naslovnoj traci desktop aplikacije", - "settings.general.row.showStatus.title": "Status servera", - "settings.general.row.showStatus.description": "Prikaži dugme statusa servera u naslovnoj traci", - "settings.general.row.mobileTitlebarBottom.title": "Donja navigacija", - "settings.general.row.mobileTitlebarBottom.description": - "Postavi naslovnu traku i kartice sesije na dno ekrana na mobilnim uređajima", - "settings.general.row.showCustomAgents.title": "Prilagođeni agenti", - "settings.general.row.showCustomAgents.description": "Prikaži izbor agenta u uređivaču poruke", "settings.general.row.reasoningSummaries.title": "Prikaži sažetke rasuđivanja", "settings.general.row.reasoningSummaries.description": "Prikaži sažetke rasuđivanja modela na vremenskoj traci", @@ -814,16 +663,6 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje", "settings.general.row.editToolPartsExpanded.description": "Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci", - "settings.general.row.newInterface.title": "Novi raspored", - "settings.general.row.newInterface.badge": "Novo", - "settings.general.row.newInterface.description": - "Koristite nove kartice i raspored početne stranice. Ograničeno vrijeme možete se prebacivati između rasporeda.", - "settings.general.row.newInterfaceNotice.title": "Sada koristite novi raspored", - "settings.general.row.newInterfaceNotice.description": "Prethodni raspored više nije dostupan", - "settings.general.row.newInterfaceNotice.dismiss": "Odbaci", - "settings.general.row.pinchZoom.title": "Zumiranje štipanjem", - "settings.general.row.pinchZoom.description": - "Dozvoli zumiranje gestom štipanja na dodirnoj ploči i pomoću Ctrl-pomjeranja", "settings.general.row.wayland.title": "Koristi nativni Wayland", "settings.general.row.wayland.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.", "settings.general.row.wayland.tooltip": @@ -838,8 +677,6 @@ export const dict = { "settings.updates.row.check.description": "Ručno provjeri ažuriranja i instaliraj ako su dostupna", "settings.updates.action.checkNow": "Provjeri sada", "settings.updates.action.checking": "Provjera...", - "settings.updates.action.downloading": "Preuzimanje...", - "settings.updates.action.installing": "Instaliranje...", "settings.updates.toast.latest.title": "Sve je ažurno", "settings.updates.toast.latest.description": "Koristiš najnoviju verziju OpenCode-a.", "sound.option.none": "Nijedan", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 52bfb642c3..ad43c1d111 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -23,9 +23,6 @@ export const dict = { "command.sidebar.toggle": "Skift sidebjælke", "command.project.open": "Åbn projekt", - "command.project.previous": "Forrige projekt", - "command.project.next": "Næste projekt", - "command.project.index": "Skift til projekt {{index}}", "command.provider.connect": "Tilslut udbyder", "command.server.switch": "Skift server", "command.settings.open": "Åbn indstillinger", @@ -48,7 +45,6 @@ export const dict = { "command.session.new": "Ny session", "command.file.open": "Åbn fil", "command.tab.close": "Luk fane", - "command.tab.reopenClosed": "Åbn lukket fane igen", "command.context.addSelection": "Tilføj markering til kontekst", "command.context.addSelection.description": "Tilføj markerede linjer fra den aktuelle fil", "command.input.focus": "Fokuser inputfelt", @@ -93,7 +89,6 @@ export const dict = { "command.session.unshare.description": "Stop med at dele denne session", "palette.search.placeholder": "Søg i filer, kommandoer og sessioner", - "palette.search.placeholder.home": "Søg i kommandoer og sessioner", "palette.empty": "Ingen resultater fundet", "palette.group.commands": "Kommandoer", "palette.group.files": "Filer", @@ -213,11 +208,6 @@ export const dict = { "model.input.video": "video", "model.input.pdf": "pdf", "model.tooltip.allows": "Tillader: {{inputs}}", - "model.tooltip.context.label": "Kontekst", - "model.tooltip.inputs": "Input", - "model.tooltip.model": "Model", - "model.tooltip.provider": "Udbyder", - "model.tooltip.reasoning": "Tænkning", "model.tooltip.reasoning.allowed": "Tillader tænkning", "model.tooltip.reasoning.none": "Ingen tænkning", "model.tooltip.context": "Kontekstgrænse {{limit}}", @@ -227,7 +217,6 @@ export const dict = { "common.loading": "Indlæser", "common.loading.ellipsis": "...", "common.cancel": "Annuller", - "common.clear": "Ryd", "common.connect": "Forbind", "common.disconnect": "Frakobl", "common.continue": "Indsend", @@ -245,8 +234,6 @@ export const dict = { "prompt.mode.shell": "Shell", "prompt.mode.normal": "Prompt", "prompt.mode.shell.exit": "esc for at afslutte", - "session.child.promptDisabled": "Der kan ikke sendes prompts til underagentsessioner.", - "session.child.backToParent": "Tilbage til hovedsessionen.", "prompt.example.1": "Ret en TODO i koden", "prompt.example.2": "Hvad er teknologistakken for dette projekt?", @@ -286,17 +273,11 @@ export const dict = { "prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst", "prompt.context.removeFile": "Fjern fil fra kontekst", "prompt.action.attachFile": "Vedhæft fil", - "prompt.menu.addImagesAndFiles": "Tilføj filer og mere", - "prompt.menu.imagesAndFiles": "Billeder og filer", - "prompt.menu.commands": "Kommandoer", - "prompt.menu.context": "Kontekst", - "prompt.menu.shellCommand": "Shell-kommando", "prompt.attachment.remove": "Fjern vedhæftning", "prompt.action.send": "Send", "prompt.action.stop": "Stop", "prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning", - "prompt.toast.attachmentDuplicate.title": "Denne fil er allerede uploadet", "prompt.toast.pasteUnsupported.description": "Kun billeder, PDF'er eller tekstfiler kan vedhæftes her.", "prompt.toast.modelAgentRequired.title": "Vælg en agent og model", "prompt.toast.modelAgentRequired.description": "Vælg en agent og model før du sender en forespørgsel.", @@ -359,75 +340,6 @@ export const dict = { "dialog.server.menu.delete": "Slet", "dialog.server.current": "Nuværende server", "dialog.server.status.default": "Standard", - "wsl.server.add": "Tilføj WSL-server", - "wsl.server.addShort": "Tilføj WSL", - "wsl.server.label": "WSL", - "wsl.server.menu.label": "WSL-server", - "wsl.server.retryStart": "Prøv at starte igen", - "wsl.server.updating": "Opdaterer...", - "wsl.onboarding.step.distro": "Vælg distribution", - "wsl.onboarding.step.opencode": "OpenCode", - "wsl.onboarding.checkingRuntime": "Tjekker WSL...", - "wsl.onboarding.restartRequired": "Windows skal genstartes for at fuldføre installationen af WSL.", - "wsl.onboarding.ready": "WSL er klar.", - "wsl.onboarding.required": "WSL er påkrævet for at fortsætte.", - "wsl.onboarding.checkingDistros": "Tjekker distributioner...", - "wsl.onboarding.installingDistro": "Installerer {{distro}}...", - "wsl.onboarding.checkingDistro": "Tjekker {{distro}}...", - "wsl.onboarding.listingDistros": "Henter liste over distributioner...", - "wsl.onboarding.distroReady": "{{distro}} er klar.", - "wsl.onboarding.distroNotInstalled": "{{distro}} er ikke installeret endnu.", - "wsl.onboarding.openDistroOnce": "Åbn {{distro}} én gang for at fuldføre opsætningen.", - "wsl.onboarding.finishingDistro": "Fuldfører opsætningen af {{distro}}.", - "wsl.onboarding.pickDistro": "Vælg en distribution, eller installer en nedenfor.", - "wsl.onboarding.checkingOpencode": "Tjekker OpenCode...", - "wsl.onboarding.checkingOpencodeIn": "Tjekker OpenCode i {{distro}}...", - "wsl.onboarding.updatingOpencode": "Opdaterer OpenCode...", - "wsl.onboarding.updatingOpencodeIn": "Opdaterer OpenCode i {{distro}}...", - "wsl.onboarding.updateOpencodeIn": "Opdater OpenCode i {{distro}}.", - "wsl.onboarding.updateOpencode": "Opdater OpenCode", - "wsl.onboarding.opencodeReadyIn": "OpenCode er klar i {{distro}}.", - "wsl.onboarding.opencodeReady": "OpenCode er klar.", - "wsl.onboarding.installOpencodeIn": "Installer OpenCode i {{distro}}.", - "wsl.onboarding.installOpencode": "Installer OpenCode", - "wsl.onboarding.chooseDistroFirst": "Vælg først en distribution.", - "wsl.onboarding.loadFailed": "Kunne ikke indlæse WSL-status.", - "wsl.onboarding.loading": "Indlæser...", - "wsl.onboarding.installedDistros": "Installerede distributioner", - "wsl.onboarding.checkAgain": "Tjek igen", - "wsl.onboarding.distroStatus.ready": "Klar", - "wsl.onboarding.distroStatus.checking": "Tjekker...", - "wsl.onboarding.distroStatus.opencodeMissing": "OpenCode er ikke installeret", - "wsl.onboarding.distroStatus.missingTools": "Mangler bash, curl", - "wsl.onboarding.distroStatus.unsupported": "Ikke understøttet · Brug WSL 2", - "wsl.onboarding.needAnotherDistro": "Har du brug for en anden distribution?", - "wsl.onboarding.needAnotherDistroHint": "Installer en Linux-distribution fra WSL-kataloget", - "wsl.onboarding.wslNotInstalled.title": "WSL er ikke installeret", - "wsl.onboarding.wslNotInstalled.description": - "WSL (Windows Subsystem for Linux) er påkrævet, før OpenCode kan tilføje en WSL-server", - "wsl.onboarding.wslUnavailable.title": "WSL er ikke tilgængelig", - "wsl.onboarding.wslUnavailable.description": "OpenCode kunne ikke bekræfte WSL på denne maskine.", - "wsl.onboarding.installWsl": "Installer WSL", - "wsl.onboarding.windowsRestartRequired": - "Genstart Windows for at fuldføre installationen af WSL, og åbn derefter OpenCode igen.", - "wsl.onboarding.next": "Næste", - "wsl.onboarding.refresh": "Opdater", - "wsl.onboarding.allDistrosAdded": "Alle installerede distributioner er allerede tilføjet.", - "wsl.onboarding.noDistros": "Ingen distributioner fundet endnu.", - "wsl.onboarding.install": "Installer", - "wsl.onboarding.installing": "Installerer...", - "wsl.onboarding.installDistro": "Installer distribution", - "wsl.onboarding.searchDistros": "Søg efter distributioner", - "wsl.onboarding.wsl2Required": "WSL 2 er påkrævet.", - "wsl.onboarding.toolsRequired": "Denne distribution kræver bash og curl.", - "wsl.onboarding.openTerminal": "Åbn terminal", - "wsl.onboarding.path": "Sti: {{path}}", - "wsl.onboarding.notFound": "ikke fundet", - "wsl.onboarding.version": "Version: {{version}}", - "wsl.onboarding.unknown": "ukendt", - "wsl.onboarding.desktopVersion": "desktop {{version}}", - "wsl.onboarding.versionMismatch": "Den installerede version matcher ikke desktopappens version.", - "wsl.onboarding.adding": "Tilføjer...", "dialog.project.edit.title": "Rediger projekt", "dialog.project.edit.name": "Navn", @@ -491,7 +403,6 @@ export const dict = { "language.no": "Norsk", "language.br": "Português (Brasil)", "language.bs": "Bosanski", - "language.uk": "Українська", "language.th": "ไทย", "language.tr": "Türkçe", @@ -539,12 +450,10 @@ export const dict = { "error.page.title": "Noget gik galt", "error.page.description": "Der opstod en fejl under indlæsning af applikationen.", - "error.page.description.localServerStartup": "Der opstod en fejl under start af den lokale server.", "error.page.details.label": "Fejldetaljer", "error.page.action.restart": "Genstart", "error.page.action.report": "Rapportér fejl", "error.page.action.reported": "Fejl rapporteret", - "error.page.action.exportLogs": "Eksportér logfiler", "error.page.action.checking": "Tjekker...", "error.page.action.checkUpdates": "Tjek for opdateringer", "error.page.action.updateTo": "Opdater til {{version}}", @@ -592,33 +501,11 @@ export const dict = { "home.recentProjects": "Seneste projekter", "home.empty.title": "Ingen seneste projekter", "home.empty.description": "Kom i gang ved at åbne et lokalt projekt", - "home.title": "Hjem", - "home.projects": "Projekter", - "home.project.add": "Tilføj projekt", - "home.recentlyClosed": "Lukket for nylig", - "home.server.collapse": "Skjul serverprojekter", - "home.server.expand": "Udvid serverprojekter", - "home.sessions.search.placeholder": "Søg i sessioner", - "home.sessions.search.placeholder.scoped": "Søg i sessioner i {{scope}}", - "home.sessions.search.sessions": "Sessioner", - "home.sessions.search.noResults": "Ingen sessioner fundet for {{query}}", - "home.sessions.empty": "Intet her endnu", - "home.sessions.empty.description": "Opret en session for at komme i gang", - "home.sessions.group.today": "I dag", - "home.sessions.group.yesterday": "I går", - "home.sessions.group.older": "Ældre", - "home.providerTip": - "Opret forbindelse til mere end 75 udbydere for at bruge andre modeller, herunder Claude, GPT, Gemini og flere", "session.tab.session": "Session", "session.tab.review": "Gennemgang", "session.tab.context": "Kontekst", - "session.tab.unknown": "Ukendt session", "session.panel.reviewAndFiles": "Gennemgang og filer", - "session.error.notFound": "Denne session kan ikke findes", - "session.error.notFound.description": "Denne fane henviser til en session, der ikke længere findes på denne server.", - "session.error.notFound.closeTab": "Luk fane", - "session.error.serverConnection": "Kan ikke oprette forbindelse til denne server", "session.review.filesChanged": "{{count}} Filer ændret", "session.review.change.one": "Ændring", "session.review.change.other": "Ændringer", @@ -628,8 +515,6 @@ export const dict = { "session.review.noSnapshot": "Snapshot-sporing er deaktiveret i konfigurationen, så sessionsændringer er ikke tilgængelige", "session.review.noChanges": "Ingen ændringer", - "session.review.noUncommittedChanges": "Ingen ændringer uden commit endnu", - "session.review.noBranchChanges": "Ingen grenændringer endnu", "session.files.selectToOpen": "Vælg en fil at åbne", "session.files.all": "Alle filer", "session.files.empty": "Ingen filer", @@ -644,10 +529,6 @@ export const dict = { "session.todo.title": "Opgaver", "session.todo.collapse": "Skjul", "session.todo.expand": "Udvid", - "session.question.minimize": "Minimer spørgsmål", - "session.question.restore": "Gendan spørgsmål", - "session.question.pending.one": "{{count}} afventende spørgsmål", - "session.question.pending.other": "{{count}} afventende spørgsmål", "session.followupDock.summary.one": "{{count}} besked i kø", "session.followupDock.summary.other": "{{count}} beskeder i kø", "session.followupDock.sendNow": "Send nu", @@ -661,17 +542,9 @@ export const dict = { "session.revertDock.restore": "Gendan besked", "session.new.title": "Byg hvad som helst", - "session.new.project.new": "Nyt projekt", - "session.new.project.search": "Søg efter projekter", - "session.new.project.add": "Tilføj projekt", "session.new.worktree.main": "Hovedgren", "session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})", "session.new.worktree.create": "Opret nyt worktree", - "session.new.workspace.runIn": "Kør session i", - "session.new.workspace.triggerLocal": "Lokal", - "session.new.workspace.local": "Lokalt repository", - "session.new.workspace.existing": "Arbejdsområde…", - "session.new.git.none": "Ingen Git", "session.new.lastModified": "Sidst ændret", "session.header.search.placeholder": "Søg {{project}}", @@ -741,8 +614,6 @@ export const dict = { "sidebar.project.recentSessions": "Seneste sessioner", "sidebar.project.viewAllSessions": "Vis alle sessioner", "sidebar.project.clearNotifications": "Ryd notifikationer", - "sidebar.empty.title": "Ingen åbne projekter", - "sidebar.empty.description": "Åbn et projekt for at komme i gang", "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Desktop", @@ -754,7 +625,6 @@ export const dict = { "settings.desktop.wsl.description": "Kør OpenCode-serveren inde i WSL på Windows.", "settings.general.section.appearance": "Udseende", - "settings.general.section.advanced": "Avanceret", "settings.general.section.notifications": "Systemmeddelelser", "settings.general.section.updates": "Opdateringer", "settings.general.section.sounds": "Lydeffekter", @@ -763,11 +633,6 @@ export const dict = { "settings.general.row.language.title": "Sprog", "settings.general.row.language.description": "Ændr visningssproget for OpenCode", - "settings.general.row.shell.title": "Terminalshell", - "settings.general.row.shell.description": - "Vælg den shell, der bruges i din terminal. Kompatible shells bruges også til agentens værktøjskald.", - "settings.general.row.shell.autoDefault": "Automatisk (standard)", - "settings.general.row.shell.terminalOnly": "kun terminal", "settings.general.row.appearance.title": "Udseende", "settings.general.row.appearance.description": "Tilpas hvordan OpenCode ser ud på din enhed", "settings.general.row.colorScheme.title": "Farveskema", @@ -776,29 +641,14 @@ export const dict = { "settings.general.row.theme.description": "Tilpas hvordan OpenCode er temabestemt.", "settings.general.row.font.title": "Kode-skrifttype", "settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke", - "settings.general.row.terminalFont.title": "Terminalskrifttype", - "settings.general.row.terminalFont.description": "Tilpas den skrifttype, der bruges i terminalen", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "UI-skrifttype", "settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen", "settings.general.row.followup.title": "Opfølgningsadfærd", "settings.general.row.followup.description": "Vælg om opfølgende forespørgsler skal styre straks eller vente i kø", "settings.general.row.followup.option.queue": "Kø", "settings.general.row.followup.option.steer": "Styr", - "settings.general.row.showFileTree.title": "Filtræ", - "settings.general.row.showFileTree.description": "Vis filtræspanelet i sessioner", - "settings.general.row.showNavigation.title": "Navigationsknapper", - "settings.general.row.showNavigation.description": "Vis tilbage- og frem-knapperne i desktopappens titellinje", - "settings.general.row.showSearch.title": "Kommandopalette", - "settings.general.row.showSearch.description": "Vis knappen til søgning og kommandopaletten i titellinjen", - "settings.general.row.showTerminal.title": "Terminal", - "settings.general.row.showTerminal.description": "Vis terminalknappen i desktopappens titellinje", - "settings.general.row.showStatus.title": "Serverstatus", - "settings.general.row.showStatus.description": "Vis serverstatusknappen i titellinjen", - "settings.general.row.mobileTitlebarBottom.title": "Navigation nederst", - "settings.general.row.mobileTitlebarBottom.description": - "Placer titellinjen og sessionsfanerne nederst på skærmen på mobilenheder", - "settings.general.row.showCustomAgents.title": "Brugerdefinerede agenter", - "settings.general.row.showCustomAgents.description": "Vis agentvælgeren i promptfeltet", "settings.general.row.reasoningSummaries.title": "Vis tænkeoversigter", "settings.general.row.reasoningSummaries.description": "Vis model tænkeoversigter i tidslinjen", @@ -807,15 +657,6 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele", "settings.general.row.editToolPartsExpanded.description": "Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen", - "settings.general.row.newInterface.title": "Nyt layout", - "settings.general.row.newInterface.badge": "Ny", - "settings.general.row.newInterface.description": - "Brug de nye faner og startsidens layout. Du kan skifte mellem layoutene i en begrænset periode.", - "settings.general.row.newInterfaceNotice.title": "Du bruger nu det nye layout", - "settings.general.row.newInterfaceNotice.description": "Det tidligere layout er ikke længere tilgængeligt", - "settings.general.row.newInterfaceNotice.dismiss": "Afvis", - "settings.general.row.pinchZoom.title": "Knib for at zoome", - "settings.general.row.pinchZoom.description": "Tillad knibebevægelser på pegefeltet og Ctrl-rulning for at zoome", "settings.general.row.wayland.title": "Brug native Wayland", "settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.", "settings.general.row.wayland.tooltip": @@ -830,8 +671,6 @@ export const dict = { "settings.updates.row.check.description": "Tjek manuelt for opdateringer og installer, hvis tilgængelig", "settings.updates.action.checkNow": "Tjek nu", "settings.updates.action.checking": "Tjekker...", - "settings.updates.action.downloading": "Downloader...", - "settings.updates.action.installing": "Installerer...", "settings.updates.toast.latest.title": "Du er opdateret", "settings.updates.toast.latest.description": "Du kører den nyeste version af OpenCode.", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 8619546079..d0ec9507e0 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -25,9 +25,6 @@ export const dict = { "theme.scheme.dark": "Dunkel", "command.sidebar.toggle": "Seitenleiste umschalten", "command.project.open": "Projekt öffnen", - "command.project.previous": "Vorheriges Projekt", - "command.project.next": "Nächstes Projekt", - "command.project.index": "Zu Projekt {{index}} wechseln", "command.provider.connect": "Anbieter verbinden", "command.server.switch": "Server wechseln", "command.settings.open": "Einstellungen öffnen", @@ -46,7 +43,6 @@ export const dict = { "command.session.new": "Neue Sitzung", "command.file.open": "Datei öffnen", "command.tab.close": "Tab schließen", - "command.tab.reopenClosed": "Geschlossenen Tab wieder öffnen", "command.context.addSelection": "Auswahl zum Kontext hinzufügen", "command.context.addSelection.description": "Ausgewählte Zeilen aus der aktuellen Datei hinzufügen", "command.input.focus": "Eingabefeld fokussieren", @@ -90,7 +86,6 @@ export const dict = { "command.session.unshare": "Teilen der Sitzung aufheben", "command.session.unshare.description": "Teilen dieser Sitzung beenden", "palette.search.placeholder": "Dateien, Befehle und Sitzungen durchsuchen", - "palette.search.placeholder.home": "Befehle und Sitzungen durchsuchen", "palette.empty": "Keine Ergebnisse gefunden", "palette.group.commands": "Befehle", "palette.group.files": "Dateien", @@ -203,11 +198,6 @@ export const dict = { "model.input.video": "Video", "model.input.pdf": "PDF", "model.tooltip.allows": "Erlaubt: {{inputs}}", - "model.tooltip.context.label": "Kontext", - "model.tooltip.inputs": "Eingaben", - "model.tooltip.model": "Modell", - "model.tooltip.provider": "Anbieter", - "model.tooltip.reasoning": "Reasoning", "model.tooltip.reasoning.allowed": "Erlaubt Reasoning", "model.tooltip.reasoning.none": "Kein Reasoning", "model.tooltip.context": "Kontextlimit {{limit}}", @@ -217,7 +207,6 @@ export const dict = { "common.loading": "Laden", "common.loading.ellipsis": "...", "common.cancel": "Abbrechen", - "common.clear": "Leeren", "common.connect": "Verbinden", "common.disconnect": "Trennen", "common.continue": "Absenden", @@ -234,8 +223,6 @@ export const dict = { "prompt.mode.shell": "Shell", "prompt.mode.normal": "Prompt", "prompt.mode.shell.exit": "esc zum Verlassen", - "session.child.promptDisabled": "Unteragenten-Sitzungen können keine Prompts erhalten.", - "session.child.backToParent": "Zurück zur Hauptsitzung.", "prompt.example.1": "Ein TODO in der Codebasis beheben", "prompt.example.2": "Was ist der Tech-Stack dieses Projekts?", "prompt.example.3": "Fehlerhafte Tests beheben", @@ -273,16 +260,10 @@ export const dict = { "prompt.context.removeActiveFile": "Aktive Datei aus dem Kontext entfernen", "prompt.context.removeFile": "Datei aus dem Kontext entfernen", "prompt.action.attachFile": "Datei anhängen", - "prompt.menu.addImagesAndFiles": "Dateien und mehr hinzufügen", - "prompt.menu.imagesAndFiles": "Bilder und Dateien", - "prompt.menu.commands": "Befehle", - "prompt.menu.context": "Kontext", - "prompt.menu.shellCommand": "Shell-Befehl", "prompt.attachment.remove": "Anhang entfernen", "prompt.action.send": "Senden", "prompt.action.stop": "Stopp", "prompt.toast.pasteUnsupported.title": "Nicht unterstützter Anhang", - "prompt.toast.attachmentDuplicate.title": "Diese Datei wurde bereits hochgeladen", "prompt.toast.pasteUnsupported.description": "Hier können nur Bilder, PDFs oder Textdateien angehängt werden.", "prompt.toast.modelAgentRequired.title": "Wählen Sie einen Agenten und ein Modell", "prompt.toast.modelAgentRequired.description": @@ -339,75 +320,6 @@ export const dict = { "dialog.server.menu.delete": "Löschen", "dialog.server.current": "Aktueller Server", "dialog.server.status.default": "Standard", - "wsl.server.add": "WSL-Server hinzufügen", - "wsl.server.addShort": "WSL hinzufügen", - "wsl.server.label": "WSL", - "wsl.server.menu.label": "WSL-Server", - "wsl.server.retryStart": "Start erneut versuchen", - "wsl.server.updating": "Wird aktualisiert...", - "wsl.onboarding.step.distro": "Distribution auswählen", - "wsl.onboarding.step.opencode": "OpenCode", - "wsl.onboarding.checkingRuntime": "WSL wird geprüft...", - "wsl.onboarding.restartRequired": "Windows muss neu gestartet werden, um die WSL-Installation abzuschließen.", - "wsl.onboarding.ready": "WSL ist bereit.", - "wsl.onboarding.required": "WSL ist erforderlich, um fortzufahren.", - "wsl.onboarding.checkingDistros": "Distributionen werden geprüft...", - "wsl.onboarding.installingDistro": "{{distro}} wird installiert...", - "wsl.onboarding.checkingDistro": "{{distro}} wird geprüft...", - "wsl.onboarding.listingDistros": "Distributionen werden aufgelistet...", - "wsl.onboarding.distroReady": "{{distro}} ist bereit.", - "wsl.onboarding.distroNotInstalled": "{{distro}} ist noch nicht installiert.", - "wsl.onboarding.openDistroOnce": "Öffnen Sie {{distro}} einmal, um die Einrichtung abzuschließen.", - "wsl.onboarding.finishingDistro": "Einrichtung von {{distro}} wird abgeschlossen.", - "wsl.onboarding.pickDistro": "Wählen Sie eine Distribution aus oder installieren Sie unten eine.", - "wsl.onboarding.checkingOpencode": "OpenCode wird geprüft...", - "wsl.onboarding.checkingOpencodeIn": "OpenCode in {{distro}} wird geprüft...", - "wsl.onboarding.updatingOpencode": "OpenCode wird aktualisiert...", - "wsl.onboarding.updatingOpencodeIn": "OpenCode in {{distro}} wird aktualisiert...", - "wsl.onboarding.updateOpencodeIn": "Aktualisieren Sie OpenCode in {{distro}}.", - "wsl.onboarding.updateOpencode": "OpenCode aktualisieren", - "wsl.onboarding.opencodeReadyIn": "OpenCode ist in {{distro}} bereit.", - "wsl.onboarding.opencodeReady": "OpenCode ist bereit.", - "wsl.onboarding.installOpencodeIn": "Installieren Sie OpenCode in {{distro}}.", - "wsl.onboarding.installOpencode": "OpenCode installieren", - "wsl.onboarding.chooseDistroFirst": "Wählen Sie zuerst eine Distribution aus.", - "wsl.onboarding.loadFailed": "WSL-Status konnte nicht geladen werden.", - "wsl.onboarding.loading": "Wird geladen...", - "wsl.onboarding.installedDistros": "Installierte Distributionen", - "wsl.onboarding.checkAgain": "Erneut prüfen", - "wsl.onboarding.distroStatus.ready": "Bereit", - "wsl.onboarding.distroStatus.checking": "Wird geprüft...", - "wsl.onboarding.distroStatus.opencodeMissing": "OpenCode nicht installiert", - "wsl.onboarding.distroStatus.missingTools": "Fehlend: bash, curl", - "wsl.onboarding.distroStatus.unsupported": "Nicht unterstützt · WSL 2 verwenden", - "wsl.onboarding.needAnotherDistro": "Andere Distribution benötigt?", - "wsl.onboarding.needAnotherDistroHint": "Installieren Sie eine Linux-Distribution aus dem WSL-Katalog", - "wsl.onboarding.wslNotInstalled.title": "WSL nicht installiert", - "wsl.onboarding.wslNotInstalled.description": - "WSL (Windows Subsystem for Linux) ist erforderlich, bevor OpenCode einen WSL-Server hinzufügen kann", - "wsl.onboarding.wslUnavailable.title": "WSL nicht verfügbar", - "wsl.onboarding.wslUnavailable.description": "OpenCode konnte WSL auf diesem Computer nicht überprüfen.", - "wsl.onboarding.installWsl": "WSL installieren", - "wsl.onboarding.windowsRestartRequired": - "Starten Sie Windows neu, um die WSL-Installation abzuschließen, und öffnen Sie dann OpenCode erneut.", - "wsl.onboarding.next": "Weiter", - "wsl.onboarding.refresh": "Aktualisieren", - "wsl.onboarding.allDistrosAdded": "Alle installierten Distributionen wurden bereits hinzugefügt.", - "wsl.onboarding.noDistros": "Noch keine Distributionen erkannt.", - "wsl.onboarding.install": "Installieren", - "wsl.onboarding.installing": "Wird installiert...", - "wsl.onboarding.installDistro": "Distribution installieren", - "wsl.onboarding.searchDistros": "Distributionen durchsuchen", - "wsl.onboarding.wsl2Required": "WSL 2 ist erforderlich.", - "wsl.onboarding.toolsRequired": "Diese Distribution benötigt bash und curl.", - "wsl.onboarding.openTerminal": "Terminal öffnen", - "wsl.onboarding.path": "Pfad: {{path}}", - "wsl.onboarding.notFound": "nicht gefunden", - "wsl.onboarding.version": "Version: {{version}}", - "wsl.onboarding.unknown": "unbekannt", - "wsl.onboarding.desktopVersion": "Desktop {{version}}", - "wsl.onboarding.versionMismatch": "Die installierte Version entspricht nicht der Version der Desktop-App.", - "wsl.onboarding.adding": "Wird hinzugefügt...", "dialog.project.edit.title": "Projekt bearbeiten", "dialog.project.edit.name": "Name", "dialog.project.edit.icon": "Icon", @@ -466,7 +378,6 @@ export const dict = { "language.no": "Norsk", "language.br": "Português (Brasil)", "language.bs": "Bosanski", - "language.uk": "Українська", "language.th": "ไทย", "language.tr": "Türkçe", "toast.language.title": "Sprache", @@ -503,12 +414,10 @@ export const dict = { "toast.update.action.notYet": "Noch nicht", "error.page.title": "Etwas ist schiefgelaufen", "error.page.description": "Beim Laden der Anwendung ist ein Fehler aufgetreten.", - "error.page.description.localServerStartup": "Beim Starten des lokalen Servers ist ein Fehler aufgetreten.", "error.page.details.label": "Fehlerdetails", "error.page.action.restart": "Neustart", "error.page.action.report": "Fehler melden", "error.page.action.reported": "Fehler gemeldet", - "error.page.action.exportLogs": "Protokolle exportieren", "error.page.action.checking": "Prüfen...", "error.page.action.checkUpdates": "Nach Updates suchen", "error.page.action.updateTo": "Auf {{version}} aktualisieren", @@ -552,33 +461,10 @@ export const dict = { "home.recentProjects": "Letzte Projekte", "home.empty.title": "Keine letzten Projekte", "home.empty.description": "Starten Sie, indem Sie ein lokales Projekt öffnen", - "home.title": "Startseite", - "home.projects": "Projekte", - "home.project.add": "Projekt hinzufügen", - "home.recentlyClosed": "Kürzlich geschlossen", - "home.server.collapse": "Serverprojekte einklappen", - "home.server.expand": "Serverprojekte ausklappen", - "home.sessions.search.placeholder": "Sitzungen durchsuchen", - "home.sessions.search.placeholder.scoped": "Sitzungen in {{scope}} durchsuchen", - "home.sessions.search.sessions": "Sitzungen", - "home.sessions.search.noResults": "Keine Sitzungen für {{query}} gefunden", - "home.sessions.empty": "Hier ist noch nichts", - "home.sessions.empty.description": "Erstellen Sie eine Sitzung, um loszulegen", - "home.sessions.group.today": "Heute", - "home.sessions.group.yesterday": "Gestern", - "home.sessions.group.older": "Älter", - "home.providerTip": - "Verbinde dich mit über 75 Anbietern, um weitere Modelle wie Claude, GPT, Gemini und andere zu nutzen", "session.tab.session": "Sitzung", "session.tab.review": "Überprüfung", "session.tab.context": "Kontext", - "session.tab.unknown": "Unbekannte Sitzung", "session.panel.reviewAndFiles": "Überprüfung und Dateien", - "session.error.notFound": "Diese Sitzung wurde nicht gefunden", - "session.error.notFound.description": - "Dieser Tab verweist auf eine Sitzung, die auf diesem Server nicht mehr existiert.", - "session.error.notFound.closeTab": "Tab schließen", - "session.error.serverConnection": "Verbindung zu diesem Server nicht möglich", "session.review.filesChanged": "{{count}} Dateien geändert", "session.review.change.one": "Änderung", "session.review.change.other": "Änderungen", @@ -588,8 +474,6 @@ export const dict = { "session.review.noSnapshot": "Snapshot-Tracking ist in der Konfiguration deaktiviert, daher sind Sitzungsänderungen nicht verfügbar", "session.review.noChanges": "Keine Änderungen", - "session.review.noUncommittedChanges": "Noch keine nicht committeten Änderungen", - "session.review.noBranchChanges": "Noch keine Branch-Änderungen", "session.files.selectToOpen": "Datei zum Öffnen auswählen", "session.files.all": "Alle Dateien", "session.files.empty": "Keine Dateien", @@ -603,10 +487,6 @@ export const dict = { "session.todo.title": "Aufgaben", "session.todo.collapse": "Einklappen", "session.todo.expand": "Ausklappen", - "session.question.minimize": "Frage minimieren", - "session.question.restore": "Frage wiederherstellen", - "session.question.pending.one": "{{count}} ausstehende Frage", - "session.question.pending.other": "{{count}} ausstehende Fragen", "session.followupDock.summary.one": "{{count}} Nachricht in der Warteschlange", "session.followupDock.summary.other": "{{count}} Nachrichten in der Warteschlange", "session.followupDock.sendNow": "Jetzt senden", @@ -619,17 +499,9 @@ export const dict = { "session.revertDock.expand": "Zurückgesetzte Nachrichten ausklappen", "session.revertDock.restore": "Nachricht wiederherstellen", "session.new.title": "Baue, was du willst", - "session.new.project.new": "Neues Projekt", - "session.new.project.search": "Projekte durchsuchen", - "session.new.project.add": "Projekt hinzufügen", "session.new.worktree.main": "Haupt-Branch", "session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})", "session.new.worktree.create": "Neuen Worktree erstellen", - "session.new.workspace.runIn": "Sitzung ausführen in", - "session.new.workspace.triggerLocal": "Lokal", - "session.new.workspace.local": "Lokales Repository", - "session.new.workspace.existing": "Arbeitsbereich…", - "session.new.git.none": "Kein Git", "session.new.lastModified": "Zuletzt geändert", "session.header.search.placeholder": "{{project}} durchsuchen", "session.header.searchFiles": "Dateien suchen", @@ -694,8 +566,6 @@ export const dict = { "sidebar.project.recentSessions": "Letzte Sitzungen", "sidebar.project.viewAllSessions": "Alle Sitzungen anzeigen", "sidebar.project.clearNotifications": "Benachrichtigungen löschen", - "sidebar.empty.title": "Keine Projekte geöffnet", - "sidebar.empty.description": "Öffnen Sie ein Projekt, um loszulegen", "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Desktop", "settings.section.server": "Server", @@ -705,7 +575,6 @@ export const dict = { "settings.desktop.wsl.title": "WSL-Integration", "settings.desktop.wsl.description": "OpenCode-Server innerhalb von WSL unter Windows ausführen.", "settings.general.section.appearance": "Erscheinungsbild", - "settings.general.section.advanced": "Erweitert", "settings.general.section.notifications": "Systembenachrichtigungen", "settings.general.section.updates": "Updates", "settings.general.section.sounds": "Soundeffekte", @@ -713,11 +582,6 @@ export const dict = { "settings.general.section.display": "Anzeige", "settings.general.row.language.title": "Sprache", "settings.general.row.language.description": "Die Anzeigesprache für OpenCode ändern", - "settings.general.row.shell.title": "Terminal-Shell", - "settings.general.row.shell.description": - "Wählen Sie die Shell für Ihr Terminal aus. Kompatible Shells werden auch für Tool-Aufrufe von Agenten verwendet.", - "settings.general.row.shell.autoDefault": "Automatisch (Standard)", - "settings.general.row.shell.terminalOnly": "nur Terminal", "settings.general.row.appearance.title": "Erscheinungsbild", "settings.general.row.appearance.description": "Anpassen, wie OpenCode auf Ihrem Gerät aussieht", "settings.general.row.colorScheme.title": "Farbschema", @@ -727,8 +591,8 @@ export const dict = { "settings.general.row.theme.description": "Das Thema von OpenCode anpassen.", "settings.general.row.font.title": "Code-Schriftart", "settings.general.row.font.description": "Die in Codeblöcken verwendete Schriftart anpassen", - "settings.general.row.terminalFont.title": "Terminalschriftart", - "settings.general.row.terminalFont.description": "Passe die im Terminal verwendete Schriftart an", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "UI-Schriftart", "settings.general.row.uiFont.description": "Die im gesamten Interface verwendete Schriftart anpassen", "settings.general.row.followup.title": "Verhalten bei Folgefragen", @@ -736,23 +600,6 @@ export const dict = { "Wählen Sie, ob Folgefragen sofort steuern oder in einer Warteschlange warten", "settings.general.row.followup.option.queue": "Warteschlange", "settings.general.row.followup.option.steer": "Steuern", - "settings.general.row.showFileTree.title": "Dateibaum", - "settings.general.row.showFileTree.description": "Dateibaum in Sitzungen anzeigen", - "settings.general.row.showNavigation.title": "Navigationssteuerung", - "settings.general.row.showNavigation.description": - "Schaltflächen zum Vor- und Zurücknavigieren in der Desktop-Titelleiste anzeigen", - "settings.general.row.showSearch.title": "Befehlspalette", - "settings.general.row.showSearch.description": - "Schaltfläche für Suche und Befehlspalette in der Titelleiste anzeigen", - "settings.general.row.showTerminal.title": "Terminal", - "settings.general.row.showTerminal.description": "Terminal-Schaltfläche in der Desktop-Titelleiste anzeigen", - "settings.general.row.showStatus.title": "Serverstatus", - "settings.general.row.showStatus.description": "Serverstatus-Schaltfläche in der Titelleiste anzeigen", - "settings.general.row.mobileTitlebarBottom.title": "Navigation unten", - "settings.general.row.mobileTitlebarBottom.description": - "Titelleiste und Sitzungs-Tabs auf Mobilgeräten am unteren Bildschirmrand platzieren", - "settings.general.row.showCustomAgents.title": "Benutzerdefinierte Agenten", - "settings.general.row.showCustomAgents.description": "Agentenauswahl im Eingabebereich anzeigen", "settings.general.row.reasoningSummaries.title": "Reasoning-Zusammenfassungen anzeigen", "settings.general.row.reasoningSummaries.description": "Zusammenfassungen des Modell-Reasonings in der Timeline anzeigen", @@ -762,15 +609,6 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen", "settings.general.row.editToolPartsExpanded.description": "Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen", - "settings.general.row.newInterface.title": "Neues Layout", - "settings.general.row.newInterface.badge": "Neu", - "settings.general.row.newInterface.description": - "Verwenden Sie die neuen Tabs und das Startseitenlayout. Für begrenzte Zeit können Sie zwischen den Layouts wechseln.", - "settings.general.row.newInterfaceNotice.title": "Sie verwenden jetzt das neue Layout", - "settings.general.row.newInterfaceNotice.description": "Das vorherige Layout ist nicht mehr verfügbar", - "settings.general.row.newInterfaceNotice.dismiss": "Verwerfen", - "settings.general.row.pinchZoom.title": "Zoom per Fingergeste", - "settings.general.row.pinchZoom.description": "Zoomen per Zwei-Finger- und Ctrl-Scroll-Geste erlauben", "settings.general.row.wayland.title": "Natives Wayland verwenden", "settings.general.row.wayland.description": "X11-Fallback unter Wayland deaktivieren. Erfordert Neustart.", "settings.general.row.wayland.tooltip": @@ -783,8 +621,6 @@ export const dict = { "settings.updates.row.check.description": "Manuell nach Updates suchen und installieren, wenn verfügbar", "settings.updates.action.checkNow": "Jetzt prüfen", "settings.updates.action.checking": "Wird geprüft...", - "settings.updates.action.downloading": "Wird heruntergeladen...", - "settings.updates.action.installing": "Wird installiert...", "settings.updates.toast.latest.title": "Du bist auf dem neuesten Stand", "settings.updates.toast.latest.description": "Du verwendest die aktuelle Version von OpenCode.", "sound.option.none": "Keine", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 76623f4468..0124fcf6ed 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -48,7 +48,6 @@ export const dict = { "command.session.new": "New session", "command.file.open": "Open file", "command.tab.close": "Close tab", - "command.tab.reopenClosed": "Reopen closed tab", "command.context.addSelection": "Add selection to context", "command.context.addSelection.description": "Add selected lines from the current file", "command.input.focus": "Focus input", @@ -93,7 +92,6 @@ export const dict = { "command.session.unshare.description": "Stop sharing this session", "palette.search.placeholder": "Search files, commands, and sessions", - "palette.search.placeholder.home": "Search commands and sessions", "palette.empty": "No results found", "palette.group.commands": "Commands", "palette.group.files": "Files", @@ -102,7 +100,6 @@ export const dict = { "dialog.provider.empty": "No providers found", "dialog.provider.group.popular": "Popular", "dialog.provider.group.other": "Other", - "dialog.provider.custom.label": "Custom OpenAI-compatible provider", "dialog.provider.tag.recommended": "Recommended", "dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more", "dialog.provider.opencode.tagline": "Reliable optimized models", @@ -123,7 +120,6 @@ export const dict = { "dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode", "dialog.model.unpaid.addMore.title": "Add more models from popular providers", - "dialog.model.unpaid.viewMoreProviders": "See 70+ more providers", "dialog.provider.viewAll": "Show more providers", @@ -215,11 +211,6 @@ export const dict = { "model.input.video": "video", "model.input.pdf": "pdf", "model.tooltip.allows": "Allows: {{inputs}}", - "model.tooltip.context.label": "Context", - "model.tooltip.inputs": "Inputs", - "model.tooltip.model": "Model", - "model.tooltip.provider": "Provider", - "model.tooltip.reasoning": "Reasoning", "model.tooltip.reasoning.allowed": "Allows reasoning", "model.tooltip.reasoning.none": "No reasoning", "model.tooltip.context": "Context limit {{limit}}", @@ -290,18 +281,12 @@ export const dict = { "prompt.context.removeActiveFile": "Remove active file from context", "prompt.context.removeFile": "Remove file from context", "prompt.action.attachFile": "Add files", - "prompt.menu.addImagesAndFiles": "Add files and more", - "prompt.menu.imagesAndFiles": "Images and files", - "prompt.menu.commands": "Commands", - "prompt.menu.context": "Context", - "prompt.menu.shellCommand": "Shell command", "prompt.attachment.remove": "Remove attachment", "prompt.action.send": "Send", "prompt.action.stop": "Stop", "prompt.toast.pasteUnsupported.title": "Unsupported attachment", "prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.", - "prompt.toast.attachmentDuplicate.title": "This file has already been uploaded", "prompt.toast.modelAgentRequired.title": "Select an agent and model", "prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.", "prompt.toast.worktreeCreateFailed.title": "Failed to create worktree", @@ -619,7 +604,6 @@ export const dict = { "home.title": "Home", "home.projects": "Projects", "home.project.add": "Add project", - "home.recentlyClosed": "Recently closed", "home.server.collapse": "Collapse server projects", "home.server.expand": "Expand server projects", "home.sessions.search.placeholder": "Search sessions", @@ -631,7 +615,6 @@ export const dict = { "home.sessions.group.today": "Today", "home.sessions.group.yesterday": "Yesterday", "home.sessions.group.older": "Older", - "home.providerTip": "Connect to 75+ providers to use other models, including Claude, GPT, Gemini, etc", "session.tab.session": "Session", "session.tab.review": "Review", @@ -642,7 +625,7 @@ export const dict = { "session.error.notFound.description": "This tab points to a session that no longer exists on this server.", "session.error.notFound.closeTab": "Close Tab", "session.error.serverConnection": "Can't connect to this server", - "session.review.filesChanged": "Files Changed {{count}}", + "session.review.filesChanged": "{{count}} Files Changed", "session.review.change.one": "Change", "session.review.change.other": "Changes", "session.review.loadingChanges": "Loading changes...", @@ -701,7 +684,6 @@ export const dict = { "session.new.workspace.triggerLocal": "Local", "session.new.workspace.local": "Local repository", "session.new.workspace.existing": "Workspace…", - "session.new.git.none": "No Git", "session.new.lastModified": "Last modified", "session.header.search.placeholder": "Search {{project}}", @@ -714,9 +696,6 @@ export const dict = { "session.header.open.finder": "Finder", "session.header.open.fileExplorer": "File Explorer", "session.header.open.fileManager": "File Manager", - "session.header.reveal.finder": "Reveal in Finder", - "session.header.reveal.fileExplorer": "Reveal in File Explorer", - "session.header.reveal.containingFolder": "Open containing folder", "session.header.open.app.vscode": "VS Code", "session.header.open.app.cursor": "Cursor", "session.header.open.app.zed": "Zed", @@ -860,8 +839,9 @@ export const dict = { "settings.general.row.language.title": "Language", "settings.general.row.language.description": "Change the display language for OpenCode", - "settings.general.row.shell.title": "Terminal shell", - "settings.general.row.shell.description": "Shell used by the terminal and agent tools", + "settings.general.row.shell.title": "Terminal Shell", + "settings.general.row.shell.description": + "Choose the shell used for your terminal. Compatible shells are also used for agent tool calls.", "settings.general.row.shell.autoDefault": "Auto (Default)", "settings.general.row.shell.terminalOnly": "terminal only", "settings.general.row.appearance.title": "Appearance", @@ -893,9 +873,8 @@ export const dict = { "settings.general.row.mobileTitlebarBottom.title": "Bottom navigation", "settings.general.row.mobileTitlebarBottom.description": "Place the title bar and session tabs at the bottom of the screen on mobile", - "settings.general.row.showCustomAgents.title": "Show agent", - "settings.general.row.showCustomAgents.description": - "Switch between agents in the composer. When hidden, defaults to Build agent.", + "settings.general.row.showCustomAgents.title": "Custom agents", + "settings.general.row.showCustomAgents.description": "Show the agent picker in the composer", "settings.general.row.reasoningSummaries.title": "Show reasoning summaries", "settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline", "settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts", @@ -904,13 +883,8 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts", "settings.general.row.editToolPartsExpanded.description": "Show edit, write, and patch tool parts expanded by default in the timeline", - "settings.general.row.newInterface.title": "New layout", - "settings.general.row.newInterface.badge": "New", - "settings.general.row.newInterface.description": - "Use the new tabs and home layout. Switch between layouts for a limited time.", - "settings.general.row.newInterfaceNotice.title": "You're now using new layout", - "settings.general.row.newInterfaceNotice.description": "The previous layout is no longer available", - "settings.general.row.newInterfaceNotice.dismiss": "Dismiss", + "settings.general.row.newLayoutDesigns.title": "New layout and designs", + "settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI", "settings.general.row.pinchZoom.title": "Pinch to zoom", "settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 043c997c04..a6371d7312 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -23,9 +23,6 @@ export const dict = { "command.sidebar.toggle": "Alternar barra lateral", "command.project.open": "Abrir proyecto", - "command.project.previous": "Proyecto anterior", - "command.project.next": "Siguiente proyecto", - "command.project.index": "Cambiar al proyecto {{index}}", "command.provider.connect": "Conectar proveedor", "command.server.switch": "Cambiar servidor", "command.settings.open": "Abrir ajustes", @@ -48,7 +45,6 @@ export const dict = { "command.session.new": "Nueva sesión", "command.file.open": "Abrir archivo", "command.tab.close": "Cerrar pestaña", - "command.tab.reopenClosed": "Reabrir pestaña cerrada", "command.context.addSelection": "Añadir selección al contexto", "command.context.addSelection.description": "Añadir las líneas seleccionadas del archivo actual", "command.input.focus": "Enfocar entrada", @@ -93,7 +89,6 @@ export const dict = { "command.session.unshare.description": "Dejar de compartir esta sesión", "palette.search.placeholder": "Buscar archivos, comandos y sesiones", - "palette.search.placeholder.home": "Buscar comandos y sesiones", "palette.empty": "No se encontraron resultados", "palette.group.commands": "Comandos", "palette.group.files": "Archivos", @@ -213,11 +208,6 @@ export const dict = { "model.input.video": "video", "model.input.pdf": "pdf", "model.tooltip.allows": "Permite: {{inputs}}", - "model.tooltip.context.label": "Contexto", - "model.tooltip.inputs": "Entradas", - "model.tooltip.model": "Modelo", - "model.tooltip.provider": "Proveedor", - "model.tooltip.reasoning": "Razonamiento", "model.tooltip.reasoning.allowed": "Permite razonamiento", "model.tooltip.reasoning.none": "Sin razonamiento", "model.tooltip.context": "Límite de contexto {{limit}}", @@ -228,7 +218,6 @@ export const dict = { "common.loading": "Cargando", "common.loading.ellipsis": "...", "common.cancel": "Cancelar", - "common.clear": "Limpiar", "common.connect": "Conectar", "common.disconnect": "Desconectar", "common.continue": "Enviar", @@ -246,8 +235,6 @@ export const dict = { "prompt.mode.shell": "Shell", "prompt.mode.normal": "Prompt", "prompt.mode.shell.exit": "esc para salir", - "session.child.promptDisabled": "No se pueden enviar prompts a las sesiones de subagentes.", - "session.child.backToParent": "Volver a la sesión principal.", "prompt.example.1": "Arreglar un TODO en el código", "prompt.example.2": "¿Cuál es el stack tecnológico de este proyecto?", @@ -287,17 +274,11 @@ export const dict = { "prompt.context.removeActiveFile": "Eliminar archivo activo del contexto", "prompt.context.removeFile": "Eliminar archivo del contexto", "prompt.action.attachFile": "Adjuntar archivo", - "prompt.menu.addImagesAndFiles": "Añadir archivos y más", - "prompt.menu.imagesAndFiles": "Imágenes y archivos", - "prompt.menu.commands": "Comandos", - "prompt.menu.context": "Contexto", - "prompt.menu.shellCommand": "Comando de shell", "prompt.attachment.remove": "Eliminar adjunto", "prompt.action.send": "Enviar", "prompt.action.stop": "Detener", "prompt.toast.pasteUnsupported.title": "Adjunto no compatible", - "prompt.toast.attachmentDuplicate.title": "Este archivo ya se ha subido", "prompt.toast.pasteUnsupported.description": "Solo se pueden adjuntar imágenes, PDFs o archivos de texto aquí.", "prompt.toast.modelAgentRequired.title": "Selecciona un agente y modelo", "prompt.toast.modelAgentRequired.description": "Elige un agente y modelo antes de enviar un prompt.", @@ -360,75 +341,6 @@ export const dict = { "dialog.server.menu.delete": "Eliminar", "dialog.server.current": "Servidor actual", "dialog.server.status.default": "Predeterminado", - "wsl.server.add": "Añadir servidor WSL", - "wsl.server.addShort": "Añadir WSL", - "wsl.server.label": "WSL", - "wsl.server.menu.label": "Servidor WSL", - "wsl.server.retryStart": "Reintentar el inicio", - "wsl.server.updating": "Actualizando...", - "wsl.onboarding.step.distro": "Elegir distribución", - "wsl.onboarding.step.opencode": "OpenCode", - "wsl.onboarding.checkingRuntime": "Comprobando WSL...", - "wsl.onboarding.restartRequired": "Es necesario reiniciar Windows para terminar de instalar WSL.", - "wsl.onboarding.ready": "WSL está listo.", - "wsl.onboarding.required": "Se necesita WSL para continuar.", - "wsl.onboarding.checkingDistros": "Comprobando distribuciones...", - "wsl.onboarding.installingDistro": "Instalando {{distro}}...", - "wsl.onboarding.checkingDistro": "Comprobando {{distro}}...", - "wsl.onboarding.listingDistros": "Listando distribuciones...", - "wsl.onboarding.distroReady": "La distribución {{distro}} está lista.", - "wsl.onboarding.distroNotInstalled": "La distribución {{distro}} aún no está instalada.", - "wsl.onboarding.openDistroOnce": "Abre {{distro}} una vez para completar la configuración.", - "wsl.onboarding.finishingDistro": "Completando la configuración de {{distro}}.", - "wsl.onboarding.pickDistro": "Elige una distribución o instala una abajo.", - "wsl.onboarding.checkingOpencode": "Comprobando OpenCode...", - "wsl.onboarding.checkingOpencodeIn": "Comprobando OpenCode en {{distro}}...", - "wsl.onboarding.updatingOpencode": "Actualizando OpenCode...", - "wsl.onboarding.updatingOpencodeIn": "Actualizando OpenCode en {{distro}}...", - "wsl.onboarding.updateOpencodeIn": "Actualiza OpenCode en {{distro}}.", - "wsl.onboarding.updateOpencode": "Actualizar OpenCode", - "wsl.onboarding.opencodeReadyIn": "OpenCode está listo en {{distro}}.", - "wsl.onboarding.opencodeReady": "OpenCode está listo.", - "wsl.onboarding.installOpencodeIn": "Instala OpenCode en {{distro}}.", - "wsl.onboarding.installOpencode": "Instalar OpenCode", - "wsl.onboarding.chooseDistroFirst": "Elige primero una distribución.", - "wsl.onboarding.loadFailed": "No se pudo cargar el estado de WSL.", - "wsl.onboarding.loading": "Cargando...", - "wsl.onboarding.installedDistros": "Distribuciones instaladas", - "wsl.onboarding.checkAgain": "Comprobar de nuevo", - "wsl.onboarding.distroStatus.ready": "Lista", - "wsl.onboarding.distroStatus.checking": "Comprobando...", - "wsl.onboarding.distroStatus.opencodeMissing": "OpenCode no está instalado", - "wsl.onboarding.distroStatus.missingTools": "Faltan bash y curl", - "wsl.onboarding.distroStatus.unsupported": "No compatible · Usa WSL 2", - "wsl.onboarding.needAnotherDistro": "¿Necesitas otra distribución?", - "wsl.onboarding.needAnotherDistroHint": "Instala una distribución de Linux desde el catálogo de WSL", - "wsl.onboarding.wslNotInstalled.title": "WSL no está instalado", - "wsl.onboarding.wslNotInstalled.description": - "Se necesita WSL (Subsistema de Windows para Linux) para que OpenCode pueda añadir un servidor WSL", - "wsl.onboarding.wslUnavailable.title": "WSL no disponible", - "wsl.onboarding.wslUnavailable.description": "OpenCode no pudo comprobar WSL en este equipo.", - "wsl.onboarding.installWsl": "Instalar WSL", - "wsl.onboarding.windowsRestartRequired": - "Reinicia Windows para terminar de instalar WSL y, después, vuelve a abrir OpenCode.", - "wsl.onboarding.next": "Siguiente", - "wsl.onboarding.refresh": "Actualizar", - "wsl.onboarding.allDistrosAdded": "Ya se han añadido todas las distribuciones instaladas.", - "wsl.onboarding.noDistros": "Aún no se han detectado distribuciones.", - "wsl.onboarding.install": "Instalar", - "wsl.onboarding.installing": "Instalando...", - "wsl.onboarding.installDistro": "Instalar distribución", - "wsl.onboarding.searchDistros": "Buscar distribuciones", - "wsl.onboarding.wsl2Required": "Se necesita WSL 2.", - "wsl.onboarding.toolsRequired": "Esta distribución necesita bash y curl.", - "wsl.onboarding.openTerminal": "Abrir terminal", - "wsl.onboarding.path": "Ruta: {{path}}", - "wsl.onboarding.notFound": "no encontrado", - "wsl.onboarding.version": "Versión: {{version}}", - "wsl.onboarding.unknown": "desconocida", - "wsl.onboarding.desktopVersion": "escritorio {{version}}", - "wsl.onboarding.versionMismatch": "La versión instalada no coincide con la versión de la aplicación de escritorio.", - "wsl.onboarding.adding": "Añadiendo...", "dialog.project.edit.title": "Editar proyecto", "dialog.project.edit.name": "Nombre", @@ -493,7 +405,6 @@ export const dict = { "language.no": "Norsk", "language.br": "Português (Brasil)", "language.bs": "Bosanski", - "language.uk": "Українська", "language.th": "ไทย", "language.tr": "Türkçe", @@ -542,12 +453,10 @@ export const dict = { "error.page.title": "Algo salió mal", "error.page.description": "Ocurrió un error al cargar la aplicación.", - "error.page.description.localServerStartup": "Ocurrió un error al iniciar el servidor local.", "error.page.details.label": "Detalles del error", "error.page.action.restart": "Reiniciar", "error.page.action.report": "Informar error", "error.page.action.reported": "Error informado", - "error.page.action.exportLogs": "Exportar registros", "error.page.action.checking": "Comprobando...", "error.page.action.checkUpdates": "Buscar actualizaciones", "error.page.action.updateTo": "Actualizar a {{version}}", @@ -596,33 +505,11 @@ export const dict = { "home.recentProjects": "Proyectos recientes", "home.empty.title": "Sin proyectos recientes", "home.empty.description": "Empieza abriendo un proyecto local", - "home.title": "Inicio", - "home.projects": "Proyectos", - "home.project.add": "Añadir proyecto", - "home.recentlyClosed": "Proyectos cerrados recientemente", - "home.server.collapse": "Contraer proyectos del servidor", - "home.server.expand": "Expandir proyectos del servidor", - "home.sessions.search.placeholder": "Buscar sesiones", - "home.sessions.search.placeholder.scoped": "Buscar sesiones en {{scope}}", - "home.sessions.search.sessions": "Sesiones", - "home.sessions.search.noResults": "No se encontraron sesiones para {{query}}", - "home.sessions.empty": "Aún no hay nada", - "home.sessions.empty.description": "Crea una sesión para empezar", - "home.sessions.group.today": "Hoy", - "home.sessions.group.yesterday": "Ayer", - "home.sessions.group.older": "Anteriores", - "home.providerTip": - "Conéctate a más de 75 proveedores para usar otros modelos, como Claude, GPT, Gemini y muchos más", "session.tab.session": "Sesión", "session.tab.review": "Revisión", "session.tab.context": "Contexto", - "session.tab.unknown": "Sesión desconocida", "session.panel.reviewAndFiles": "Revisión y archivos", - "session.error.notFound": "No se encuentra esta sesión", - "session.error.notFound.description": "Esta pestaña apunta a una sesión que ya no existe en este servidor.", - "session.error.notFound.closeTab": "Cerrar pestaña", - "session.error.serverConnection": "No se puede conectar a este servidor", "session.review.filesChanged": "{{count}} Archivos Cambiados", "session.review.change.one": "Cambio", "session.review.change.other": "Cambios", @@ -632,8 +519,6 @@ export const dict = { "session.review.noSnapshot": "El seguimiento de instantáneas está deshabilitado en la configuración, por lo que los cambios de sesión no están disponibles", "session.review.noChanges": "Sin cambios", - "session.review.noUncommittedChanges": "Aún no hay cambios sin confirmar", - "session.review.noBranchChanges": "Aún no hay cambios en la rama", "session.files.selectToOpen": "Selecciona un archivo para abrir", "session.files.all": "Todos los archivos", @@ -650,10 +535,6 @@ export const dict = { "session.todo.title": "Tareas", "session.todo.collapse": "Contraer", "session.todo.expand": "Expandir", - "session.question.minimize": "Minimizar pregunta", - "session.question.restore": "Restaurar pregunta", - "session.question.pending.one": "{{count}} pregunta pendiente", - "session.question.pending.other": "{{count}} preguntas pendientes", "session.followupDock.summary.one": "{{count}} mensaje en cola", "session.followupDock.summary.other": "{{count}} mensajes en cola", "session.followupDock.sendNow": "Enviar ahora", @@ -667,17 +548,9 @@ export const dict = { "session.revertDock.restore": "Restaurar mensaje", "session.new.title": "Construye lo que quieras", - "session.new.project.new": "Nuevo proyecto", - "session.new.project.search": "Buscar proyectos", - "session.new.project.add": "Añadir proyecto", "session.new.worktree.main": "Rama principal", "session.new.worktree.mainWithBranch": "Rama principal ({{branch}})", "session.new.worktree.create": "Crear nuevo árbol de trabajo", - "session.new.workspace.runIn": "Ejecutar sesión en", - "session.new.workspace.triggerLocal": "Local", - "session.new.workspace.local": "Repositorio local", - "session.new.workspace.existing": "Espacio de trabajo…", - "session.new.git.none": "Sin Git", "session.new.lastModified": "Última modificación", "session.header.search.placeholder": "Buscar {{project}}", @@ -748,8 +621,6 @@ export const dict = { "sidebar.project.recentSessions": "Sesiones recientes", "sidebar.project.viewAllSessions": "Ver todas las sesiones", "sidebar.project.clearNotifications": "Borrar notificaciones", - "sidebar.empty.title": "No hay proyectos abiertos", - "sidebar.empty.description": "Abre un proyecto para empezar", "app.name.desktop": "OpenCode Desktop", @@ -762,7 +633,6 @@ export const dict = { "settings.desktop.wsl.description": "Ejecutar el servidor OpenCode dentro de WSL en Windows.", "settings.general.section.appearance": "Apariencia", - "settings.general.section.advanced": "Avanzado", "settings.general.section.notifications": "Notificaciones del sistema", "settings.general.section.updates": "Actualizaciones", "settings.general.section.sounds": "Efectos de sonido", @@ -771,11 +641,6 @@ export const dict = { "settings.general.row.language.title": "Idioma", "settings.general.row.language.description": "Cambiar el idioma de visualización para OpenCode", - "settings.general.row.shell.title": "Shell del terminal", - "settings.general.row.shell.description": - "Elige el shell que se usará en el terminal. Los shells compatibles también se usarán para las llamadas a herramientas del agente.", - "settings.general.row.shell.autoDefault": "Automático (Predeterminado)", - "settings.general.row.shell.terminalOnly": "solo en el terminal", "settings.general.row.appearance.title": "Apariencia", "settings.general.row.appearance.description": "Personaliza cómo se ve OpenCode en tu dispositivo", "settings.general.row.colorScheme.title": "Esquema de color", @@ -784,8 +649,8 @@ export const dict = { "settings.general.row.theme.description": "Personaliza el tema de OpenCode.", "settings.general.row.font.title": "Fuente de código", "settings.general.row.font.description": "Personaliza la fuente usada en bloques de código", - "settings.general.row.terminalFont.title": "Fuente del terminal", - "settings.general.row.terminalFont.description": "Personaliza la fuente utilizada en el terminal", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "Fuente de la interfaz", "settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz", "settings.general.row.followup.title": "Comportamiento de seguimiento", @@ -793,23 +658,6 @@ export const dict = { "Elige si los prompts de seguimiento se dirigen inmediatamente o esperan en una cola", "settings.general.row.followup.option.queue": "Cola", "settings.general.row.followup.option.steer": "Dirigir", - "settings.general.row.showFileTree.title": "Árbol de archivos", - "settings.general.row.showFileTree.description": "Mostrar el panel del árbol de archivos en las sesiones", - "settings.general.row.showNavigation.title": "Controles de navegación", - "settings.general.row.showNavigation.description": - "Mostrar los botones para retroceder y avanzar en la barra de título de la aplicación de escritorio", - "settings.general.row.showSearch.title": "Paleta de comandos", - "settings.general.row.showSearch.description": - "Mostrar el botón de búsqueda y paleta de comandos en la barra de título", - "settings.general.row.showTerminal.title": "Terminal", - "settings.general.row.showTerminal.description": "Mostrar el botón del terminal en la barra de título", - "settings.general.row.showStatus.title": "Estado del servidor", - "settings.general.row.showStatus.description": "Mostrar el botón de estado del servidor en la barra de título", - "settings.general.row.mobileTitlebarBottom.title": "Navegación inferior", - "settings.general.row.mobileTitlebarBottom.description": - "Colocar la barra de título y las pestañas de sesión en la parte inferior de la pantalla en dispositivos móviles", - "settings.general.row.showCustomAgents.title": "Agentes personalizados", - "settings.general.row.showCustomAgents.description": "Mostrar el selector de agentes en el editor", "settings.general.row.reasoningSummaries.title": "Mostrar resúmenes de razonamiento", "settings.general.row.reasoningSummaries.description": "Mostrar resúmenes del razonamiento del modelo en la línea de tiempo", @@ -819,16 +667,6 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición", "settings.general.row.editToolPartsExpanded.description": "Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo", - "settings.general.row.newInterface.title": "Nuevo diseño", - "settings.general.row.newInterface.badge": "Nuevo", - "settings.general.row.newInterface.description": - "Usa las nuevas pestañas y el diseño de la página de inicio. Durante un tiempo limitado, puedes cambiar entre los diseños.", - "settings.general.row.newInterfaceNotice.title": "Ahora estás usando el nuevo diseño", - "settings.general.row.newInterfaceNotice.description": "El diseño anterior ya no está disponible", - "settings.general.row.newInterfaceNotice.dismiss": "Descartar", - "settings.general.row.pinchZoom.title": "Pellizcar para ampliar", - "settings.general.row.pinchZoom.description": - "Permitir ampliar con el gesto de pellizco del panel táctil y con Ctrl-scroll", "settings.general.row.wayland.title": "Usar Wayland nativo", "settings.general.row.wayland.description": "Deshabilitar fallback a X11 en Wayland. Requiere reinicio.", "settings.general.row.wayland.tooltip": @@ -844,8 +682,6 @@ export const dict = { "settings.updates.row.check.description": "Buscar actualizaciones manualmente e instalarlas si hay alguna", "settings.updates.action.checkNow": "Buscar ahora", "settings.updates.action.checking": "Buscando...", - "settings.updates.action.downloading": "Descargando...", - "settings.updates.action.installing": "Instalando...", "settings.updates.toast.latest.title": "Estás al día", "settings.updates.toast.latest.description": "Estás usando la última versión de OpenCode.", "sound.option.none": "Ninguno", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index fc5c5bbb34..7ebd34e66b 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -21,9 +21,6 @@ export const dict = { "theme.scheme.dark": "Sombre", "command.sidebar.toggle": "Basculer la barre latérale", "command.project.open": "Ouvrir un projet", - "command.project.previous": "Projet précédent", - "command.project.next": "Projet suivant", - "command.project.index": "Passer au projet {{index}}", "command.provider.connect": "Connecter un fournisseur", "command.server.switch": "Changer de serveur", "command.settings.open": "Ouvrir les paramètres", @@ -42,7 +39,6 @@ export const dict = { "command.session.new": "Nouvelle session", "command.file.open": "Ouvrir un fichier", "command.tab.close": "Fermer l'onglet", - "command.tab.reopenClosed": "Rouvrir l'onglet fermé", "command.context.addSelection": "Ajouter la sélection au contexte", "command.context.addSelection.description": "Ajouter les lignes sélectionnées du fichier actuel", "command.input.focus": "Focus sur l'entrée", @@ -86,7 +82,6 @@ export const dict = { "command.session.unshare": "Ne plus partager la session", "command.session.unshare.description": "Arrêter de partager cette session", "palette.search.placeholder": "Rechercher des fichiers, des commandes et des sessions", - "palette.search.placeholder.home": "Rechercher des commandes et des sessions", "palette.empty": "Aucun résultat trouvé", "palette.group.commands": "Commandes", "palette.group.files": "Fichiers", @@ -198,11 +193,6 @@ export const dict = { "model.input.video": "vidéo", "model.input.pdf": "pdf", "model.tooltip.allows": "Autorise : {{inputs}}", - "model.tooltip.context.label": "Contexte", - "model.tooltip.inputs": "Entrées", - "model.tooltip.model": "Modèle", - "model.tooltip.provider": "Fournisseur", - "model.tooltip.reasoning": "Raisonnement", "model.tooltip.reasoning.allowed": "Autorise le raisonnement", "model.tooltip.reasoning.none": "Sans raisonnement", "model.tooltip.context": "Limite de contexte {{limit}}", @@ -212,7 +202,6 @@ export const dict = { "common.loading": "Chargement", "common.loading.ellipsis": "...", "common.cancel": "Annuler", - "common.clear": "Effacer", "common.connect": "Connecter", "common.disconnect": "Déconnecter", "common.continue": "Soumettre", @@ -229,8 +218,6 @@ export const dict = { "prompt.mode.shell": "Shell", "prompt.mode.normal": "Prompt", "prompt.mode.shell.exit": "esc pour quitter", - "session.child.promptDisabled": "Les sessions de sous-agents ne peuvent pas recevoir de messages.", - "session.child.backToParent": "Revenir à la session principale.", "prompt.example.1": "Corriger un TODO dans la base de code", "prompt.example.2": "Quelle est la pile technique de ce projet ?", "prompt.example.3": "Réparer les tests échoués", @@ -268,16 +255,10 @@ export const dict = { "prompt.context.removeActiveFile": "Retirer le fichier actif du contexte", "prompt.context.removeFile": "Retirer le fichier du contexte", "prompt.action.attachFile": "Joindre un fichier", - "prompt.menu.addImagesAndFiles": "Ajouter des fichiers et plus encore", - "prompt.menu.imagesAndFiles": "Images et fichiers", - "prompt.menu.commands": "Commandes", - "prompt.menu.context": "Contexte", - "prompt.menu.shellCommand": "Commande shell", "prompt.attachment.remove": "Supprimer la pièce jointe", "prompt.action.send": "Envoyer", "prompt.action.stop": "Arrêter", "prompt.toast.pasteUnsupported.title": "Pièce jointe non prise en charge", - "prompt.toast.attachmentDuplicate.title": "Ce fichier a déjà été téléversé", "prompt.toast.pasteUnsupported.description": "Seules les images, les PDF ou les fichiers texte peuvent être joints ici.", "prompt.toast.modelAgentRequired.title": "Sélectionnez un agent et un modèle", @@ -334,75 +315,6 @@ export const dict = { "dialog.server.menu.delete": "Supprimer", "dialog.server.current": "Serveur actuel", "dialog.server.status.default": "Défaut", - "wsl.server.add": "Ajouter un serveur WSL", - "wsl.server.addShort": "Ajouter WSL", - "wsl.server.label": "WSL", - "wsl.server.menu.label": "Serveur WSL", - "wsl.server.retryStart": "Réessayer de démarrer", - "wsl.server.updating": "Mise à jour...", - "wsl.onboarding.step.distro": "Choisir une distribution", - "wsl.onboarding.step.opencode": "OpenCode", - "wsl.onboarding.checkingRuntime": "Vérification de WSL...", - "wsl.onboarding.restartRequired": "Windows doit redémarrer pour terminer l'installation de WSL.", - "wsl.onboarding.ready": "WSL est prêt.", - "wsl.onboarding.required": "WSL est requis pour continuer.", - "wsl.onboarding.checkingDistros": "Vérification des distributions...", - "wsl.onboarding.installingDistro": "Installation de {{distro}}...", - "wsl.onboarding.checkingDistro": "Vérification de {{distro}}...", - "wsl.onboarding.listingDistros": "Récupération de la liste des distributions...", - "wsl.onboarding.distroReady": "{{distro}} est prête.", - "wsl.onboarding.distroNotInstalled": "{{distro}} n'est pas encore installée.", - "wsl.onboarding.openDistroOnce": "Ouvrez {{distro}} une fois pour terminer la configuration.", - "wsl.onboarding.finishingDistro": "Finalisation de la configuration de {{distro}}.", - "wsl.onboarding.pickDistro": "Choisissez une distribution ou installez-en une ci-dessous.", - "wsl.onboarding.checkingOpencode": "Vérification d'OpenCode...", - "wsl.onboarding.checkingOpencodeIn": "Vérification d'OpenCode dans {{distro}}...", - "wsl.onboarding.updatingOpencode": "Mise à jour d'OpenCode...", - "wsl.onboarding.updatingOpencodeIn": "Mise à jour d'OpenCode dans {{distro}}...", - "wsl.onboarding.updateOpencodeIn": "Mettez à jour OpenCode dans {{distro}}.", - "wsl.onboarding.updateOpencode": "Mettre à jour OpenCode", - "wsl.onboarding.opencodeReadyIn": "OpenCode est prêt dans {{distro}}.", - "wsl.onboarding.opencodeReady": "OpenCode est prêt.", - "wsl.onboarding.installOpencodeIn": "Installez OpenCode dans {{distro}}.", - "wsl.onboarding.installOpencode": "Installer OpenCode", - "wsl.onboarding.chooseDistroFirst": "Choisissez d'abord une distribution.", - "wsl.onboarding.loadFailed": "Impossible de charger l'état de WSL.", - "wsl.onboarding.loading": "Chargement...", - "wsl.onboarding.installedDistros": "Distributions installées", - "wsl.onboarding.checkAgain": "Vérifier à nouveau", - "wsl.onboarding.distroStatus.ready": "Prête", - "wsl.onboarding.distroStatus.checking": "Vérification...", - "wsl.onboarding.distroStatus.opencodeMissing": "OpenCode non installé", - "wsl.onboarding.distroStatus.missingTools": "bash et curl manquants", - "wsl.onboarding.distroStatus.unsupported": "Non prise en charge · Utilisez WSL 2", - "wsl.onboarding.needAnotherDistro": "Besoin d'une autre distribution ?", - "wsl.onboarding.needAnotherDistroHint": "Installez une distribution Linux depuis le catalogue WSL", - "wsl.onboarding.wslNotInstalled.title": "WSL n'est pas installé", - "wsl.onboarding.wslNotInstalled.description": - "WSL (Windows Subsystem for Linux) est requis avant qu'OpenCode puisse ajouter un serveur WSL", - "wsl.onboarding.wslUnavailable.title": "WSL indisponible", - "wsl.onboarding.wslUnavailable.description": "OpenCode n'a pas pu vérifier WSL sur cet appareil.", - "wsl.onboarding.installWsl": "Installer WSL", - "wsl.onboarding.windowsRestartRequired": - "Redémarrez Windows pour terminer l'installation de WSL, puis rouvrez OpenCode.", - "wsl.onboarding.next": "Suivant", - "wsl.onboarding.refresh": "Actualiser", - "wsl.onboarding.allDistrosAdded": "Toutes les distributions installées ont déjà été ajoutées.", - "wsl.onboarding.noDistros": "Aucune distribution détectée pour l'instant.", - "wsl.onboarding.install": "Installer", - "wsl.onboarding.installing": "Installation...", - "wsl.onboarding.installDistro": "Installer une distribution", - "wsl.onboarding.searchDistros": "Rechercher des distributions", - "wsl.onboarding.wsl2Required": "WSL 2 est requis.", - "wsl.onboarding.toolsRequired": "Cette distribution nécessite bash et curl.", - "wsl.onboarding.openTerminal": "Ouvrir le terminal", - "wsl.onboarding.path": "Chemin : {{path}}", - "wsl.onboarding.notFound": "introuvable", - "wsl.onboarding.version": "Version : {{version}}", - "wsl.onboarding.unknown": "inconnue", - "wsl.onboarding.desktopVersion": "desktop {{version}}", - "wsl.onboarding.versionMismatch": "La version installée ne correspond pas à celle de l'application desktop.", - "wsl.onboarding.adding": "Ajout...", "dialog.project.edit.title": "Modifier le projet", "dialog.project.edit.name": "Nom", "dialog.project.edit.icon": "Icône", @@ -461,7 +373,6 @@ export const dict = { "language.no": "Norsk", "language.br": "Português (Brasil)", "language.bs": "Bosanski", - "language.uk": "Українська", "language.th": "ไทย", "language.tr": "Türkçe", "toast.language.title": "Langue", @@ -499,12 +410,10 @@ export const dict = { "toast.update.action.notYet": "Pas encore", "error.page.title": "Quelque chose s'est mal passé", "error.page.description": "Une erreur s'est produite lors du chargement de l'application.", - "error.page.description.localServerStartup": "Une erreur s'est produite lors du démarrage du serveur local.", "error.page.details.label": "Détails de l'erreur", "error.page.action.restart": "Redémarrer", "error.page.action.report": "Signaler l'erreur", "error.page.action.reported": "Erreur signalée", - "error.page.action.exportLogs": "Exporter les journaux", "error.page.action.checking": "Vérification...", "error.page.action.checkUpdates": "Vérifier les mises à jour", "error.page.action.updateTo": "Mettre à jour vers {{version}}", @@ -549,40 +458,16 @@ export const dict = { "home.recentProjects": "Projets récents", "home.empty.title": "Aucun projet récent", "home.empty.description": "Commencez par ouvrir un projet local", - "home.title": "Accueil", - "home.projects": "Projets", - "home.project.add": "Ajouter un projet", - "home.recentlyClosed": "Récemment fermés", - "home.server.collapse": "Réduire les projets du serveur", - "home.server.expand": "Développer les projets du serveur", - "home.sessions.search.placeholder": "Rechercher des sessions", - "home.sessions.search.placeholder.scoped": "Rechercher les sessions dans {{scope}}", - "home.sessions.search.sessions": "Sessions", - "home.sessions.search.noResults": "Aucune session trouvée pour {{query}}", - "home.sessions.empty": "Rien ici pour l'instant", - "home.sessions.empty.description": "Créez une session pour commencer", - "home.sessions.group.today": "Aujourd'hui", - "home.sessions.group.yesterday": "Hier", - "home.sessions.group.older": "Plus anciennes", - "home.providerTip": - "Connectez-vous à plus de 75 fournisseurs pour utiliser d’autres modèles, notamment Claude, GPT, Gemini, etc.", "session.tab.session": "Session", "session.tab.review": "Revue", "session.tab.context": "Contexte", - "session.tab.unknown": "Session inconnue", "session.panel.reviewAndFiles": "Revue et fichiers", - "session.error.notFound": "Cette session est introuvable", - "session.error.notFound.description": "Cet onglet pointe vers une session qui n'existe plus sur ce serveur.", - "session.error.notFound.closeTab": "Fermer l'onglet", - "session.error.serverConnection": "Impossible de se connecter à ce serveur", "session.review.filesChanged": "{{count}} fichiers modifiés", "session.review.change.one": "Modification", "session.review.change.other": "Modifications", "session.review.loadingChanges": "Chargement des modifications...", "session.review.empty": "Aucune modification dans cette session pour l'instant", "session.review.noChanges": "Aucune modification", - "session.review.noUncommittedChanges": "Aucune modification non validée pour l'instant", - "session.review.noBranchChanges": "Aucune modification de branche pour l'instant", "session.review.noVcs": "Aucun système de contrôle de version Git détecté, modifications non affichées", "session.review.noSnapshot": "Le suivi des instantanés est désactivé dans la configuration, les modifications de session sont donc indisponibles", @@ -599,10 +484,6 @@ export const dict = { "session.todo.title": "Tâches", "session.todo.collapse": "Réduire", "session.todo.expand": "Développer", - "session.question.minimize": "Réduire la question", - "session.question.restore": "Restaurer la question", - "session.question.pending.one": "{{count}} question en attente", - "session.question.pending.other": "{{count}} questions en attente", "session.followupDock.summary.one": "{{count}} message en file d'attente", "session.followupDock.summary.other": "{{count}} messages en file d'attente", "session.followupDock.sendNow": "Envoyer maintenant", @@ -615,17 +496,9 @@ export const dict = { "session.revertDock.expand": "Développer les messages annulés", "session.revertDock.restore": "Restaurer le message", "session.new.title": "Créez ce que vous voulez", - "session.new.project.new": "Nouveau projet", - "session.new.project.search": "Rechercher des projets", - "session.new.project.add": "Ajouter un projet", "session.new.worktree.main": "Branche principale", "session.new.worktree.mainWithBranch": "Branche principale ({{branch}})", "session.new.worktree.create": "Créer un nouvel arbre de travail", - "session.new.workspace.runIn": "Exécuter la session dans", - "session.new.workspace.triggerLocal": "Local", - "session.new.workspace.local": "Dépôt local", - "session.new.workspace.existing": "Espace de travail…", - "session.new.git.none": "Pas de Git", "session.new.lastModified": "Dernière modification", "session.header.search.placeholder": "Rechercher {{project}}", "session.header.searchFiles": "Rechercher des fichiers", @@ -691,8 +564,6 @@ export const dict = { "sidebar.project.recentSessions": "Sessions récentes", "sidebar.project.viewAllSessions": "Voir toutes les sessions", "sidebar.project.clearNotifications": "Effacer les notifications", - "sidebar.empty.title": "Aucun projet ouvert", - "sidebar.empty.description": "Ouvrez un projet pour commencer", "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Bureau", "settings.section.server": "Serveur", @@ -702,7 +573,6 @@ export const dict = { "settings.desktop.wsl.title": "Intégration WSL", "settings.desktop.wsl.description": "Exécuter le serveur OpenCode dans WSL sur Windows.", "settings.general.section.appearance": "Apparence", - "settings.general.section.advanced": "Avancé", "settings.general.section.notifications": "Notifications système", "settings.general.section.updates": "Mises à jour", "settings.general.section.sounds": "Effets sonores", @@ -710,11 +580,6 @@ export const dict = { "settings.general.section.display": "Affichage", "settings.general.row.language.title": "Langue", "settings.general.row.language.description": "Changer la langue d'affichage pour OpenCode", - "settings.general.row.shell.title": "Shell du terminal", - "settings.general.row.shell.description": - "Choisissez le shell utilisé par votre terminal. Les shells compatibles sont également utilisés pour les appels d'outils de l'agent.", - "settings.general.row.shell.autoDefault": "Auto (par défaut)", - "settings.general.row.shell.terminalOnly": "terminal uniquement", "settings.general.row.appearance.title": "Apparence", "settings.general.row.appearance.description": "Personnaliser l'apparence d'OpenCode sur votre appareil", "settings.general.row.colorScheme.title": "Schéma de couleurs", @@ -723,8 +588,8 @@ export const dict = { "settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.", "settings.general.row.font.title": "Police de code", "settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code", - "settings.general.row.terminalFont.title": "Police du terminal", - "settings.general.row.terminalFont.description": "Personnalisez la police utilisée dans le terminal", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "Police de l'interface", "settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface", "settings.general.row.followup.title": "Comportement de suivi", @@ -732,24 +597,6 @@ export const dict = { "Choisissez si les messages de suivi dirigent immédiatement ou attendent dans une file d'attente", "settings.general.row.followup.option.queue": "File d'attente", "settings.general.row.followup.option.steer": "Diriger", - "settings.general.row.showFileTree.title": "Arborescence des fichiers", - "settings.general.row.showFileTree.description": "Afficher le panneau d'arborescence des fichiers dans les sessions", - "settings.general.row.showNavigation.title": "Commandes de navigation", - "settings.general.row.showNavigation.description": - "Afficher les boutons précédent et suivant dans la barre de titre de l'application desktop", - "settings.general.row.showSearch.title": "Palette de commandes", - "settings.general.row.showSearch.description": - "Afficher le bouton de recherche et de palette de commandes dans la barre de titre", - "settings.general.row.showTerminal.title": "Terminal", - "settings.general.row.showTerminal.description": - "Afficher le bouton du terminal dans la barre de titre de l'application desktop", - "settings.general.row.showStatus.title": "État du serveur", - "settings.general.row.showStatus.description": "Afficher le bouton d'état du serveur dans la barre de titre", - "settings.general.row.mobileTitlebarBottom.title": "Navigation en bas", - "settings.general.row.mobileTitlebarBottom.description": - "Placer la barre de titre et les onglets de session en bas de l'écran sur mobile", - "settings.general.row.showCustomAgents.title": "Agents personnalisés", - "settings.general.row.showCustomAgents.description": "Afficher le sélecteur d'agent dans la zone de saisie", "settings.general.row.reasoningSummaries.title": "Afficher les résumés de raisonnement", "settings.general.row.reasoningSummaries.description": "Afficher les résumés de raisonnement du modèle dans la chronologie", @@ -759,16 +606,6 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit", "settings.general.row.editToolPartsExpanded.description": "Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie", - "settings.general.row.newInterface.title": "Nouvelle mise en page", - "settings.general.row.newInterface.badge": "Nouveau", - "settings.general.row.newInterface.description": - "Utilisez les nouveaux onglets et la mise en page de l'accueil. Vous pouvez alterner entre les mises en page pendant une durée limitée.", - "settings.general.row.newInterfaceNotice.title": "Vous utilisez maintenant la nouvelle mise en page", - "settings.general.row.newInterfaceNotice.description": "La mise en page précédente n'est plus disponible", - "settings.general.row.newInterfaceNotice.dismiss": "Ignorer", - "settings.general.row.pinchZoom.title": "Pincer pour zoomer", - "settings.general.row.pinchZoom.description": - "Autoriser les gestes de pincement du pavé tactile et Ctrl-défilement pour zoomer", "settings.general.row.wayland.title": "Utiliser Wayland natif", "settings.general.row.wayland.description": "Désactiver le repli X11 sur Wayland. Nécessite un redémarrage.", "settings.general.row.wayland.tooltip": @@ -781,8 +618,6 @@ export const dict = { "settings.updates.row.check.description": "Vérifier manuellement les mises à jour et installer si disponible", "settings.updates.action.checkNow": "Vérifier maintenant", "settings.updates.action.checking": "Vérification...", - "settings.updates.action.downloading": "Téléchargement...", - "settings.updates.action.installing": "Installation...", "settings.updates.toast.latest.title": "Vous êtes à jour", "settings.updates.toast.latest.description": "Vous utilisez la dernière version d'OpenCode.", "sound.option.none": "Aucun", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 56c63e0e70..ad5dcf5387 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -21,9 +21,6 @@ export const dict = { "theme.scheme.dark": "ダーク", "command.sidebar.toggle": "サイドバーの切り替え", "command.project.open": "プロジェクトを開く", - "command.project.previous": "前のプロジェクト", - "command.project.next": "次のプロジェクト", - "command.project.index": "プロジェクト{{index}}に切り替え", "command.provider.connect": "プロバイダーに接続", "command.server.switch": "サーバーの切り替え", "command.settings.open": "設定を開く", @@ -42,7 +39,6 @@ export const dict = { "command.session.new": "新しいセッション", "command.file.open": "ファイルを開く", "command.tab.close": "タブを閉じる", - "command.tab.reopenClosed": "閉じたタブを再度開く", "command.context.addSelection": "選択範囲をコンテキストに追加", "command.context.addSelection.description": "現在のファイルから選択した行を追加", "command.input.focus": "入力欄にフォーカス", @@ -86,7 +82,6 @@ export const dict = { "command.session.unshare": "セッションの共有を停止", "command.session.unshare.description": "このセッションの共有を停止", "palette.search.placeholder": "ファイル、コマンド、セッションを検索", - "palette.search.placeholder.home": "コマンドとセッションを検索", "palette.empty": "結果が見つかりません", "palette.group.commands": "コマンド", "palette.group.files": "ファイル", @@ -154,7 +149,7 @@ export const dict = { "provider.custom.field.providerID.placeholder": "myprovider", "provider.custom.field.providerID.description": "小文字、数字、ハイフン、アンダースコア", "provider.custom.field.name.label": "表示名", - "provider.custom.field.name.placeholder": "マイAIプロバイダー", + "provider.custom.field.name.placeholder": "My AI Provider", "provider.custom.field.baseURL.label": "ベースURL", "provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1", "provider.custom.field.apiKey.label": "APIキー", @@ -197,11 +192,6 @@ export const dict = { "model.input.video": "動画", "model.input.pdf": "pdf", "model.tooltip.allows": "対応: {{inputs}}", - "model.tooltip.context.label": "コンテキスト", - "model.tooltip.inputs": "入力", - "model.tooltip.model": "モデル", - "model.tooltip.provider": "プロバイダー", - "model.tooltip.reasoning": "推論", "model.tooltip.reasoning.allowed": "推論を許可", "model.tooltip.reasoning.none": "推論なし", "model.tooltip.context": "コンテキスト上限 {{limit}}", @@ -211,7 +201,6 @@ export const dict = { "common.loading": "読み込み中", "common.loading.ellipsis": "...", "common.cancel": "キャンセル", - "common.clear": "クリア", "common.connect": "接続", "common.disconnect": "切断", "common.continue": "送信", @@ -228,8 +217,6 @@ export const dict = { "prompt.mode.shell": "シェル", "prompt.mode.normal": "プロンプト", "prompt.mode.shell.exit": "escで終了", - "session.child.promptDisabled": "サブエージェントのセッションにはプロンプトを送信できません。", - "session.child.backToParent": "メインセッションに戻る。", "prompt.example.1": "コードベースのTODOを修正", "prompt.example.2": "このプロジェクトの技術スタックは何ですか?", "prompt.example.3": "壊れたテストを修正", @@ -267,16 +254,10 @@ export const dict = { "prompt.context.removeActiveFile": "コンテキストからアクティブなファイルを削除", "prompt.context.removeFile": "コンテキストからファイルを削除", "prompt.action.attachFile": "ファイルを添付", - "prompt.menu.addImagesAndFiles": "ファイルなどを追加", - "prompt.menu.imagesAndFiles": "画像とファイル", - "prompt.menu.commands": "コマンド", - "prompt.menu.context": "コンテキスト", - "prompt.menu.shellCommand": "シェルコマンド", "prompt.attachment.remove": "添付ファイルを削除", "prompt.action.send": "送信", "prompt.action.stop": "停止", "prompt.toast.pasteUnsupported.title": "サポートされていない添付ファイル", - "prompt.toast.attachmentDuplicate.title": "このファイルはすでにアップロードされています", "prompt.toast.pasteUnsupported.description": "画像、PDF、またはテキストファイルのみ添付できます。", "prompt.toast.modelAgentRequired.title": "エージェントとモデルを選択", "prompt.toast.modelAgentRequired.description": "プロンプトを送信する前にエージェントとモデルを選択してください。", @@ -332,75 +313,6 @@ export const dict = { "dialog.server.menu.delete": "削除", "dialog.server.current": "現在のサーバー", "dialog.server.status.default": "デフォルト", - "wsl.server.add": "WSLサーバーを追加", - "wsl.server.addShort": "WSLを追加", - "wsl.server.label": "WSL", - "wsl.server.menu.label": "WSLサーバー", - "wsl.server.retryStart": "起動を再試行", - "wsl.server.updating": "更新中...", - "wsl.onboarding.step.distro": "ディストリビューションを選択", - "wsl.onboarding.step.opencode": "OpenCode", - "wsl.onboarding.checkingRuntime": "WSLを確認中...", - "wsl.onboarding.restartRequired": "WSLのインストールを完了するにはWindowsの再起動が必要です。", - "wsl.onboarding.ready": "WSLの準備ができました。", - "wsl.onboarding.required": "続行するにはWSLが必要です。", - "wsl.onboarding.checkingDistros": "ディストリビューションを確認中...", - "wsl.onboarding.installingDistro": "{{distro}}をインストール中...", - "wsl.onboarding.checkingDistro": "{{distro}}を確認中...", - "wsl.onboarding.listingDistros": "ディストリビューションの一覧を取得中...", - "wsl.onboarding.distroReady": "{{distro}}の準備ができました。", - "wsl.onboarding.distroNotInstalled": "{{distro}}はまだインストールされていません。", - "wsl.onboarding.openDistroOnce": "セットアップを完了するには、{{distro}}を一度開いてください。", - "wsl.onboarding.finishingDistro": "{{distro}}のセットアップを完了しています。", - "wsl.onboarding.pickDistro": "ディストリビューションを選択するか、以下からインストールしてください。", - "wsl.onboarding.checkingOpencode": "OpenCodeを確認中...", - "wsl.onboarding.checkingOpencodeIn": "{{distro}}内のOpenCodeを確認中...", - "wsl.onboarding.updatingOpencode": "OpenCodeを更新中...", - "wsl.onboarding.updatingOpencodeIn": "{{distro}}内のOpenCodeを更新中...", - "wsl.onboarding.updateOpencodeIn": "{{distro}}内のOpenCodeを更新してください。", - "wsl.onboarding.updateOpencode": "OpenCodeを更新", - "wsl.onboarding.opencodeReadyIn": "{{distro}}内のOpenCodeの準備ができました。", - "wsl.onboarding.opencodeReady": "OpenCodeの準備ができました。", - "wsl.onboarding.installOpencodeIn": "{{distro}}にOpenCodeをインストールしてください。", - "wsl.onboarding.installOpencode": "OpenCodeをインストール", - "wsl.onboarding.chooseDistroFirst": "先にディストリビューションを選択してください。", - "wsl.onboarding.loadFailed": "WSLの状態を読み込めませんでした。", - "wsl.onboarding.loading": "読み込み中...", - "wsl.onboarding.installedDistros": "インストール済みのディストリビューション", - "wsl.onboarding.checkAgain": "再確認", - "wsl.onboarding.distroStatus.ready": "準備完了", - "wsl.onboarding.distroStatus.checking": "確認中...", - "wsl.onboarding.distroStatus.opencodeMissing": "OpenCodeがインストールされていません", - "wsl.onboarding.distroStatus.missingTools": "bash、curlがありません", - "wsl.onboarding.distroStatus.unsupported": "非対応 · WSL 2を使用してください", - "wsl.onboarding.needAnotherDistro": "別のディストリビューションが必要ですか?", - "wsl.onboarding.needAnotherDistroHint": "WSLカタログからLinuxディストリビューションをインストール", - "wsl.onboarding.wslNotInstalled.title": "WSLがインストールされていません", - "wsl.onboarding.wslNotInstalled.description": - "OpenCodeでWSLサーバーを追加するには、WSL (Windows Subsystem for Linux) が必要です", - "wsl.onboarding.wslUnavailable.title": "WSLを利用できません", - "wsl.onboarding.wslUnavailable.description": "このマシンのWSLを確認できませんでした。", - "wsl.onboarding.installWsl": "WSLをインストール", - "wsl.onboarding.windowsRestartRequired": - "WSLのインストールを完了するにはWindowsを再起動し、OpenCodeをもう一度開いてください。", - "wsl.onboarding.next": "次へ", - "wsl.onboarding.refresh": "更新", - "wsl.onboarding.allDistrosAdded": "インストール済みのディストリビューションはすべて追加済みです。", - "wsl.onboarding.noDistros": "ディストリビューションはまだ検出されていません。", - "wsl.onboarding.install": "インストール", - "wsl.onboarding.installing": "インストール中...", - "wsl.onboarding.installDistro": "ディストリビューションをインストール", - "wsl.onboarding.searchDistros": "ディストリビューションを検索", - "wsl.onboarding.wsl2Required": "WSL 2が必要です。", - "wsl.onboarding.toolsRequired": "このディストリビューションにはbashとcurlが必要です。", - "wsl.onboarding.openTerminal": "ターミナルを開く", - "wsl.onboarding.path": "パス: {{path}}", - "wsl.onboarding.notFound": "見つかりません", - "wsl.onboarding.version": "バージョン: {{version}}", - "wsl.onboarding.unknown": "不明", - "wsl.onboarding.desktopVersion": "デスクトップ {{version}}", - "wsl.onboarding.versionMismatch": "インストールされているバージョンがデスクトップアプリのバージョンと一致しません。", - "wsl.onboarding.adding": "追加中...", "dialog.project.edit.title": "プロジェクトを編集", "dialog.project.edit.name": "名前", "dialog.project.edit.icon": "アイコン", @@ -458,7 +370,6 @@ export const dict = { "language.no": "Norsk", "language.br": "Português (Brasil)", "language.bs": "Bosanski", - "language.uk": "Українська", "language.th": "ไทย", "language.tr": "Türkçe", "toast.language.title": "言語", @@ -495,12 +406,10 @@ export const dict = { "toast.update.action.notYet": "今はしない", "error.page.title": "問題が発生しました", "error.page.description": "アプリケーションの読み込み中にエラーが発生しました。", - "error.page.description.localServerStartup": "ローカルサーバーの起動中にエラーが発生しました。", "error.page.details.label": "エラー詳細", "error.page.action.restart": "再起動", "error.page.action.report": "エラーを報告", "error.page.action.reported": "エラーを報告しました", - "error.page.action.exportLogs": "ログをエクスポート", "error.page.action.checking": "確認中...", "error.page.action.checkUpdates": "アップデートを確認", "error.page.action.updateTo": "{{version}}にアップデート", @@ -542,31 +451,10 @@ export const dict = { "home.recentProjects": "最近のプロジェクト", "home.empty.title": "最近のプロジェクトはありません", "home.empty.description": "ローカルプロジェクトを開いて始めましょう", - "home.title": "ホーム", - "home.projects": "プロジェクト", - "home.project.add": "プロジェクトを追加", - "home.recentlyClosed": "最近閉じたプロジェクト", - "home.server.collapse": "サーバーのプロジェクトを折りたたむ", - "home.server.expand": "サーバーのプロジェクトを展開", - "home.sessions.search.placeholder": "セッションを検索", - "home.sessions.search.placeholder.scoped": "{{scope}}のセッションを検索", - "home.sessions.search.sessions": "セッション", - "home.sessions.search.noResults": "{{query}}に一致するセッションが見つかりません", - "home.sessions.empty": "まだ何もありません", - "home.sessions.empty.description": "セッションを作成して始めましょう", - "home.sessions.group.today": "今日", - "home.sessions.group.yesterday": "昨日", - "home.sessions.group.older": "それ以前", - "home.providerTip": "75以上のプロバイダーに接続して、Claude、GPT、Geminiなどの他のモデルを利用できます", "session.tab.session": "セッション", "session.tab.review": "レビュー", "session.tab.context": "コンテキスト", - "session.tab.unknown": "不明なセッション", "session.panel.reviewAndFiles": "レビューとファイル", - "session.error.notFound": "このセッションが見つかりません", - "session.error.notFound.description": "このタブが参照するセッションは、このサーバーには存在しません。", - "session.error.notFound.closeTab": "タブを閉じる", - "session.error.serverConnection": "このサーバーに接続できません", "session.review.filesChanged": "{{count}} ファイル変更", "session.review.change.one": "変更", "session.review.change.other": "変更", @@ -575,8 +463,6 @@ export const dict = { "session.review.noVcs": "Gitバージョン管理システムが検出されないため、変更は表示されません", "session.review.noSnapshot": "設定でスナップショット追跡が無効になっているため、セッションの変更は利用できません", "session.review.noChanges": "変更なし", - "session.review.noUncommittedChanges": "コミットされていない変更はまだありません", - "session.review.noBranchChanges": "ブランチの変更はまだありません", "session.files.selectToOpen": "開くファイルを選択", "session.files.all": "すべてのファイル", "session.files.empty": "ファイルなし", @@ -590,10 +476,6 @@ export const dict = { "session.todo.title": "ToDo", "session.todo.collapse": "折りたたむ", "session.todo.expand": "展開", - "session.question.minimize": "質問を最小化", - "session.question.restore": "質問を復元", - "session.question.pending.one": "{{count}}件の保留中の質問", - "session.question.pending.other": "{{count}}件の保留中の質問", "session.followupDock.summary.one": "{{count}} 件のメッセージが待機中", "session.followupDock.summary.other": "{{count}} 件のメッセージが待機中", "session.followupDock.sendNow": "今すぐ送信", @@ -606,17 +488,9 @@ export const dict = { "session.revertDock.expand": "ロールバックされたメッセージを展開", "session.revertDock.restore": "メッセージを復元", "session.new.title": "何でも作る", - "session.new.project.new": "新しいプロジェクト", - "session.new.project.search": "プロジェクトを検索", - "session.new.project.add": "プロジェクトを追加", "session.new.worktree.main": "メインブランチ", "session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})", "session.new.worktree.create": "新しいワークツリーを作成", - "session.new.workspace.runIn": "セッションの実行先", - "session.new.workspace.triggerLocal": "ローカル", - "session.new.workspace.local": "ローカルリポジトリ", - "session.new.workspace.existing": "ワークスペース…", - "session.new.git.none": "Git なし", "session.new.lastModified": "最終更新", "session.header.search.placeholder": "{{project}}を検索", "session.header.searchFiles": "ファイルを検索", @@ -680,8 +554,6 @@ export const dict = { "sidebar.project.recentSessions": "最近のセッション", "sidebar.project.viewAllSessions": "すべてのセッションを表示", "sidebar.project.clearNotifications": "通知をクリア", - "sidebar.empty.title": "開いているプロジェクトはありません", - "sidebar.empty.description": "プロジェクトを開いて始めましょう", "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "デスクトップ", "settings.section.server": "サーバー", @@ -691,7 +563,6 @@ export const dict = { "settings.desktop.wsl.title": "WSL連携", "settings.desktop.wsl.description": "WindowsのWSL環境でOpenCodeサーバーを実行します。", "settings.general.section.appearance": "外観", - "settings.general.section.advanced": "詳細設定", "settings.general.section.notifications": "システム通知", "settings.general.section.updates": "アップデート", "settings.general.section.sounds": "効果音", @@ -699,11 +570,6 @@ export const dict = { "settings.general.section.display": "ディスプレイ", "settings.general.row.language.title": "言語", "settings.general.row.language.description": "OpenCodeの表示言語を変更します", - "settings.general.row.shell.title": "ターミナルシェル", - "settings.general.row.shell.description": - "ターミナルで使用するシェルを選択します。互換性のあるシェルはエージェントのツール呼び出しにも使用されます。", - "settings.general.row.shell.autoDefault": "自動 (デフォルト)", - "settings.general.row.shell.terminalOnly": "ターミナルのみ", "settings.general.row.appearance.title": "外観", "settings.general.row.appearance.description": "デバイスでのOpenCodeの表示をカスタマイズします", "settings.general.row.colorScheme.title": "配色", @@ -712,8 +578,8 @@ export const dict = { "settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。", "settings.general.row.font.title": "コードフォント", "settings.general.row.font.description": "コードブロックで使用するフォントをカスタマイズします", - "settings.general.row.terminalFont.title": "ターミナルのフォント", - "settings.general.row.terminalFont.description": "ターミナルで使用するフォントをカスタマイズ", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "UIフォント", "settings.general.row.uiFont.description": "インターフェース全体で使用するフォントをカスタマイズします", "settings.general.row.followup.title": "フォローアップの動作", @@ -721,21 +587,6 @@ export const dict = { "フォローアッププロンプトを即座に実行するか、キューで待機させるかを選択します", "settings.general.row.followup.option.queue": "キューに追加", "settings.general.row.followup.option.steer": "即座に実行 (Steer)", - "settings.general.row.showFileTree.title": "ファイルツリー", - "settings.general.row.showFileTree.description": "セッションにファイルツリーパネルを表示します", - "settings.general.row.showNavigation.title": "ナビゲーションコントロール", - "settings.general.row.showNavigation.description": "デスクトップのタイトルバーに戻るボタンと進むボタンを表示します", - "settings.general.row.showSearch.title": "コマンドパレット", - "settings.general.row.showSearch.description": "タイトルバーに検索とコマンドパレットのボタンを表示します", - "settings.general.row.showTerminal.title": "ターミナル", - "settings.general.row.showTerminal.description": "デスクトップのタイトルバーにターミナルボタンを表示します", - "settings.general.row.showStatus.title": "サーバーステータス", - "settings.general.row.showStatus.description": "タイトルバーにサーバーステータスボタンを表示します", - "settings.general.row.mobileTitlebarBottom.title": "下部ナビゲーション", - "settings.general.row.mobileTitlebarBottom.description": - "モバイルではタイトルバーとセッションタブを画面下部に配置します", - "settings.general.row.showCustomAgents.title": "カスタムエージェント", - "settings.general.row.showCustomAgents.description": "コンポーザーにエージェント選択を表示します", "settings.general.row.reasoningSummaries.title": "推論の要約を表示", "settings.general.row.reasoningSummaries.description": "タイムラインにモデルの推論の要約を表示します", "settings.general.row.shellToolPartsExpanded.title": "shell ツールパーツを展開", @@ -744,16 +595,6 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開", "settings.general.row.editToolPartsExpanded.description": "タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します", - "settings.general.row.newInterface.title": "新しいレイアウト", - "settings.general.row.newInterface.badge": "新機能", - "settings.general.row.newInterface.description": - "新しいタブとホーム画面のレイアウトを使用します。期間限定でレイアウトを切り替えられます。", - "settings.general.row.newInterfaceNotice.title": "新しいレイアウトを使用しています", - "settings.general.row.newInterfaceNotice.description": "以前のレイアウトは利用できなくなりました", - "settings.general.row.newInterfaceNotice.dismiss": "閉じる", - "settings.general.row.pinchZoom.title": "ピンチでズーム", - "settings.general.row.pinchZoom.description": - "トラックパッドのピンチ操作とCtrl+スクロール操作によるズームを許可します", "settings.general.row.wayland.title": "ネイティブWaylandを使用", "settings.general.row.wayland.description": "WaylandでのX11フォールバックを無効にします。再起動が必要です。", "settings.general.row.wayland.tooltip": @@ -766,8 +607,6 @@ export const dict = { "settings.updates.row.check.description": "手動でアップデートを確認し、利用可能ならインストールします", "settings.updates.action.checkNow": "今すぐ確認", "settings.updates.action.checking": "確認中...", - "settings.updates.action.downloading": "ダウンロード中...", - "settings.updates.action.installing": "インストール中...", "settings.updates.toast.latest.title": "最新です", "settings.updates.toast.latest.description": "OpenCode は最新バージョンです。", "sound.option.none": "なし", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 02d9a5e0e1..15f1f28013 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -82,7 +82,6 @@ export const dict = { "command.session.unshare": "세션 공유 중지", "command.session.unshare.description": "이 세션 공유 중지", "palette.search.placeholder": "파일, 명령어 및 세션 검색", - "palette.search.placeholder.home": "명령어 및 세션 검색", "palette.empty": "결과 없음", "palette.group.commands": "명령어", "palette.group.files": "파일", @@ -255,16 +254,10 @@ export const dict = { "prompt.context.removeActiveFile": "컨텍스트에서 활성 파일 제거", "prompt.context.removeFile": "컨텍스트에서 파일 제거", "prompt.action.attachFile": "파일 첨부", - "prompt.menu.addImagesAndFiles": "파일 및 기타 항목 추가", - "prompt.menu.imagesAndFiles": "이미지 및 파일", - "prompt.menu.commands": "명령어", - "prompt.menu.context": "컨텍스트", - "prompt.menu.shellCommand": "셸 명령", "prompt.attachment.remove": "첨부 파일 제거", "prompt.action.send": "전송", "prompt.action.stop": "중지", "prompt.toast.pasteUnsupported.title": "지원되지 않는 첨부 파일", - "prompt.toast.attachmentDuplicate.title": "이 파일은 이미 업로드되었습니다", "prompt.toast.pasteUnsupported.description": "이미지, PDF 또는 텍스트 파일만 첨부할 수 있습니다.", "prompt.toast.modelAgentRequired.title": "에이전트 및 모델 선택", "prompt.toast.modelAgentRequired.description": "프롬프트를 보내기 전에 에이전트와 모델을 선택하세요.", @@ -582,8 +575,8 @@ export const dict = { "settings.general.row.theme.description": "OpenCode 테마 사용자 지정", "settings.general.row.font.title": "코드 글꼴", "settings.general.row.font.description": "코드 블록에 사용되는 글꼴을 사용자 지정", - "settings.general.row.terminalFont.title": "터미널 글꼴", - "settings.general.row.terminalFont.description": "터미널에서 사용할 글꼴을 설정합니다", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "UI 글꼴", "settings.general.row.uiFont.description": "인터페이스 전반에 사용되는 글꼴을 사용자 지정", "settings.general.row.followup.title": "후속 조치 동작", @@ -855,162 +848,4 @@ export const dict = { "error.childStore.persistedProjectIconCreateFailed": "영구 프로젝트 아이콘 생성 실패", "error.childStore.storeCreateFailed": "저장소 생성 실패", "terminal.connectionLost.abnormalClose": "WebSocket이 비정상적으로 닫힘: {{code}}", - - "command.project.previous": "이전 프로젝트", - "command.project.next": "다음 프로젝트", - "command.project.index": "{{index}}번 프로젝트로 전환", - "command.tab.reopenClosed": "닫은 탭 다시 열기", - "model.tooltip.context.label": "컨텍스트", - "model.tooltip.inputs": "입력", - "model.tooltip.model": "모델", - "model.tooltip.provider": "공급자", - "model.tooltip.reasoning": "추론", - "common.clear": "지우기", - "session.child.promptDisabled": "하위 에이전트 세션에는 프롬프트를 입력할 수 없습니다.", - "session.child.backToParent": "메인 세션으로 돌아가기.", - - "wsl.server.add": "WSL 서버 추가", - "wsl.server.addShort": "WSL 추가", - "wsl.server.label": "WSL", - "wsl.server.menu.label": "WSL 서버", - "wsl.server.retryStart": "시작 재시도", - "wsl.server.updating": "업데이트 중...", - "wsl.onboarding.step.distro": "배포판 선택", - "wsl.onboarding.step.opencode": "OpenCode", - "wsl.onboarding.checkingRuntime": "WSL 확인 중...", - "wsl.onboarding.restartRequired": "WSL 설치를 완료하려면 Windows를 다시 시작해야 합니다.", - "wsl.onboarding.ready": "WSL이 준비되었습니다.", - "wsl.onboarding.required": "계속하려면 WSL이 필요합니다.", - "wsl.onboarding.checkingDistros": "배포판 확인 중...", - "wsl.onboarding.installingDistro": "{{distro}} 배포판 설치 중...", - "wsl.onboarding.checkingDistro": "{{distro}} 배포판 확인 중...", - "wsl.onboarding.listingDistros": "배포판 목록 확인 중...", - "wsl.onboarding.distroReady": "{{distro}} 배포판이 준비되었습니다.", - "wsl.onboarding.distroNotInstalled": "{{distro}} 배포판이 아직 설치되지 않았습니다.", - "wsl.onboarding.openDistroOnce": "설정을 완료하려면 {{distro}} 배포판을 한 번 여세요.", - "wsl.onboarding.finishingDistro": "{{distro}} 배포판 설정을 마무리하는 중입니다.", - "wsl.onboarding.pickDistro": "배포판을 선택하거나 아래에서 설치하세요.", - "wsl.onboarding.checkingOpencode": "OpenCode 확인 중...", - "wsl.onboarding.checkingOpencodeIn": "{{distro}}에서 OpenCode 확인 중...", - "wsl.onboarding.updatingOpencode": "OpenCode 업데이트 중...", - "wsl.onboarding.updatingOpencodeIn": "{{distro}}에서 OpenCode 업데이트 중...", - "wsl.onboarding.updateOpencodeIn": "{{distro}}에서 OpenCode를 업데이트하세요.", - "wsl.onboarding.updateOpencode": "OpenCode 업데이트", - "wsl.onboarding.opencodeReadyIn": "{{distro}}에서 OpenCode를 사용할 준비가 되었습니다.", - "wsl.onboarding.opencodeReady": "OpenCode가 준비되었습니다.", - "wsl.onboarding.installOpencodeIn": "{{distro}}에 OpenCode를 설치하세요.", - "wsl.onboarding.installOpencode": "OpenCode 설치", - "wsl.onboarding.chooseDistroFirst": "먼저 배포판을 선택하세요.", - "wsl.onboarding.loadFailed": "WSL 상태를 불러오지 못했습니다.", - "wsl.onboarding.loading": "로딩 중...", - "wsl.onboarding.installedDistros": "설치된 배포판", - "wsl.onboarding.checkAgain": "다시 확인", - "wsl.onboarding.distroStatus.ready": "준비됨", - "wsl.onboarding.distroStatus.checking": "확인 중...", - "wsl.onboarding.distroStatus.opencodeMissing": "OpenCode가 설치되지 않음", - "wsl.onboarding.distroStatus.missingTools": "bash, curl 없음", - "wsl.onboarding.distroStatus.unsupported": "지원되지 않음 · WSL 2 사용", - "wsl.onboarding.needAnotherDistro": "다른 배포판이 필요하신가요?", - "wsl.onboarding.needAnotherDistroHint": "WSL 카탈로그에서 Linux 배포판을 설치하세요", - "wsl.onboarding.wslNotInstalled.title": "WSL이 설치되지 않음", - "wsl.onboarding.wslNotInstalled.description": - "OpenCode에서 WSL 서버를 추가하려면 WSL (Windows Subsystem for Linux)이 필요합니다", - "wsl.onboarding.wslUnavailable.title": "WSL을 사용할 수 없음", - "wsl.onboarding.wslUnavailable.description": "OpenCode에서 이 컴퓨터의 WSL을 확인할 수 없습니다.", - "wsl.onboarding.installWsl": "WSL 설치", - "wsl.onboarding.windowsRestartRequired": "WSL 설치를 완료하려면 Windows를 다시 시작한 다음 OpenCode를 다시 여세요.", - "wsl.onboarding.next": "다음", - "wsl.onboarding.refresh": "새로 고침", - "wsl.onboarding.allDistrosAdded": "설치된 모든 배포판이 이미 추가되었습니다.", - "wsl.onboarding.noDistros": "아직 감지된 배포판이 없습니다.", - "wsl.onboarding.install": "설치", - "wsl.onboarding.installing": "설치 중...", - "wsl.onboarding.installDistro": "배포판 설치", - "wsl.onboarding.searchDistros": "배포판 검색", - "wsl.onboarding.wsl2Required": "WSL 2가 필요합니다.", - "wsl.onboarding.toolsRequired": "이 배포판에는 bash와 curl이 필요합니다.", - "wsl.onboarding.openTerminal": "터미널 열기", - "wsl.onboarding.path": "경로: {{path}}", - "wsl.onboarding.notFound": "찾을 수 없음", - "wsl.onboarding.version": "버전: {{version}}", - "wsl.onboarding.unknown": "알 수 없음", - "wsl.onboarding.desktopVersion": "데스크톱 {{version}}", - "wsl.onboarding.versionMismatch": "설치된 버전이 데스크톱 앱 버전과 일치하지 않습니다.", - "wsl.onboarding.adding": "추가 중...", - - "language.uk": "Українська", - "error.page.description.localServerStartup": "로컬 서버를 시작하는 동안 오류가 발생했습니다.", - "error.page.action.exportLogs": "로그 내보내기", - - "home.title": "홈", - "home.projects": "프로젝트", - "home.project.add": "프로젝트 추가", - "home.recentlyClosed": "최근에 닫은 항목", - "home.server.collapse": "서버 프로젝트 접기", - "home.server.expand": "서버 프로젝트 펼치기", - "home.sessions.search.placeholder": "세션 검색", - "home.sessions.search.placeholder.scoped": "{{scope}}에서 세션 검색", - "home.sessions.search.sessions": "세션", - "home.sessions.search.noResults": "{{query}}에 해당하는 세션을 찾을 수 없습니다", - "home.sessions.empty": "아직 아무것도 없습니다", - "home.sessions.empty.description": "세션을 생성하여 시작하세요", - "home.sessions.group.today": "오늘", - "home.sessions.group.yesterday": "어제", - "home.sessions.group.older": "이전", - "home.providerTip": "75개 이상의 제공업체에 연결하여 Claude, GPT, Gemini 등의 다른 모델을 사용하세요", - - "session.tab.unknown": "알 수 없는 세션", - "session.error.notFound": "이 세션을 찾을 수 없습니다", - "session.error.notFound.description": "이 탭은 이 서버에 더 이상 존재하지 않는 세션을 가리킵니다.", - "session.error.notFound.closeTab": "탭 닫기", - "session.error.serverConnection": "이 서버에 연결할 수 없습니다", - "session.review.noUncommittedChanges": "아직 커밋되지 않은 변경 사항이 없습니다", - "session.review.noBranchChanges": "아직 브랜치 변경 사항이 없습니다", - "session.question.minimize": "질문 최소화", - "session.question.restore": "질문 복원", - "session.question.pending.one": "{{count}}개의 대기 중인 질문", - "session.question.pending.other": "{{count}}개의 대기 중인 질문", - "session.new.project.new": "새 프로젝트", - "session.new.project.search": "프로젝트 검색", - "session.new.project.add": "프로젝트 추가", - "session.new.workspace.runIn": "세션 실행 위치", - "session.new.workspace.triggerLocal": "로컬", - "session.new.workspace.local": "로컬 저장소", - "session.new.workspace.existing": "작업 공간…", - "session.new.git.none": "Git 없음", - - "sidebar.empty.title": "열린 프로젝트 없음", - "sidebar.empty.description": "프로젝트를 열어 시작하세요", - - "settings.general.section.advanced": "고급", - "settings.general.row.shell.title": "터미널 셸", - "settings.general.row.shell.description": - "터미널에서 사용할 셸을 선택하세요. 호환되는 셸은 에이전트 도구 호출에도 사용됩니다.", - "settings.general.row.shell.autoDefault": "자동 (기본값)", - "settings.general.row.shell.terminalOnly": "터미널 전용", - "settings.general.row.showFileTree.title": "파일 트리", - "settings.general.row.showFileTree.description": "세션에 파일 트리 패널 표시", - "settings.general.row.showNavigation.title": "탐색 컨트롤", - "settings.general.row.showNavigation.description": "데스크톱 제목 표시줄에 뒤로 및 앞으로 버튼 표시", - "settings.general.row.showSearch.title": "명령 팔레트", - "settings.general.row.showSearch.description": "제목 표시줄에 검색 및 명령 팔레트 버튼 표시", - "settings.general.row.showTerminal.title": "터미널", - "settings.general.row.showTerminal.description": "데스크톱 제목 표시줄에 터미널 버튼 표시", - "settings.general.row.showStatus.title": "서버 상태", - "settings.general.row.showStatus.description": "제목 표시줄에 서버 상태 버튼 표시", - "settings.general.row.mobileTitlebarBottom.title": "하단 탐색", - "settings.general.row.mobileTitlebarBottom.description": "모바일에서 제목 표시줄과 세션 탭을 화면 하단에 배치", - "settings.general.row.showCustomAgents.title": "사용자 지정 에이전트", - "settings.general.row.showCustomAgents.description": "입력창에 에이전트 선택기 표시", - "settings.general.row.newInterface.title": "새 레이아웃", - "settings.general.row.newInterface.badge": "신규", - "settings.general.row.newInterface.description": - "새 탭과 홈 화면 레이아웃을 사용합니다. 제한된 기간 동안 레이아웃을 전환할 수 있습니다.", - "settings.general.row.newInterfaceNotice.title": "이제 새 레이아웃을 사용 중입니다", - "settings.general.row.newInterfaceNotice.description": "이전 레이아웃은 더 이상 사용할 수 없습니다", - "settings.general.row.newInterfaceNotice.dismiss": "닫기", - "settings.general.row.pinchZoom.title": "핀치 줌", - "settings.general.row.pinchZoom.description": "트랙패드 핀치 및 Ctrl-스크롤 제스처로 확대/축소 허용", - "settings.updates.action.downloading": "다운로드 중...", - "settings.updates.action.installing": "설치 중...", } diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 959e4c68cb..8ec11de192 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -77,7 +77,7 @@ export const dict = { "command.permissions.autoaccept.enable": "Aksepter tillatelser automatisk", "command.permissions.autoaccept.disable": "Stopp automatisk akseptering av tillatelser", "command.workspace.toggle": "Veksle arbeidsområder", - "command.workspace.toggle.description": "Aktiver eller deaktiver flere arbeidsområder i sidefeltet", + "command.workspace.toggle.description": "Enable or disable multiple workspaces in the sidebar", "command.session.undo": "Angre", "command.session.undo.description": "Angre siste melding", "command.session.redo": "Gjør om", @@ -92,7 +92,6 @@ export const dict = { "command.session.unshare.description": "Slutt å dele denne sesjonen", "palette.search.placeholder": "Søk i filer, kommandoer og sesjoner", - "palette.search.placeholder.home": "Søk i kommandoer og sesjoner", "palette.empty": "Ingen resultater funnet", "palette.group.commands": "Kommandoer", "palette.group.files": "Filer", @@ -278,17 +277,11 @@ export const dict = { "prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst", "prompt.context.removeFile": "Fjern fil fra kontekst", "prompt.action.attachFile": "Legg ved fil", - "prompt.menu.addImagesAndFiles": "Legg til filer og mer", - "prompt.menu.imagesAndFiles": "Bilder og filer", - "prompt.menu.commands": "Kommandoer", - "prompt.menu.context": "Kontekst", - "prompt.menu.shellCommand": "Shell-kommando", "prompt.attachment.remove": "Fjern vedlegg", "prompt.action.send": "Send", "prompt.action.stop": "Stopp", "prompt.toast.pasteUnsupported.title": "Ikke støttet vedlegg", - "prompt.toast.attachmentDuplicate.title": "Denne filen er allerede lastet opp", "prompt.toast.pasteUnsupported.description": "Kun bilder, PDF-er eller tekstfiler kan legges ved her.", "prompt.toast.modelAgentRequired.title": "Velg en agent og modell", "prompt.toast.modelAgentRequired.description": "Velg en agent og modell før du sender en forespørsel.", @@ -476,7 +469,7 @@ export const dict = { "Rotelement ikke funnet. Glemte du å legge det til i index.html? Eller kanskje id-attributten er feilstavet?", "error.serverSync.connectFailed": "Kunne ikke koble til server. Kjører det en server på `{{url}}`?", - "directory.error.invalidUrl": "Ugyldig mappe i URL-en.", + "directory.error.invalidUrl": "Invalid directory in URL.", "error.chain.unknown": "Ukjent feil", "error.chain.causedBy": "Forårsaket av:", @@ -656,8 +649,8 @@ export const dict = { "settings.general.row.theme.description": "Tilpass hvordan OpenCode er tematisert.", "settings.general.row.font.title": "Kodefont", "settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker", - "settings.general.row.terminalFont.title": "Terminalskrift", - "settings.general.row.terminalFont.description": "Tilpass skrifttypen som brukes i terminalen", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "UI-skrift", "settings.general.row.uiFont.description": "Tilpass skrifttypen som brukes i hele grensesnittet", "settings.general.row.followup.title": "Oppfølgingsadferd", @@ -943,170 +936,4 @@ export const dict = { "error.childStore.persistedProjectIconCreateFailed": "Kunne ikke opprette vedvarende prosjektikon", "error.childStore.storeCreateFailed": "Kunne ikke opprette lager", "terminal.connectionLost.abnormalClose": "WebSocket lukket unormalt: {{code}}", - - "command.project.previous": "Forrige prosjekt", - "command.project.next": "Neste prosjekt", - "command.project.index": "Bytt til prosjekt {{index}}", - "command.tab.reopenClosed": "Åpne lukket fane på nytt", - - "model.tooltip.context.label": "Kontekst", - "model.tooltip.inputs": "Inndata", - "model.tooltip.model": "Modell", - "model.tooltip.provider": "Leverandør", - "model.tooltip.reasoning": "Resonnering", - - "common.clear": "Tøm", - - "session.child.promptDisabled": "Underagentsesjoner kan ikke motta forespørsler.", - "session.child.backToParent": "Tilbake til hovedsesjonen.", - - "wsl.server.add": "Legg til WSL-server", - "wsl.server.addShort": "Legg til WSL", - "wsl.server.label": "WSL", - "wsl.server.menu.label": "WSL-server", - "wsl.server.retryStart": "Prøv å starte på nytt", - "wsl.server.updating": "Oppdaterer...", - "wsl.onboarding.step.distro": "Velg distribusjon", - "wsl.onboarding.step.opencode": "OpenCode", - "wsl.onboarding.checkingRuntime": "Sjekker WSL...", - "wsl.onboarding.restartRequired": "Windows må startes på nytt for å fullføre installasjonen av WSL.", - "wsl.onboarding.ready": "WSL er klart.", - "wsl.onboarding.required": "WSL kreves for å fortsette.", - "wsl.onboarding.checkingDistros": "Sjekker distribusjoner...", - "wsl.onboarding.installingDistro": "Installerer {{distro}}...", - "wsl.onboarding.checkingDistro": "Sjekker {{distro}}...", - "wsl.onboarding.listingDistros": "Lister distribusjoner...", - "wsl.onboarding.distroReady": "{{distro}} er klar.", - "wsl.onboarding.distroNotInstalled": "{{distro}} er ikke installert ennå.", - "wsl.onboarding.openDistroOnce": "Åpne {{distro}} én gang for å fullføre oppsettet.", - "wsl.onboarding.finishingDistro": "Fullfører oppsettet for {{distro}}.", - "wsl.onboarding.pickDistro": "Velg en distribusjon eller installer en nedenfor.", - "wsl.onboarding.checkingOpencode": "Sjekker OpenCode...", - "wsl.onboarding.checkingOpencodeIn": "Sjekker OpenCode i {{distro}}...", - "wsl.onboarding.updatingOpencode": "Oppdaterer OpenCode...", - "wsl.onboarding.updatingOpencodeIn": "Oppdaterer OpenCode i {{distro}}...", - "wsl.onboarding.updateOpencodeIn": "Oppdater OpenCode i {{distro}}.", - "wsl.onboarding.updateOpencode": "Oppdater OpenCode", - "wsl.onboarding.opencodeReadyIn": "OpenCode er klar i {{distro}}.", - "wsl.onboarding.opencodeReady": "OpenCode er klar.", - "wsl.onboarding.installOpencodeIn": "Installer OpenCode i {{distro}}.", - "wsl.onboarding.installOpencode": "Installer OpenCode", - "wsl.onboarding.chooseDistroFirst": "Velg en distribusjon først.", - "wsl.onboarding.loadFailed": "Kunne ikke laste inn WSL-status.", - "wsl.onboarding.loading": "Laster...", - "wsl.onboarding.installedDistros": "Installerte distribusjoner", - "wsl.onboarding.checkAgain": "Sjekk på nytt", - "wsl.onboarding.distroStatus.ready": "Klar", - "wsl.onboarding.distroStatus.checking": "Sjekker...", - "wsl.onboarding.distroStatus.opencodeMissing": "OpenCode er ikke installert", - "wsl.onboarding.distroStatus.missingTools": "Mangler bash, curl", - "wsl.onboarding.distroStatus.unsupported": "Ikke støttet · Bruk WSL 2", - "wsl.onboarding.needAnotherDistro": "Trenger du en annen distribusjon?", - "wsl.onboarding.needAnotherDistroHint": "Installer en Linux-distribusjon fra WSL-katalogen", - "wsl.onboarding.wslNotInstalled.title": "WSL er ikke installert", - "wsl.onboarding.wslNotInstalled.description": - "WSL (Windows Subsystem for Linux) kreves før OpenCode kan legge til en WSL-server", - "wsl.onboarding.wslUnavailable.title": "WSL er utilgjengelig", - "wsl.onboarding.wslUnavailable.description": "OpenCode kunne ikke bekrefte WSL på denne maskinen.", - "wsl.onboarding.installWsl": "Installer WSL", - "wsl.onboarding.windowsRestartRequired": - "Start Windows på nytt for å fullføre installasjonen av WSL, og åpne deretter OpenCode på nytt.", - "wsl.onboarding.next": "Neste", - "wsl.onboarding.refresh": "Oppdater", - "wsl.onboarding.allDistrosAdded": "Alle installerte distribusjoner er allerede lagt til.", - "wsl.onboarding.noDistros": "Ingen distribusjoner oppdaget ennå.", - "wsl.onboarding.install": "Installer", - "wsl.onboarding.installing": "Installerer...", - "wsl.onboarding.installDistro": "Installer distribusjon", - "wsl.onboarding.searchDistros": "Søk etter distribusjoner", - "wsl.onboarding.wsl2Required": "WSL 2 er påkrevd.", - "wsl.onboarding.toolsRequired": "Denne distribusjonen trenger bash og curl.", - "wsl.onboarding.openTerminal": "Åpne terminal", - "wsl.onboarding.path": "Bane: {{path}}", - "wsl.onboarding.notFound": "ikke funnet", - "wsl.onboarding.version": "Versjon: {{version}}", - "wsl.onboarding.unknown": "ukjent", - "wsl.onboarding.desktopVersion": "skrivebordsapp {{version}}", - "wsl.onboarding.versionMismatch": "Den installerte versjonen samsvarer ikke med versjonen av skrivebordsappen.", - "wsl.onboarding.adding": "Legger til...", - - "language.uk": "Українська", - - "error.page.description.localServerStartup": "Det oppstod en feil under oppstart av den lokale serveren.", - "error.page.action.exportLogs": "Eksporter logger", - - "home.title": "Hjem", - "home.projects": "Prosjekter", - "home.project.add": "Legg til prosjekt", - "home.recentlyClosed": "Nylig lukket", - "home.server.collapse": "Fold sammen serverprosjekter", - "home.server.expand": "Utvid serverprosjekter", - "home.sessions.search.placeholder": "Søk etter sesjoner", - "home.sessions.search.placeholder.scoped": "Søk etter sesjoner i {{scope}}", - "home.sessions.search.sessions": "Sesjoner", - "home.sessions.search.noResults": "Ingen sesjoner funnet for {{query}}", - "home.sessions.empty": "Ingenting her ennå", - "home.sessions.empty.description": "Opprett en sesjon for å komme i gang", - "home.sessions.group.today": "I dag", - "home.sessions.group.yesterday": "I går", - "home.sessions.group.older": "Eldre", - "home.providerTip": - "Koble til over 75 leverandører for å bruke andre modeller, inkludert Claude, GPT, Gemini og flere", - - "session.tab.unknown": "Ukjent sesjon", - "session.error.notFound": "Denne sesjonen finnes ikke", - "session.error.notFound.description": "Denne fanen peker til en sesjon som ikke lenger finnes på denne serveren.", - "session.error.notFound.closeTab": "Lukk fane", - "session.error.serverConnection": "Kan ikke koble til denne serveren", - "session.review.noUncommittedChanges": "Ingen ucommittede endringer ennå", - "session.review.noBranchChanges": "Ingen grenendringer ennå", - "session.question.minimize": "Minimer spørsmål", - "session.question.restore": "Gjenopprett spørsmål", - "session.question.pending.one": "{{count}} ventende spørsmål", - "session.question.pending.other": "{{count}} ventende spørsmål", - "session.new.project.new": "Nytt prosjekt", - "session.new.project.search": "Søk etter prosjekter", - "session.new.project.add": "Legg til prosjekt", - "session.new.workspace.runIn": "Kjør sesjonen i", - "session.new.workspace.triggerLocal": "Lokalt", - "session.new.workspace.local": "Lokalt depot", - "session.new.workspace.existing": "Arbeidsområde…", - "session.new.git.none": "Ingen Git", - - "sidebar.empty.title": "Ingen åpne prosjekter", - "sidebar.empty.description": "Åpne et prosjekt for å komme i gang", - - "settings.general.section.advanced": "Avansert", - "settings.general.row.shell.title": "Terminalskall", - "settings.general.row.shell.description": - "Velg skallet som skal brukes i terminalen. Kompatible skall brukes også til agentens verktøykall.", - "settings.general.row.shell.autoDefault": "Automatisk (standard)", - "settings.general.row.shell.terminalOnly": "kun terminal", - "settings.general.row.showFileTree.title": "Filtre", - "settings.general.row.showFileTree.description": "Vis filtrepanelet i sesjoner", - "settings.general.row.showNavigation.title": "Navigasjonskontroller", - "settings.general.row.showNavigation.description": - "Vis tilbake- og fremoverknappene på tittellinjen i skrivebordsappen", - "settings.general.row.showSearch.title": "Kommandopalett", - "settings.general.row.showSearch.description": "Vis knappen for søk og kommandopalett på tittellinjen", - "settings.general.row.showTerminal.title": "Terminal", - "settings.general.row.showTerminal.description": "Vis terminalknappen på tittellinjen i skrivebordsappen", - "settings.general.row.showStatus.title": "Serverstatus", - "settings.general.row.showStatus.description": "Vis serverstatusknappen på tittellinjen", - "settings.general.row.mobileTitlebarBottom.title": "Navigasjon nederst", - "settings.general.row.mobileTitlebarBottom.description": - "Plasser tittellinjen og sesjonsfanene nederst på mobilskjermen", - "settings.general.row.showCustomAgents.title": "Egendefinerte agenter", - "settings.general.row.showCustomAgents.description": "Vis agentvelgeren i skrivefeltet", - "settings.general.row.newInterface.title": "Nytt oppsett", - "settings.general.row.newInterface.badge": "Ny", - "settings.general.row.newInterface.description": - "Bruk de nye fanene og startsidens oppsett. I en begrenset periode kan du bytte mellom oppsettene.", - "settings.general.row.newInterfaceNotice.title": "Du bruker nå det nye oppsettet", - "settings.general.row.newInterfaceNotice.description": "Det forrige oppsettet er ikke lenger tilgjengelig", - "settings.general.row.newInterfaceNotice.dismiss": "Avvis", - "settings.general.row.pinchZoom.title": "Knip for å zoome", - "settings.general.row.pinchZoom.description": "Tillat knipebevegelser på styreflaten og Ctrl-rulling for å zoome", - "settings.updates.action.downloading": "Laster ned...", - "settings.updates.action.installing": "Installerer...", } satisfies Partial> diff --git a/packages/app/src/i18n/parity.test.ts b/packages/app/src/i18n/parity.test.ts index 566be37e64..8ad1beb56b 100644 --- a/packages/app/src/i18n/parity.test.ts +++ b/packages/app/src/i18n/parity.test.ts @@ -1,118 +1,33 @@ import { describe, expect, test } from "bun:test" +import { dict as en } from "./en" +import { dict as ar } from "./ar" +import { dict as br } from "./br" +import { dict as bs } from "./bs" +import { dict as da } from "./da" +import { dict as de } from "./de" +import { dict as es } from "./es" +import { dict as fr } from "./fr" +import { dict as ja } from "./ja" +import { dict as ko } from "./ko" +import { dict as no } from "./no" +import { dict as pl } from "./pl" +import { dict as ru } from "./ru" +import { dict as uk } from "./uk" +import { dict as th } from "./th" +import { dict as zh } from "./zh" +import { dict as zht } from "./zht" +import { dict as tr } from "./tr" -const appLocales = [ - "ar", - "br", - "bs", - "da", - "de", - "es", - "fr", - "ja", - "ko", - "no", - "pl", - "ru", - "uk", - "th", - "tr", - "zh", - "zht", -] as const -const desktopLocales = appLocales.filter((locale) => locale !== "th" && locale !== "tr") +const locales = [ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, uk, th, tr, zh, zht] +const keys = ["command.session.previous.unseen", "command.session.next.unseen"] as const -const domains = [ - { - name: "app", - source: "./en.ts", - target: (locale: string) => `./${locale}.ts`, - locales: appLocales, - }, - { - name: "ui", - source: "../../../ui/src/i18n/en.ts", - target: (locale: string) => `../../../ui/src/i18n/${locale}.ts`, - locales: appLocales, - }, - { - name: "desktop", - source: "../../../desktop/src/renderer/i18n/en.ts", - target: (locale: string) => `../../../desktop/src/renderer/i18n/${locale}.ts`, - locales: desktopLocales, - }, -] as const - -describe.skipIf(!!process.env.CI)("i18n parity", () => { - test("non-English locales have every English key", async () => { - for (const domain of domains) { - const source = await dictionary(domain.source) - for (const locale of domain.locales) { - const target = await dictionary(domain.target(locale)) - const missing = Object.keys(source).filter((key) => !Object.hasOwn(target, key)) - const extra = Object.keys(target).filter((key) => !Object.hasOwn(source, key)) - expect({ domain: domain.name, locale, missing, extra }).toEqual({ - domain: domain.name, - locale, - missing: [], - extra: [], - }) - } - } - }) - - test("non-English locales preserve English placeholders", async () => { - for (const domain of domains) { - const source = await dictionary(domain.source) - for (const locale of domain.locales) { - const target = await dictionary(domain.target(locale)) - const mismatched = Object.keys(source).filter( - (key) => Object.hasOwn(target, key) && placeholders(source[key]).join() !== placeholders(target[key]).join(), - ) - expect({ domain: domain.name, locale, mismatched }).toEqual({ domain: domain.name, locale, mismatched: [] }) - } - } - }) - - test("non-English locales translate targeted unseen session keys", async () => { - const source = await dictionary("./en.ts") - for (const locale of appLocales) { - const target = await dictionary(`./${locale}.ts`) - for (const key of ["command.session.previous.unseen", "command.session.next.unseen"]) { - expect(target[key]).toBeDefined() - expect(target[key]).not.toBe(source[key]) - } - } - }) - - test("changed-file summary keys preserve rendered English copy and localize complete phrases", async () => { - const source = await dictionary("../../../ui/src/i18n/en.ts") - expect(source["ui.sessionTurn.diffs.changed.one"].replace("{{count}}", "1")).toBe("1 Changed file") - expect(source["ui.sessionTurn.diffs.changed.other"].replace("{{count}}", "2")).toBe("2 Changed files") - expect(source["ui.sessionTurn.diffs.changed"]).toBeUndefined() - - for (const locale of appLocales) { - const target = await dictionary(`../../../ui/src/i18n/${locale}.ts`) - for (const key of ["ui.sessionTurn.diffs.changed.one", "ui.sessionTurn.diffs.changed.other"]) { - expect(target[key].trim()).not.toBe("") - expect(placeholders(target[key])).toEqual(["count"]) +describe("i18n parity", () => { + test("non-English locales translate targeted unseen session keys", () => { + for (const locale of locales) { + for (const key of keys) { + expect(locale[key]).toBeDefined() + expect(locale[key]).not.toBe(en[key]) } } }) }) - -async function dictionary(file: string) { - const module: unknown = await import(file) - if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) { - throw new Error(`Invalid translation dictionary: ${file}`) - } - return module.dict -} - -function isDictionary(value: unknown): value is Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) return false - return Object.values(value).every((item) => typeof item === "string") -} - -function placeholders(value: string) { - return Array.from(value.matchAll(/{{\s*([^}]+?)\s*}}/g), (match) => match[1]).sort() -} diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 7517f16b81..fcf9927da0 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -21,9 +21,6 @@ export const dict = { "theme.scheme.dark": "Ciemny", "command.sidebar.toggle": "Przełącz pasek boczny", "command.project.open": "Otwórz projekt", - "command.project.previous": "Poprzedni projekt", - "command.project.next": "Następny projekt", - "command.project.index": "Przełącz na projekt {{index}}", "command.provider.connect": "Połącz dostawcę", "command.server.switch": "Przełącz serwer", "command.settings.open": "Otwórz ustawienia", @@ -42,7 +39,6 @@ export const dict = { "command.session.new": "Nowa sesja", "command.file.open": "Otwórz plik", "command.tab.close": "Zamknij kartę", - "command.tab.reopenClosed": "Otwórz ponownie zamkniętą kartę", "command.context.addSelection": "Dodaj zaznaczenie do kontekstu", "command.context.addSelection.description": "Dodaj zaznaczone linie z bieżącego pliku", "command.input.focus": "Fokus na pole wejściowe", @@ -86,7 +82,6 @@ export const dict = { "command.session.unshare": "Przestań udostępniać sesję", "command.session.unshare.description": "Zatrzymaj udostępnianie tej sesji", "palette.search.placeholder": "Szukaj plików, poleceń i sesji", - "palette.search.placeholder.home": "Szukaj poleceń i sesji", "palette.empty": "Brak wyników", "palette.group.commands": "Polecenia", "palette.group.files": "Pliki", @@ -199,11 +194,6 @@ export const dict = { "model.input.video": "wideo", "model.input.pdf": "pdf", "model.tooltip.allows": "Obsługuje: {{inputs}}", - "model.tooltip.context.label": "Kontekst", - "model.tooltip.inputs": "Dane wejściowe", - "model.tooltip.model": "Model", - "model.tooltip.provider": "Dostawca", - "model.tooltip.reasoning": "Wnioskowanie", "model.tooltip.reasoning.allowed": "Obsługuje wnioskowanie", "model.tooltip.reasoning.none": "Brak wnioskowania", "model.tooltip.context": "Limit kontekstu {{limit}}", @@ -213,7 +203,6 @@ export const dict = { "common.loading": "Ładowanie", "common.loading.ellipsis": "...", "common.cancel": "Anuluj", - "common.clear": "Wyczyść", "common.connect": "Połącz", "common.disconnect": "Rozłącz", "common.continue": "Prześlij", @@ -230,8 +219,6 @@ export const dict = { "prompt.mode.shell": "Terminal", "prompt.mode.normal": "Prompt", "prompt.mode.shell.exit": "esc aby wyjść", - "session.child.promptDisabled": "Nie można wysyłać promptów w sesjach podagentów.", - "session.child.backToParent": "Wróć do głównej sesji.", "prompt.example.1": "Napraw TODO w bazie kodu", "prompt.example.2": "Jaki jest stos technologiczny tego projektu?", "prompt.example.3": "Napraw zepsute testy", @@ -269,16 +256,10 @@ export const dict = { "prompt.context.removeActiveFile": "Usuń aktywny plik z kontekstu", "prompt.context.removeFile": "Usuń plik z kontekstu", "prompt.action.attachFile": "Załącz plik", - "prompt.menu.addImagesAndFiles": "Dodaj pliki i inne elementy", - "prompt.menu.imagesAndFiles": "Obrazy i pliki", - "prompt.menu.commands": "Polecenia", - "prompt.menu.context": "Kontekst", - "prompt.menu.shellCommand": "Polecenie powłoki", "prompt.attachment.remove": "Usuń załącznik", "prompt.action.send": "Wyślij", "prompt.action.stop": "Zatrzymaj", "prompt.toast.pasteUnsupported.title": "Nieobsługiwany załącznik", - "prompt.toast.attachmentDuplicate.title": "Ten plik został już przesłany", "prompt.toast.pasteUnsupported.description": "Można tutaj załączać tylko obrazy, pliki PDF lub pliki tekstowe.", "prompt.toast.modelAgentRequired.title": "Wybierz agenta i model", "prompt.toast.modelAgentRequired.description": "Wybierz agenta i model przed wysłaniem zapytania.", @@ -334,75 +315,6 @@ export const dict = { "dialog.server.menu.delete": "Usuń", "dialog.server.current": "Obecny serwer", "dialog.server.status.default": "Domyślny", - "wsl.server.add": "Dodaj serwer WSL", - "wsl.server.addShort": "Dodaj WSL", - "wsl.server.label": "WSL", - "wsl.server.menu.label": "Serwer WSL", - "wsl.server.retryStart": "Ponów uruchamianie", - "wsl.server.updating": "Aktualizowanie...", - "wsl.onboarding.step.distro": "Wybierz dystrybucję", - "wsl.onboarding.step.opencode": "OpenCode", - "wsl.onboarding.checkingRuntime": "Sprawdzanie WSL...", - "wsl.onboarding.restartRequired": "Aby dokończyć instalację WSL, uruchom ponownie system Windows.", - "wsl.onboarding.ready": "WSL jest gotowy.", - "wsl.onboarding.required": "Aby kontynuować, wymagany jest WSL.", - "wsl.onboarding.checkingDistros": "Sprawdzanie dystrybucji...", - "wsl.onboarding.installingDistro": "Instalowanie {{distro}}...", - "wsl.onboarding.checkingDistro": "Sprawdzanie {{distro}}...", - "wsl.onboarding.listingDistros": "Pobieranie listy dystrybucji...", - "wsl.onboarding.distroReady": "Dystrybucja {{distro}} jest gotowa.", - "wsl.onboarding.distroNotInstalled": "Dystrybucja {{distro}} nie jest jeszcze zainstalowana.", - "wsl.onboarding.openDistroOnce": "Uruchom raz {{distro}}, aby dokończyć konfigurację.", - "wsl.onboarding.finishingDistro": "Kończenie konfiguracji {{distro}}.", - "wsl.onboarding.pickDistro": "Wybierz dystrybucję lub zainstaluj jedną z poniższych.", - "wsl.onboarding.checkingOpencode": "Sprawdzanie OpenCode...", - "wsl.onboarding.checkingOpencodeIn": "Sprawdzanie OpenCode w dystrybucji {{distro}}...", - "wsl.onboarding.updatingOpencode": "Aktualizowanie OpenCode...", - "wsl.onboarding.updatingOpencodeIn": "Aktualizowanie OpenCode w dystrybucji {{distro}}...", - "wsl.onboarding.updateOpencodeIn": "Zaktualizuj OpenCode w dystrybucji {{distro}}.", - "wsl.onboarding.updateOpencode": "Zaktualizuj OpenCode", - "wsl.onboarding.opencodeReadyIn": "OpenCode jest gotowy w dystrybucji {{distro}}.", - "wsl.onboarding.opencodeReady": "OpenCode jest gotowy.", - "wsl.onboarding.installOpencodeIn": "Zainstaluj OpenCode w dystrybucji {{distro}}.", - "wsl.onboarding.installOpencode": "Zainstaluj OpenCode", - "wsl.onboarding.chooseDistroFirst": "Najpierw wybierz dystrybucję.", - "wsl.onboarding.loadFailed": "Nie udało się załadować stanu WSL.", - "wsl.onboarding.loading": "Ładowanie...", - "wsl.onboarding.installedDistros": "Zainstalowane dystrybucje", - "wsl.onboarding.checkAgain": "Sprawdź ponownie", - "wsl.onboarding.distroStatus.ready": "Gotowa", - "wsl.onboarding.distroStatus.checking": "Sprawdzanie...", - "wsl.onboarding.distroStatus.opencodeMissing": "OpenCode nie jest zainstalowany", - "wsl.onboarding.distroStatus.missingTools": "Brak bash i curl", - "wsl.onboarding.distroStatus.unsupported": "Nieobsługiwana · Użyj WSL 2", - "wsl.onboarding.needAnotherDistro": "Potrzebujesz innej dystrybucji?", - "wsl.onboarding.needAnotherDistroHint": "Zainstaluj dystrybucję systemu Linux z katalogu WSL", - "wsl.onboarding.wslNotInstalled.title": "WSL nie jest zainstalowany", - "wsl.onboarding.wslNotInstalled.description": - "WSL (Podsystem Windows dla systemu Linux) jest wymagany, zanim OpenCode będzie mógł dodać serwer WSL", - "wsl.onboarding.wslUnavailable.title": "WSL jest niedostępny", - "wsl.onboarding.wslUnavailable.description": "OpenCode nie mógł zweryfikować WSL na tym komputerze.", - "wsl.onboarding.installWsl": "Zainstaluj WSL", - "wsl.onboarding.windowsRestartRequired": - "Uruchom ponownie system Windows, aby dokończyć instalację WSL, a następnie ponownie otwórz OpenCode.", - "wsl.onboarding.next": "Dalej", - "wsl.onboarding.refresh": "Odśwież", - "wsl.onboarding.allDistrosAdded": "Wszystkie zainstalowane dystrybucje zostały już dodane.", - "wsl.onboarding.noDistros": "Nie wykryto jeszcze żadnych dystrybucji.", - "wsl.onboarding.install": "Zainstaluj", - "wsl.onboarding.installing": "Instalowanie...", - "wsl.onboarding.installDistro": "Zainstaluj dystrybucję", - "wsl.onboarding.searchDistros": "Szukaj dystrybucji", - "wsl.onboarding.wsl2Required": "Wymagany jest WSL 2.", - "wsl.onboarding.toolsRequired": "Ta dystrybucja wymaga bash i curl.", - "wsl.onboarding.openTerminal": "Otwórz terminal", - "wsl.onboarding.path": "Ścieżka: {{path}}", - "wsl.onboarding.notFound": "nie znaleziono", - "wsl.onboarding.version": "Wersja: {{version}}", - "wsl.onboarding.unknown": "nieznana", - "wsl.onboarding.desktopVersion": "aplikacja komputerowa {{version}}", - "wsl.onboarding.versionMismatch": "Zainstalowana wersja nie jest zgodna z wersją aplikacji komputerowej.", - "wsl.onboarding.adding": "Dodawanie...", "dialog.project.edit.title": "Edytuj projekt", "dialog.project.edit.name": "Nazwa", "dialog.project.edit.icon": "Ikona", @@ -412,8 +324,7 @@ export const dict = { "dialog.project.edit.color": "Kolor", "dialog.project.edit.color.select": "Wybierz kolor {{color}}", "dialog.project.edit.worktree.startup": "Skrypt uruchamiania przestrzeni roboczej", - "dialog.project.edit.worktree.startup.description": - "Uruchamiany po utworzeniu nowej przestrzeni roboczej (worktree).", + "dialog.project.edit.worktree.startup.description": "Runs after creating a new workspace (worktree).", "dialog.project.edit.worktree.startup.placeholder": "np. bun install", "context.breakdown.title": "Podział kontekstu", "context.breakdown.note": 'Przybliżony podział tokenów wejściowych. "Inne" obejmuje definicje narzędzi i narzut.', @@ -460,7 +371,6 @@ export const dict = { "language.no": "Norsk", "language.br": "Português (Brasil)", "language.bs": "Bosanski", - "language.uk": "Українська", "language.th": "ไทย", "language.tr": "Türkçe", "toast.language.title": "Język", @@ -497,12 +407,10 @@ export const dict = { "toast.update.action.notYet": "Jeszcze nie", "error.page.title": "Coś poszło nie tak", "error.page.description": "Wystąpił błąd podczas ładowania aplikacji.", - "error.page.description.localServerStartup": "Wystąpił błąd podczas uruchamiania serwera lokalnego.", "error.page.details.label": "Szczegóły błędu", "error.page.action.restart": "Restartuj", "error.page.action.report": "Zgłoś błąd", "error.page.action.reported": "Błąd zgłoszony", - "error.page.action.exportLogs": "Eksportuj dzienniki", "error.page.action.checking": "Sprawdzanie...", "error.page.action.checkUpdates": "Sprawdź aktualizacje", "error.page.action.updateTo": "Zaktualizuj do {{version}}", @@ -522,8 +430,7 @@ export const dict = { "error.chain.didYouMean": "Czy miałeś na myśli: {{suggestions}}", "error.chain.modelNotFound": "Model nie znaleziony: {{provider}}/{{model}}", "error.chain.checkConfig": "Sprawdź swoją konfigurację (opencode.json) nazwy dostawców/modeli", - "error.chain.mcpFailed": - "Serwer MCP „{{name}}” nie uruchomił się. Uwaga: OpenCode nie obsługuje jeszcze uwierzytelniania MCP.", + "error.chain.mcpFailed": 'MCP server "{{name}}" failed. Note, OpenCode does not support MCP authentication yet.', "error.chain.providerAuthFailed": "Uwierzytelnianie dostawcy nie powiodło się ({{provider}}): {{message}}", "error.chain.providerInitFailed": 'Nie udało się zainicjować dostawcy "{{provider}}". Sprawdź poświadczenia i konfigurację.', @@ -545,32 +452,10 @@ export const dict = { "home.recentProjects": "Ostatnie projekty", "home.empty.title": "Brak ostatnich projektów", "home.empty.description": "Zacznij od otwarcia lokalnego projektu", - "home.title": "Strona główna", - "home.projects": "Projekty", - "home.project.add": "Dodaj projekt", - "home.recentlyClosed": "Ostatnio zamknięte", - "home.server.collapse": "Zwiń projekty serwera", - "home.server.expand": "Rozwiń projekty serwera", - "home.sessions.search.placeholder": "Szukaj sesji", - "home.sessions.search.placeholder.scoped": "Szukaj sesji w {{scope}}", - "home.sessions.search.sessions": "Sesje", - "home.sessions.search.noResults": "Nie znaleziono sesji dla {{query}}", - "home.sessions.empty": "Jeszcze nic tu nie ma", - "home.sessions.empty.description": "Utwórz sesję, aby rozpocząć", - "home.sessions.group.today": "Dzisiaj", - "home.sessions.group.yesterday": "Wczoraj", - "home.sessions.group.older": "Starsze", - "home.providerTip": - "Połącz się z ponad 75 dostawcami, aby korzystać z innych modeli, w tym Claude, GPT, Gemini i innych", "session.tab.session": "Sesja", "session.tab.review": "Przegląd", "session.tab.context": "Kontekst", - "session.tab.unknown": "Nieznana sesja", "session.panel.reviewAndFiles": "Przegląd i pliki", - "session.error.notFound": "Nie można znaleźć tej sesji", - "session.error.notFound.description": "Ta karta wskazuje sesję, która nie istnieje już na tym serwerze.", - "session.error.notFound.closeTab": "Zamknij kartę", - "session.error.serverConnection": "Nie można połączyć się z tym serwerem", "session.review.filesChanged": "Zmieniono {{count}} plików", "session.review.change.one": "Zmiana", "session.review.change.other": "Zmiany", @@ -579,8 +464,6 @@ export const dict = { "session.review.noVcs": "Nie wykryto systemu kontroli wersji Git, zmiany nie są wyświetlane", "session.review.noSnapshot": "Śledzenie migawek jest wyłączone w konfiguracji, więc zmiany w sesji są niedostępne", "session.review.noChanges": "Brak zmian", - "session.review.noUncommittedChanges": "Brak jeszcze niezatwierdzonych zmian", - "session.review.noBranchChanges": "Brak jeszcze zmian w gałęzi", "session.files.selectToOpen": "Wybierz plik do otwarcia", "session.files.all": "Wszystkie pliki", "session.files.empty": "Brak plików", @@ -594,10 +477,6 @@ export const dict = { "session.todo.title": "Zadania", "session.todo.collapse": "Zwiń", "session.todo.expand": "Rozwiń", - "session.question.minimize": "Zminimalizuj pytanie", - "session.question.restore": "Przywróć pytanie", - "session.question.pending.one": "{{count}} oczekujące pytanie", - "session.question.pending.other": "Pytania oczekujące: {{count}}", "session.followupDock.summary.one": "{{count}} wiadomość w kolejce", "session.followupDock.summary.other": "{{count}} wiadomości w kolejce", "session.followupDock.sendNow": "Wyślij teraz", @@ -610,17 +489,9 @@ export const dict = { "session.revertDock.expand": "Rozwiń cofnięte wiadomości", "session.revertDock.restore": "Przywróć wiadomość", "session.new.title": "Zbuduj cokolwiek", - "session.new.project.new": "Nowy projekt", - "session.new.project.search": "Szukaj projektów", - "session.new.project.add": "Dodaj projekt", "session.new.worktree.main": "Główna gałąź", "session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})", "session.new.worktree.create": "Utwórz nowe drzewo robocze", - "session.new.workspace.runIn": "Uruchom sesję w", - "session.new.workspace.triggerLocal": "Lokalnie", - "session.new.workspace.local": "Lokalne repozytorium", - "session.new.workspace.existing": "Przestrzeń robocza…", - "session.new.git.none": "Brak Git", "session.new.lastModified": "Ostatnio zmodyfikowano", "session.header.search.placeholder": "Szukaj {{project}}", "session.header.searchFiles": "Szukaj plików", @@ -684,18 +555,15 @@ export const dict = { "sidebar.project.recentSessions": "Ostatnie sesje", "sidebar.project.viewAllSessions": "Zobacz wszystkie sesje", "sidebar.project.clearNotifications": "Wyczyść powiadomienia", - "sidebar.empty.title": "Brak otwartych projektów", - "sidebar.empty.description": "Otwórz projekt, aby rozpocząć", "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Pulpit", "settings.section.server": "Serwer", "settings.tab.general": "Ogólne", "settings.tab.shortcuts": "Skróty", "settings.desktop.section.wsl": "WSL", - "settings.desktop.wsl.title": "Integracja z WSL", - "settings.desktop.wsl.description": "Uruchamiaj serwer OpenCode wewnątrz WSL w systemie Windows.", + "settings.desktop.wsl.title": "WSL integration", + "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", "settings.general.section.appearance": "Wygląd", - "settings.general.section.advanced": "Zaawansowane", "settings.general.section.notifications": "Powiadomienia systemowe", "settings.general.section.updates": "Aktualizacje", "settings.general.section.sounds": "Efekty dźwiękowe", @@ -703,11 +571,6 @@ export const dict = { "settings.general.section.display": "Ekran", "settings.general.row.language.title": "Język", "settings.general.row.language.description": "Zmień język wyświetlania dla OpenCode", - "settings.general.row.shell.title": "Powłoka terminala", - "settings.general.row.shell.description": - "Wybierz powłokę używaną w terminalu. Zgodne powłoki są również używane przez wywołania narzędzi agenta.", - "settings.general.row.shell.autoDefault": "Automatycznie (domyślna)", - "settings.general.row.shell.terminalOnly": "tylko terminal", "settings.general.row.appearance.title": "Wygląd", "settings.general.row.appearance.description": "Dostosuj wygląd OpenCode na swoim urządzeniu", "settings.general.row.colorScheme.title": "Schemat kolorów", @@ -717,30 +580,14 @@ export const dict = { "settings.general.row.theme.description": "Dostosuj motyw OpenCode.", "settings.general.row.font.title": "Czcionka kodu", "settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu", - "settings.general.row.terminalFont.title": "Czcionka terminala", - "settings.general.row.terminalFont.description": "Dostosuj czcionkę używaną w terminalu", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "Czcionka interfejsu", "settings.general.row.uiFont.description": "Dostosuj czcionkę używaną w całym interfejsie", "settings.general.row.followup.title": "Zachowanie kontynuacji", "settings.general.row.followup.description": "Wybierz, czy kontynuacja ma być natychmiastowa, czy czekać w kolejce", "settings.general.row.followup.option.queue": "Kolejka", "settings.general.row.followup.option.steer": "Sterowanie", - "settings.general.row.showFileTree.title": "Drzewo plików", - "settings.general.row.showFileTree.description": "Pokaż panel drzewa plików w sesjach", - "settings.general.row.showNavigation.title": "Elementy nawigacji", - "settings.general.row.showNavigation.description": - "Pokaż przyciski Wstecz i Dalej na pasku tytułu aplikacji komputerowej", - "settings.general.row.showSearch.title": "Paleta poleceń", - "settings.general.row.showSearch.description": "Pokaż przycisk wyszukiwania i palety poleceń na pasku tytułu", - "settings.general.row.showTerminal.title": "Terminal", - "settings.general.row.showTerminal.description": "Pokaż przycisk terminala na pasku tytułu aplikacji komputerowej", - "settings.general.row.showStatus.title": "Status serwera", - "settings.general.row.showStatus.description": "Pokaż przycisk statusu serwera na pasku tytułu", - "settings.general.row.mobileTitlebarBottom.title": "Dolna nawigacja", - "settings.general.row.mobileTitlebarBottom.description": - "Umieść pasek tytułu i karty sesji u dołu ekranu na urządzeniach mobilnych", - "settings.general.row.showCustomAgents.title": "Agenci niestandardowi", - "settings.general.row.showCustomAgents.description": "Pokaż wybór agenta w edytorze wiadomości", "settings.general.row.reasoningSummaries.title": "Pokaż podsumowania wnioskowania", "settings.general.row.reasoningSummaries.description": "Wyświetlaj podsumowania wnioskowania modelu na osi czasu", "settings.general.row.shellToolPartsExpanded.title": "Rozwijaj elementy narzędzia shell", @@ -749,16 +596,6 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit", "settings.general.row.editToolPartsExpanded.description": "Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu", - "settings.general.row.newInterface.title": "Nowy układ", - "settings.general.row.newInterface.badge": "Nowość", - "settings.general.row.newInterface.description": - "Używaj nowych kart i układu strony głównej. Przez ograniczony czas możesz przełączać się między układami.", - "settings.general.row.newInterfaceNotice.title": "Korzystasz teraz z nowego układu", - "settings.general.row.newInterfaceNotice.description": "Poprzedni układ nie jest już dostępny", - "settings.general.row.newInterfaceNotice.dismiss": "Odrzuć", - "settings.general.row.pinchZoom.title": "Powiększanie gestem szczypania", - "settings.general.row.pinchZoom.description": - "Zezwalaj na powiększanie gestem szczypania na gładziku i przewijaniem z klawiszem Ctrl", "settings.general.row.wayland.title": "Użyj natywnego Wayland", "settings.general.row.wayland.description": "Wyłącz fallback X11 na Wayland. Wymaga restartu.", "settings.general.row.wayland.tooltip": @@ -771,8 +608,6 @@ export const dict = { "settings.updates.row.check.description": "Ręcznie sprawdź aktualizacje i zainstaluj, jeśli są dostępne", "settings.updates.action.checkNow": "Sprawdź teraz", "settings.updates.action.checking": "Sprawdzanie...", - "settings.updates.action.downloading": "Pobieranie...", - "settings.updates.action.installing": "Instalowanie...", "settings.updates.toast.latest.title": "Masz najnowszą wersję", "settings.updates.toast.latest.description": "Korzystasz z najnowszej wersji OpenCode.", "sound.option.none": "Brak", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 17d6ba58a9..b2f58b101b 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -23,9 +23,6 @@ export const dict = { "command.sidebar.toggle": "Переключить боковую панель", "command.project.open": "Открыть проект", - "command.project.previous": "Предыдущий проект", - "command.project.next": "Следующий проект", - "command.project.index": "Переключиться на проект {{index}}", "command.provider.connect": "Подключить провайдера", "command.server.switch": "Переключить сервер", "command.settings.open": "Открыть настройки", @@ -48,7 +45,6 @@ export const dict = { "command.session.new": "Новая сессия", "command.file.open": "Открыть файл", "command.tab.close": "Закрыть вкладку", - "command.tab.reopenClosed": "Повторно открыть закрытую вкладку", "command.context.addSelection": "Добавить выделение в контекст", "command.context.addSelection.description": "Добавить выбранные строки из текущего файла", "command.input.focus": "Фокус на поле ввода", @@ -93,7 +89,6 @@ export const dict = { "command.session.unshare.description": "Прекратить публикацию сессии", "palette.search.placeholder": "Поиск файлов, команд и сессий", - "palette.search.placeholder.home": "Поиск команд и сессий", "palette.empty": "Ничего не найдено", "palette.group.commands": "Команды", "palette.group.files": "Файлы", @@ -213,11 +208,6 @@ export const dict = { "model.input.video": "видео", "model.input.pdf": "pdf", "model.tooltip.allows": "Разрешено: {{inputs}}", - "model.tooltip.context.label": "Контекст", - "model.tooltip.inputs": "Входные данные", - "model.tooltip.model": "Модель", - "model.tooltip.provider": "Провайдер", - "model.tooltip.reasoning": "Рассуждение", "model.tooltip.reasoning.allowed": "Разрешает рассуждение", "model.tooltip.reasoning.none": "Без рассуждения", "model.tooltip.context": "Лимит контекста {{limit}}", @@ -228,7 +218,6 @@ export const dict = { "common.loading": "Загрузка", "common.loading.ellipsis": "...", "common.cancel": "Отмена", - "common.clear": "Очистить", "common.connect": "Подключить", "common.disconnect": "Отключить", "common.continue": "Отправить", @@ -246,8 +235,6 @@ export const dict = { "prompt.mode.shell": "Оболочка", "prompt.mode.normal": "Промпт", "prompt.mode.shell.exit": "esc для выхода", - "session.child.promptDisabled": "В сессиях субагентов нельзя отправлять запросы.", - "session.child.backToParent": "Вернуться к основной сессии.", "prompt.example.1": "Исправить TODO в коде", "prompt.example.2": "Какой технологический стек этого проекта?", @@ -287,17 +274,11 @@ export const dict = { "prompt.context.removeActiveFile": "Удалить активный файл из контекста", "prompt.context.removeFile": "Удалить файл из контекста", "prompt.action.attachFile": "Прикрепить файл", - "prompt.menu.addImagesAndFiles": "Добавить файлы и другое", - "prompt.menu.imagesAndFiles": "Изображения и файлы", - "prompt.menu.commands": "Команды", - "prompt.menu.context": "Контекст", - "prompt.menu.shellCommand": "Команда оболочки", "prompt.attachment.remove": "Удалить вложение", "prompt.action.send": "Отправить", "prompt.action.stop": "Остановить", "prompt.toast.pasteUnsupported.title": "Неподдерживаемое вложение", - "prompt.toast.attachmentDuplicate.title": "Этот файл уже загружен", "prompt.toast.pasteUnsupported.description": "Здесь можно прикрепить только изображения, PDF или текстовые файлы.", "prompt.toast.modelAgentRequired.title": "Выберите агента и модель", "prompt.toast.modelAgentRequired.description": "Выберите агента и модель перед отправкой запроса.", @@ -360,75 +341,6 @@ export const dict = { "dialog.server.menu.delete": "Удалить", "dialog.server.current": "Текущий сервер", "dialog.server.status.default": "По умолч.", - "wsl.server.add": "Добавить сервер WSL", - "wsl.server.addShort": "Добавить WSL", - "wsl.server.label": "WSL", - "wsl.server.menu.label": "Сервер WSL", - "wsl.server.retryStart": "Повторить запуск", - "wsl.server.updating": "Обновление...", - "wsl.onboarding.step.distro": "Выбрать дистрибутив", - "wsl.onboarding.step.opencode": "OpenCode", - "wsl.onboarding.checkingRuntime": "Проверка WSL...", - "wsl.onboarding.restartRequired": "Чтобы завершить установку WSL, перезапустите Windows.", - "wsl.onboarding.ready": "WSL готова к работе.", - "wsl.onboarding.required": "Для продолжения требуется WSL.", - "wsl.onboarding.checkingDistros": "Проверка дистрибутивов...", - "wsl.onboarding.installingDistro": "Установка {{distro}}...", - "wsl.onboarding.checkingDistro": "Проверка {{distro}}...", - "wsl.onboarding.listingDistros": "Получение списка дистрибутивов...", - "wsl.onboarding.distroReady": "Дистрибутив {{distro}} готов к работе.", - "wsl.onboarding.distroNotInstalled": "Дистрибутив {{distro}} пока не установлен.", - "wsl.onboarding.openDistroOnce": "Откройте {{distro}} один раз, чтобы завершить настройку.", - "wsl.onboarding.finishingDistro": "Завершение настройки {{distro}}.", - "wsl.onboarding.pickDistro": "Выберите дистрибутив или установите его ниже.", - "wsl.onboarding.checkingOpencode": "Проверка OpenCode...", - "wsl.onboarding.checkingOpencodeIn": "Проверка OpenCode в {{distro}}...", - "wsl.onboarding.updatingOpencode": "Обновление OpenCode...", - "wsl.onboarding.updatingOpencodeIn": "Обновление OpenCode в {{distro}}...", - "wsl.onboarding.updateOpencodeIn": "Обновите OpenCode в {{distro}}.", - "wsl.onboarding.updateOpencode": "Обновить OpenCode", - "wsl.onboarding.opencodeReadyIn": "OpenCode в {{distro}} готов к работе.", - "wsl.onboarding.opencodeReady": "OpenCode готов к работе.", - "wsl.onboarding.installOpencodeIn": "Установите OpenCode в {{distro}}.", - "wsl.onboarding.installOpencode": "Установить OpenCode", - "wsl.onboarding.chooseDistroFirst": "Сначала выберите дистрибутив.", - "wsl.onboarding.loadFailed": "Не удалось загрузить состояние WSL.", - "wsl.onboarding.loading": "Загрузка...", - "wsl.onboarding.installedDistros": "Установленные дистрибутивы", - "wsl.onboarding.checkAgain": "Проверить снова", - "wsl.onboarding.distroStatus.ready": "Готов", - "wsl.onboarding.distroStatus.checking": "Проверка...", - "wsl.onboarding.distroStatus.opencodeMissing": "OpenCode не установлен", - "wsl.onboarding.distroStatus.missingTools": "Отсутствуют bash и curl", - "wsl.onboarding.distroStatus.unsupported": "Не поддерживается · Используйте WSL 2", - "wsl.onboarding.needAnotherDistro": "Нужен другой дистрибутив?", - "wsl.onboarding.needAnotherDistroHint": "Установите дистрибутив Linux из каталога WSL", - "wsl.onboarding.wslNotInstalled.title": "WSL не установлена", - "wsl.onboarding.wslNotInstalled.description": - "Для добавления сервера WSL в OpenCode требуется WSL (Windows Subsystem for Linux)", - "wsl.onboarding.wslUnavailable.title": "WSL недоступна", - "wsl.onboarding.wslUnavailable.description": "OpenCode не смог проверить WSL на этом компьютере.", - "wsl.onboarding.installWsl": "Установить WSL", - "wsl.onboarding.windowsRestartRequired": - "Перезапустите Windows, чтобы завершить установку WSL, затем снова откройте OpenCode.", - "wsl.onboarding.next": "Далее", - "wsl.onboarding.refresh": "Обновить", - "wsl.onboarding.allDistrosAdded": "Все установленные дистрибутивы уже добавлены.", - "wsl.onboarding.noDistros": "Дистрибутивы пока не обнаружены.", - "wsl.onboarding.install": "Установить", - "wsl.onboarding.installing": "Установка...", - "wsl.onboarding.installDistro": "Установить дистрибутив", - "wsl.onboarding.searchDistros": "Поиск дистрибутивов", - "wsl.onboarding.wsl2Required": "Требуется WSL 2.", - "wsl.onboarding.toolsRequired": "Для этого дистрибутива нужны bash и curl.", - "wsl.onboarding.openTerminal": "Открыть терминал", - "wsl.onboarding.path": "Путь: {{path}}", - "wsl.onboarding.notFound": "не найден", - "wsl.onboarding.version": "Версия: {{version}}", - "wsl.onboarding.unknown": "неизвестно", - "wsl.onboarding.desktopVersion": "приложение {{version}}", - "wsl.onboarding.versionMismatch": "Установленная версия не соответствует версии приложения.", - "wsl.onboarding.adding": "Добавление...", "dialog.project.edit.title": "Редактировать проект", "dialog.project.edit.name": "Название", @@ -493,7 +405,6 @@ export const dict = { "language.no": "Norsk", "language.br": "Português (Brasil)", "language.bs": "Bosanski", - "language.uk": "Українська", "language.th": "ไทย", "language.tr": "Türkçe", @@ -541,12 +452,10 @@ export const dict = { "error.page.title": "Что-то пошло не так", "error.page.description": "Произошла ошибка при загрузке приложения.", - "error.page.description.localServerStartup": "Произошла ошибка при запуске локального сервера.", "error.page.details.label": "Детали ошибки", "error.page.action.restart": "Перезапустить", "error.page.action.report": "Сообщить об ошибке", "error.page.action.reported": "Об ошибке сообщено", - "error.page.action.exportLogs": "Экспортировать журналы", "error.page.action.checking": "Проверка...", "error.page.action.checkUpdates": "Проверить обновления", "error.page.action.updateTo": "Обновить до {{version}}", @@ -596,33 +505,11 @@ export const dict = { "home.recentProjects": "Недавние проекты", "home.empty.title": "Нет недавних проектов", "home.empty.description": "Начните с открытия локального проекта", - "home.title": "Главная", - "home.projects": "Проекты", - "home.project.add": "Добавить проект", - "home.recentlyClosed": "Недавно закрытые", - "home.server.collapse": "Свернуть проекты сервера", - "home.server.expand": "Развернуть проекты сервера", - "home.sessions.search.placeholder": "Поиск сессий", - "home.sessions.search.placeholder.scoped": "Поиск сессий в {{scope}}", - "home.sessions.search.sessions": "Сессии", - "home.sessions.search.noResults": "Сессии по запросу {{query}} не найдены", - "home.sessions.empty": "Здесь пока ничего нет", - "home.sessions.empty.description": "Создайте сессию, чтобы начать", - "home.sessions.group.today": "Сегодня", - "home.sessions.group.yesterday": "Вчера", - "home.sessions.group.older": "Ранее", - "home.providerTip": - "Подключитесь к более чем 75 провайдерам, чтобы использовать другие модели, включая Claude, GPT, Gemini и другие", "session.tab.session": "Сессия", "session.tab.review": "Обзор", "session.tab.context": "Контекст", - "session.tab.unknown": "Неизвестная сессия", "session.panel.reviewAndFiles": "Обзор и файлы", - "session.error.notFound": "Эта сессия не найдена", - "session.error.notFound.description": "Эта вкладка указывает на сессию, которой больше нет на этом сервере.", - "session.error.notFound.closeTab": "Закрыть вкладку", - "session.error.serverConnection": "Не удаётся подключиться к этому серверу", "session.review.filesChanged": "{{count}} файлов изменено", "session.review.change.one": "Изменение", "session.review.change.other": "Изменения", @@ -631,8 +518,6 @@ export const dict = { "session.review.noVcs": "Система контроля версий Git не обнаружена, изменения не отображаются", "session.review.noSnapshot": "Отслеживание снимков отключено в настройках, поэтому изменения сессии недоступны", "session.review.noChanges": "Нет изменений", - "session.review.noUncommittedChanges": "Пока нет незафиксированных изменений", - "session.review.noBranchChanges": "Пока нет изменений в ветке", "session.files.selectToOpen": "Выберите файл, чтобы открыть", "session.files.all": "Все файлы", "session.files.empty": "Нет файлов", @@ -647,10 +532,6 @@ export const dict = { "session.todo.title": "Задачи", "session.todo.collapse": "Свернуть", "session.todo.expand": "Развернуть", - "session.question.minimize": "Свернуть вопрос", - "session.question.restore": "Восстановить вопрос", - "session.question.pending.one": "{{count}} вопрос без ответа", - "session.question.pending.other": "Вопросы без ответа: {{count}}", "session.followupDock.summary.one": "{{count}} сообщение в очереди", "session.followupDock.summary.other": "{{count}} сообщений в очереди", "session.followupDock.sendNow": "Отправить сейчас", @@ -664,17 +545,9 @@ export const dict = { "session.revertDock.restore": "Восстановить сообщение", "session.new.title": "Создавайте что угодно", - "session.new.project.new": "Новый проект", - "session.new.project.search": "Поиск проектов", - "session.new.project.add": "Добавить проект", "session.new.worktree.main": "Основная ветка", "session.new.worktree.mainWithBranch": "Основная ветка ({{branch}})", "session.new.worktree.create": "Создать новый worktree", - "session.new.workspace.runIn": "Запустить сессию в", - "session.new.workspace.triggerLocal": "Локально", - "session.new.workspace.local": "Локальный репозиторий", - "session.new.workspace.existing": "Рабочее пространство…", - "session.new.git.none": "Нет Git", "session.new.lastModified": "Последнее изменение", "session.header.search.placeholder": "Поиск {{project}}", @@ -746,8 +619,6 @@ export const dict = { "sidebar.project.recentSessions": "Недавние сессии", "sidebar.project.viewAllSessions": "Посмотреть все сессии", "sidebar.project.clearNotifications": "Очистить уведомления", - "sidebar.empty.title": "Нет открытых проектов", - "sidebar.empty.description": "Откройте проект, чтобы начать", "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Приложение", @@ -759,7 +630,6 @@ export const dict = { "settings.desktop.wsl.description": "Запускать сервер OpenCode внутри WSL на Windows.", "settings.general.section.appearance": "Внешний вид", - "settings.general.section.advanced": "Дополнительно", "settings.general.section.notifications": "Системные уведомления", "settings.general.section.updates": "Обновления", "settings.general.section.sounds": "Звуковые эффекты", @@ -768,11 +638,6 @@ export const dict = { "settings.general.row.language.title": "Язык", "settings.general.row.language.description": "Изменить язык отображения OpenCode", - "settings.general.row.shell.title": "Оболочка терминала", - "settings.general.row.shell.description": - "Выберите оболочку для терминала. Совместимые оболочки также используются агентом при вызове инструментов.", - "settings.general.row.shell.autoDefault": "Авто (по умолчанию)", - "settings.general.row.shell.terminalOnly": "только терминал", "settings.general.row.appearance.title": "Внешний вид", "settings.general.row.appearance.description": "Настройте как OpenCode выглядит на вашем устройстве", "settings.general.row.colorScheme.title": "Цветовая схема", @@ -781,8 +646,8 @@ export const dict = { "settings.general.row.theme.description": "Настройте оформление OpenCode.", "settings.general.row.font.title": "Шрифт кода", "settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода", - "settings.general.row.terminalFont.title": "Шрифт терминала", - "settings.general.row.terminalFont.description": "Настройте шрифт, используемый в терминале", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "Шрифт интерфейса", "settings.general.row.uiFont.description": "Настройте шрифт, используемый во всем интерфейсе", "settings.general.row.followup.title": "Поведение уточняющих вопросов", @@ -790,22 +655,6 @@ export const dict = { "Выберите, отправлять ли уточняющие вопросы сразу или помещать их в очередь", "settings.general.row.followup.option.queue": "Очередь", "settings.general.row.followup.option.steer": "Направлять", - "settings.general.row.showFileTree.title": "Дерево файлов", - "settings.general.row.showFileTree.description": "Показывать панель дерева файлов в сессиях", - "settings.general.row.showNavigation.title": "Элементы навигации", - "settings.general.row.showNavigation.description": - "Показывать кнопки «Назад» и «Вперёд» в строке заголовка приложения", - "settings.general.row.showSearch.title": "Палитра команд", - "settings.general.row.showSearch.description": "Показывать кнопку поиска и палитры команд в строке заголовка", - "settings.general.row.showTerminal.title": "Терминал", - "settings.general.row.showTerminal.description": "Показывать кнопку терминала в строке заголовка приложения", - "settings.general.row.showStatus.title": "Состояние сервера", - "settings.general.row.showStatus.description": "Показывать кнопку состояния сервера в строке заголовка", - "settings.general.row.mobileTitlebarBottom.title": "Нижняя навигация", - "settings.general.row.mobileTitlebarBottom.description": - "На мобильных устройствах размещать строку заголовка и вкладки сессии внизу экрана", - "settings.general.row.showCustomAgents.title": "Пользовательские агенты", - "settings.general.row.showCustomAgents.description": "Показывать выбор агента в редакторе запросов", "settings.general.row.reasoningSummaries.title": "Показывать сводки рассуждений", "settings.general.row.reasoningSummaries.description": "Отображать сводки рассуждений модели в ленте", @@ -815,16 +664,6 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit", "settings.general.row.editToolPartsExpanded.description": "Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию", - "settings.general.row.newInterface.title": "Новая компоновка", - "settings.general.row.newInterface.badge": "Новое", - "settings.general.row.newInterface.description": - "Используйте новые вкладки и компоновку главной страницы. В течение ограниченного времени можно переключаться между вариантами компоновки.", - "settings.general.row.newInterfaceNotice.title": "Теперь вы используете новую компоновку", - "settings.general.row.newInterfaceNotice.description": "Прежняя компоновка больше недоступна", - "settings.general.row.newInterfaceNotice.dismiss": "Закрыть", - "settings.general.row.pinchZoom.title": "Масштабирование щипком", - "settings.general.row.pinchZoom.description": - "Разрешить масштабирование жестом щипка на трекпаде и прокруткой с Ctrl", "settings.general.row.wayland.title": "Использовать нативный Wayland", "settings.general.row.wayland.description": "Отключить X11 fallback на Wayland. Требуется перезапуск.", "settings.general.row.wayland.tooltip": @@ -839,8 +678,6 @@ export const dict = { "settings.updates.row.check.description": "Проверить обновления вручную и установить, если доступны", "settings.updates.action.checkNow": "Проверить сейчас", "settings.updates.action.checking": "Проверка...", - "settings.updates.action.downloading": "Загрузка...", - "settings.updates.action.installing": "Установка...", "settings.updates.toast.latest.title": "У вас последняя версия", "settings.updates.toast.latest.description": "Вы используете последнюю версию OpenCode.", "sound.option.none": "Нет", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 2c0b7d081c..d066f3c616 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -23,9 +23,6 @@ export const dict = { "command.sidebar.toggle": "สลับแถบข้าง", "command.project.open": "เปิดโปรเจกต์", - "command.project.previous": "โปรเจกต์ก่อนหน้า", - "command.project.next": "โปรเจกต์ถัดไป", - "command.project.index": "สลับไปยังโปรเจกต์ {{index}}", "command.provider.connect": "เชื่อมต่อผู้ให้บริการ", "command.server.switch": "สลับเซิร์ฟเวอร์", "command.settings.open": "เปิดการตั้งค่า", @@ -35,7 +32,7 @@ export const dict = { "command.session.next.unseen": "เซสชันที่ยังไม่ได้อ่านถัดไป", "command.session.archive": "จัดเก็บเซสชัน", - "command.palette": "ชุดคำสั่ง", + "command.palette": "คำสั่งค้นหา", "command.theme.cycle": "เปลี่ยนธีม", "command.theme.set": "ใช้ธีม: {{theme}}", @@ -48,7 +45,6 @@ export const dict = { "command.session.new": "เซสชันใหม่", "command.file.open": "เปิดไฟล์", "command.tab.close": "ปิดแท็บ", - "command.tab.reopenClosed": "เปิดแท็บที่ปิดไปอีกครั้ง", "command.context.addSelection": "เพิ่มส่วนที่เลือกไปยังบริบท", "command.context.addSelection.description": "เพิ่มบรรทัดที่เลือกจากไฟล์ปัจจุบัน", "command.input.focus": "โฟกัสช่องป้อนข้อมูล", @@ -93,7 +89,6 @@ export const dict = { "command.session.unshare.description": "หยุดการแชร์เซสชันนี้", "palette.search.placeholder": "ค้นหาไฟล์ คำสั่ง และเซสชัน", - "palette.search.placeholder.home": "ค้นหาคำสั่งและเซสชัน", "palette.empty": "ไม่พบผลลัพธ์", "palette.group.commands": "คำสั่ง", "palette.group.files": "ไฟล์", @@ -168,7 +163,7 @@ export const dict = { "provider.custom.field.providerID.placeholder": "myprovider", "provider.custom.field.providerID.description": "ตัวอักษรพิมพ์เล็ก ตัวเลข ยัติภังค์ หรือขีดล่าง", "provider.custom.field.name.label": "ชื่อที่แสดง", - "provider.custom.field.name.placeholder": "ผู้ให้บริการ AI ของฉัน", + "provider.custom.field.name.placeholder": "My AI Provider", "provider.custom.field.baseURL.label": "URL พื้นฐาน", "provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1", "provider.custom.field.apiKey.label": "คีย์ API", @@ -213,11 +208,6 @@ export const dict = { "model.input.video": "วิดีโอ", "model.input.pdf": "PDF", "model.tooltip.allows": "อนุญาต: {{inputs}}", - "model.tooltip.context.label": "บริบท", - "model.tooltip.inputs": "ข้อมูลนำเข้า", - "model.tooltip.model": "โมเดล", - "model.tooltip.provider": "ผู้ให้บริการ", - "model.tooltip.reasoning": "การใช้เหตุผล", "model.tooltip.reasoning.allowed": "อนุญาตการใช้เหตุผล", "model.tooltip.reasoning.none": "ไม่มีการใช้เหตุผล", "model.tooltip.context": "ขีดจำกัดบริบท {{limit}}", @@ -228,7 +218,6 @@ export const dict = { "common.loading": "กำลังโหลด", "common.loading.ellipsis": "...", "common.cancel": "ยกเลิก", - "common.clear": "ล้าง", "common.connect": "เชื่อมต่อ", "common.disconnect": "ยกเลิกการเชื่อมต่อ", "common.continue": "ส่ง", @@ -246,8 +235,6 @@ export const dict = { "prompt.mode.shell": "เชลล์", "prompt.mode.normal": "พรอมต์", "prompt.mode.shell.exit": "กด esc เพื่อออก", - "session.child.promptDisabled": "ไม่สามารถส่งพรอมต์ในเซสชันเอเจนต์ย่อยได้.", - "session.child.backToParent": "กลับไปยังเซสชันหลัก.", "prompt.example.1": "แก้ไข TODO ในโค้ดเบส", "prompt.example.2": "เทคโนโลยีของโปรเจกต์นี้คืออะไร?", @@ -287,17 +274,11 @@ export const dict = { "prompt.context.removeActiveFile": "เอาไฟล์ที่ใช้งานอยู่ออกจากบริบท", "prompt.context.removeFile": "เอาไฟล์ออกจากบริบท", "prompt.action.attachFile": "แนบไฟล์", - "prompt.menu.addImagesAndFiles": "เพิ่มไฟล์และอื่น ๆ", - "prompt.menu.imagesAndFiles": "รูปภาพและไฟล์", - "prompt.menu.commands": "คำสั่ง", - "prompt.menu.context": "บริบท", - "prompt.menu.shellCommand": "คำสั่งเชลล์", "prompt.attachment.remove": "เอาไฟล์แนบออก", "prompt.action.send": "ส่ง", "prompt.action.stop": "หยุด", "prompt.toast.pasteUnsupported.title": "ไฟล์แนบที่ไม่รองรับ", - "prompt.toast.attachmentDuplicate.title": "ไฟล์นี้ถูกอัปโหลดแล้ว", "prompt.toast.pasteUnsupported.description": "แนบได้เฉพาะรูปภาพ, PDF หรือไฟล์ข้อความเท่านั้น", "prompt.toast.modelAgentRequired.title": "เลือกเอเจนต์และโมเดล", "prompt.toast.modelAgentRequired.description": "เลือกเอเจนต์และโมเดลก่อนส่งพร้อมท์", @@ -360,75 +341,6 @@ export const dict = { "dialog.server.menu.delete": "ลบ", "dialog.server.current": "เซิร์ฟเวอร์ปัจจุบัน", "dialog.server.status.default": "ค่าเริ่มต้น", - "wsl.server.add": "เพิ่มเซิร์ฟเวอร์ WSL", - "wsl.server.addShort": "เพิ่ม WSL", - "wsl.server.label": "WSL", - "wsl.server.menu.label": "เซิร์ฟเวอร์ WSL", - "wsl.server.retryStart": "ลองเริ่มอีกครั้ง", - "wsl.server.updating": "กำลังอัปเดต...", - "wsl.onboarding.step.distro": "เลือกดิสโทร", - "wsl.onboarding.step.opencode": "OpenCode", - "wsl.onboarding.checkingRuntime": "กำลังตรวจสอบ WSL...", - "wsl.onboarding.restartRequired": "ต้องรีสตาร์ท Windows เพื่อให้การติดตั้ง WSL เสร็จสมบูรณ์.", - "wsl.onboarding.ready": "WSL พร้อมใช้งานแล้ว.", - "wsl.onboarding.required": "ต้องมี WSL เพื่อดำเนินการต่อ.", - "wsl.onboarding.checkingDistros": "กำลังตรวจสอบดิสโทร...", - "wsl.onboarding.installingDistro": "กำลังติดตั้ง {{distro}}...", - "wsl.onboarding.checkingDistro": "กำลังตรวจสอบ {{distro}}...", - "wsl.onboarding.listingDistros": "กำลังแสดงรายการดิสโทร...", - "wsl.onboarding.distroReady": "{{distro}} พร้อมใช้งานแล้ว.", - "wsl.onboarding.distroNotInstalled": "ยังไม่ได้ติดตั้ง {{distro}}.", - "wsl.onboarding.openDistroOnce": "เปิด {{distro}} หนึ่งครั้งเพื่อให้การตั้งค่าเสร็จสมบูรณ์.", - "wsl.onboarding.finishingDistro": "กำลังตั้งค่า {{distro}} ให้เสร็จสมบูรณ์.", - "wsl.onboarding.pickDistro": "เลือกดิสโทรหรือติดตั้งจากรายการด้านล่าง.", - "wsl.onboarding.checkingOpencode": "กำลังตรวจสอบ OpenCode...", - "wsl.onboarding.checkingOpencodeIn": "กำลังตรวจสอบ OpenCode ใน {{distro}}...", - "wsl.onboarding.updatingOpencode": "กำลังอัปเดต OpenCode...", - "wsl.onboarding.updatingOpencodeIn": "กำลังอัปเดต OpenCode ใน {{distro}}...", - "wsl.onboarding.updateOpencodeIn": "อัปเดต OpenCode ใน {{distro}}.", - "wsl.onboarding.updateOpencode": "อัปเดต OpenCode", - "wsl.onboarding.opencodeReadyIn": "OpenCode พร้อมใช้งานใน {{distro}} แล้ว.", - "wsl.onboarding.opencodeReady": "OpenCode พร้อมใช้งานแล้ว.", - "wsl.onboarding.installOpencodeIn": "ติดตั้ง OpenCode ใน {{distro}}.", - "wsl.onboarding.installOpencode": "ติดตั้ง OpenCode", - "wsl.onboarding.chooseDistroFirst": "เลือกดิสโทรก่อน.", - "wsl.onboarding.loadFailed": "ไม่สามารถโหลดสถานะ WSL ได้.", - "wsl.onboarding.loading": "กำลังโหลด...", - "wsl.onboarding.installedDistros": "ดิสโทรที่ติดตั้งแล้ว", - "wsl.onboarding.checkAgain": "ตรวจสอบอีกครั้ง", - "wsl.onboarding.distroStatus.ready": "พร้อมใช้งาน", - "wsl.onboarding.distroStatus.checking": "กำลังตรวจสอบ...", - "wsl.onboarding.distroStatus.opencodeMissing": "ยังไม่ได้ติดตั้ง OpenCode", - "wsl.onboarding.distroStatus.missingTools": "ไม่มี bash, curl", - "wsl.onboarding.distroStatus.unsupported": "ไม่รองรับ · ใช้ WSL 2", - "wsl.onboarding.needAnotherDistro": "ต้องการดิสโทรอื่น?", - "wsl.onboarding.needAnotherDistroHint": "ติดตั้งดิสทริบิวชัน Linux จากแค็ตตาล็อก WSL", - "wsl.onboarding.wslNotInstalled.title": "ยังไม่ได้ติดตั้ง WSL", - "wsl.onboarding.wslNotInstalled.description": - "ต้องมี WSL (Windows Subsystem for Linux) ก่อนที่ OpenCode จะเพิ่มเซิร์ฟเวอร์ WSL ได้", - "wsl.onboarding.wslUnavailable.title": "WSL ไม่พร้อมใช้งาน", - "wsl.onboarding.wslUnavailable.description": "OpenCode ไม่สามารถตรวจสอบ WSL บนเครื่องนี้ได้.", - "wsl.onboarding.installWsl": "ติดตั้ง WSL", - "wsl.onboarding.windowsRestartRequired": - "รีสตาร์ท Windows เพื่อให้การติดตั้ง WSL เสร็จสมบูรณ์ แล้วเปิด OpenCode อีกครั้ง.", - "wsl.onboarding.next": "ถัดไป", - "wsl.onboarding.refresh": "รีเฟรช", - "wsl.onboarding.allDistrosAdded": "เพิ่มดิสโทรที่ติดตั้งไว้ทั้งหมดแล้ว.", - "wsl.onboarding.noDistros": "ยังไม่พบดิสโทร.", - "wsl.onboarding.install": "ติดตั้ง", - "wsl.onboarding.installing": "กำลังติดตั้ง...", - "wsl.onboarding.installDistro": "ติดตั้งดิสโทร", - "wsl.onboarding.searchDistros": "ค้นหาดิสโทร", - "wsl.onboarding.wsl2Required": "ต้องใช้ WSL 2.", - "wsl.onboarding.toolsRequired": "ดิสโทรนี้ต้องมี bash และ curl.", - "wsl.onboarding.openTerminal": "เปิดเทอร์มินัล", - "wsl.onboarding.path": "เส้นทาง: {{path}}", - "wsl.onboarding.notFound": "ไม่พบ", - "wsl.onboarding.version": "เวอร์ชัน: {{version}}", - "wsl.onboarding.unknown": "ไม่ทราบ", - "wsl.onboarding.desktopVersion": "เดสก์ท็อป {{version}}", - "wsl.onboarding.versionMismatch": "เวอร์ชันที่ติดตั้งไม่ตรงกับเวอร์ชันของแอปเดสก์ท็อป.", - "wsl.onboarding.adding": "กำลังเพิ่ม...", "dialog.project.edit.title": "แก้ไขโปรเจกต์", "dialog.project.edit.name": "ชื่อ", @@ -491,7 +403,6 @@ export const dict = { "language.no": "Norsk", "language.br": "Português (Brasil)", "language.bs": "Bosanski", - "language.uk": "Українська", "language.th": "ไทย", "language.tr": "Türkçe", @@ -540,12 +451,10 @@ export const dict = { "error.page.title": "เกิดข้อผิดพลาด", "error.page.description": "เกิดข้อผิดพลาดระหว่างการโหลดแอปพลิเคชัน", - "error.page.description.localServerStartup": "เกิดข้อผิดพลาดขณะเริ่มเซิร์ฟเวอร์ในเครื่อง.", "error.page.details.label": "รายละเอียดข้อผิดพลาด", "error.page.action.restart": "รีสตาร์ท", "error.page.action.report": "รายงานข้อผิดพลาด", "error.page.action.reported": "รายงานข้อผิดพลาดแล้ว", - "error.page.action.exportLogs": "ส่งออกบันทึก", "error.page.action.checking": "กำลังตรวจสอบ...", "error.page.action.checkUpdates": "ตรวจสอบการอัปเดต", "error.page.action.updateTo": "อัปเดตเป็น {{version}}", @@ -591,32 +500,11 @@ export const dict = { "home.recentProjects": "โปรเจกต์ล่าสุด", "home.empty.title": "ไม่มีโปรเจกต์ล่าสุด", "home.empty.description": "เริ่มต้นโดยเปิดโปรเจกต์ในเครื่อง", - "home.title": "หน้าหลัก", - "home.projects": "โปรเจกต์", - "home.project.add": "เพิ่มโปรเจกต์", - "home.recentlyClosed": "ที่ปิดไปล่าสุด", - "home.server.collapse": "ย่อโปรเจกต์ของเซิร์ฟเวอร์", - "home.server.expand": "ขยายโปรเจกต์ของเซิร์ฟเวอร์", - "home.sessions.search.placeholder": "ค้นหาเซสชัน", - "home.sessions.search.placeholder.scoped": "ค้นหาเซสชันใน {{scope}}", - "home.sessions.search.sessions": "เซสชัน", - "home.sessions.search.noResults": "ไม่พบเซสชันสำหรับ {{query}}", - "home.sessions.empty": "ยังไม่มีอะไรที่นี่", - "home.sessions.empty.description": "สร้างเซสชันเพื่อเริ่มต้น", - "home.sessions.group.today": "วันนี้", - "home.sessions.group.yesterday": "เมื่อวาน", - "home.sessions.group.older": "ก่อนหน้านี้", - "home.providerTip": "เชื่อมต่อกับผู้ให้บริการกว่า 75 รายเพื่อใช้โมเดลอื่นๆ รวมถึง Claude, GPT, Gemini และอีกมากมาย", "session.tab.session": "เซสชัน", "session.tab.review": "ตรวจสอบ", "session.tab.context": "บริบท", - "session.tab.unknown": "เซสชันที่ไม่รู้จัก", "session.panel.reviewAndFiles": "ตรวจสอบและไฟล์", - "session.error.notFound": "ไม่พบเซสชันนี้", - "session.error.notFound.description": "แท็บนี้อ้างถึงเซสชันที่ไม่มีอยู่บนเซิร์ฟเวอร์นี้แล้ว.", - "session.error.notFound.closeTab": "ปิดแท็บ", - "session.error.serverConnection": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์นี้", "session.review.filesChanged": "{{count}} ไฟล์ที่เปลี่ยนแปลง", "session.review.change.one": "การเปลี่ยนแปลง", "session.review.change.other": "การเปลี่ยนแปลง", @@ -625,8 +513,6 @@ export const dict = { "session.review.noVcs": "ไม่ตรวจพบระบบควบคุมเวอร์ชัน Git การเปลี่ยนแปลงจะไม่แสดง", "session.review.noSnapshot": "การติดตามสแนปชอตถูกปิดใช้งานในการกำหนดค่า ดังนั้นการเปลี่ยนแปลงเซสชันจึงไม่พร้อมใช้งาน", "session.review.noChanges": "ไม่มีการเปลี่ยนแปลง", - "session.review.noUncommittedChanges": "ยังไม่มีการเปลี่ยนแปลงที่รอคอมมิต", - "session.review.noBranchChanges": "ยังไม่มีการเปลี่ยนแปลงในสาขา", "session.files.selectToOpen": "เลือกไฟล์เพื่อเปิด", "session.files.empty": "ไม่มีไฟล์", @@ -643,10 +529,6 @@ export const dict = { "session.todo.title": "สิ่งที่ต้องทำ", "session.todo.collapse": "ย่อ", "session.todo.expand": "ขยาย", - "session.question.minimize": "ย่อคำถาม", - "session.question.restore": "คืนค่าคำถาม", - "session.question.pending.one": "คำถามที่รอดำเนินการ {{count}} ข้อ", - "session.question.pending.other": "คำถามที่รอดำเนินการ {{count}} ข้อ", "session.followupDock.summary.one": "{{count}} ข้อความในคิว", "session.followupDock.summary.other": "{{count}} ข้อความในคิว", "session.followupDock.sendNow": "ส่งทันที", @@ -660,17 +542,9 @@ export const dict = { "session.revertDock.restore": "กู้คืนข้อความ", "session.new.title": "สร้างอะไรก็ได้", - "session.new.project.new": "โปรเจกต์ใหม่", - "session.new.project.search": "ค้นหาโปรเจกต์", - "session.new.project.add": "เพิ่มโปรเจกต์", "session.new.worktree.main": "สาขาหลัก", "session.new.worktree.mainWithBranch": "สาขาหลัก ({{branch}})", "session.new.worktree.create": "สร้าง worktree ใหม่", - "session.new.workspace.runIn": "เรียกใช้เซสชันใน", - "session.new.workspace.triggerLocal": "ในเครื่อง", - "session.new.workspace.local": "รีโพซิทอรีในเครื่อง", - "session.new.workspace.existing": "พื้นที่ทำงาน…", - "session.new.git.none": "ไม่มี Git", "session.new.lastModified": "แก้ไขล่าสุด", "session.header.search.placeholder": "ค้นหา {{project}}", @@ -738,8 +612,6 @@ export const dict = { "sidebar.project.recentSessions": "เซสชันล่าสุด", "sidebar.project.viewAllSessions": "ดูเซสชันทั้งหมด", "sidebar.project.clearNotifications": "ล้างการแจ้งเตือน", - "sidebar.empty.title": "ไม่มีโปรเจกต์ที่เปิดอยู่", - "sidebar.empty.description": "เปิดโปรเจกต์เพื่อเริ่มต้น", "app.name.desktop": "OpenCode Desktop", @@ -752,7 +624,6 @@ export const dict = { "settings.desktop.wsl.description": "เรียกใช้เซิร์ฟเวอร์ OpenCode ภายใน WSL บน Windows", "settings.general.section.appearance": "รูปลักษณ์", - "settings.general.section.advanced": "ขั้นสูง", "settings.general.section.notifications": "การแจ้งเตือนระบบ", "settings.general.section.updates": "การอัปเดต", "settings.general.section.sounds": "เสียงเอฟเฟกต์", @@ -761,11 +632,6 @@ export const dict = { "settings.general.row.language.title": "ภาษา", "settings.general.row.language.description": "เปลี่ยนภาษาที่แสดงสำหรับ OpenCode", - "settings.general.row.shell.title": "เชลล์เทอร์มินัล", - "settings.general.row.shell.description": - "เลือกเชลล์ที่จะใช้กับเทอร์มินัล เชลล์ที่เข้ากันได้จะใช้สำหรับการเรียกเครื่องมือของเอเจนต์ด้วย", - "settings.general.row.shell.autoDefault": "อัตโนมัติ (ค่าเริ่มต้น)", - "settings.general.row.shell.terminalOnly": "เทอร์มินัลเท่านั้น", "settings.general.row.appearance.title": "รูปลักษณ์", "settings.general.row.appearance.description": "ปรับแต่งวิธีการที่ OpenCode มีลักษณะบนอุปกรณ์ของคุณ", "settings.general.row.colorScheme.title": "โทนสี", @@ -774,29 +640,14 @@ export const dict = { "settings.general.row.theme.description": "ปรับแต่งวิธีการที่ OpenCode มีธีม", "settings.general.row.font.title": "ฟอนต์โค้ด", "settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ด", - "settings.general.row.terminalFont.title": "ฟอนต์เทอร์มินัล", - "settings.general.row.terminalFont.description": "ปรับแต่งฟอนต์ที่ใช้ในเทอร์มินัล", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "ฟอนต์ UI", "settings.general.row.uiFont.description": "ปรับแต่งฟอนต์ที่ใช้ทั่วทั้งอินเทอร์เฟซ", "settings.general.row.followup.title": "พฤติกรรมการติดตามผล", "settings.general.row.followup.description": "เลือกว่าจะให้พร้อมท์ติดตามผลทำงานทันทีหรือรอในคิว", "settings.general.row.followup.option.queue": "คิว", "settings.general.row.followup.option.steer": "นำทาง", - "settings.general.row.showFileTree.title": "ต้นไม้ไฟล์", - "settings.general.row.showFileTree.description": "แสดงแผงต้นไม้ไฟล์ในเซสชัน", - "settings.general.row.showNavigation.title": "ตัวควบคุมการนำทาง", - "settings.general.row.showNavigation.description": "แสดงปุ่มย้อนกลับและไปข้างหน้าในแถบชื่อเรื่องเดสก์ท็อป", - "settings.general.row.showSearch.title": "ชุดคำสั่ง", - "settings.general.row.showSearch.description": "แสดงปุ่มค้นหาและชุดคำสั่งในแถบชื่อเรื่อง", - "settings.general.row.showTerminal.title": "เทอร์มินัล", - "settings.general.row.showTerminal.description": "แสดงปุ่มเทอร์มินัลในแถบชื่อเรื่องเดสก์ท็อป", - "settings.general.row.showStatus.title": "สถานะเซิร์ฟเวอร์", - "settings.general.row.showStatus.description": "แสดงปุ่มสถานะเซิร์ฟเวอร์ในแถบชื่อเรื่อง", - "settings.general.row.mobileTitlebarBottom.title": "การนำทางด้านล่าง", - "settings.general.row.mobileTitlebarBottom.description": - "วางแถบชื่อเรื่องและแท็บเซสชันไว้ด้านล่างของหน้าจอบนอุปกรณ์เคลื่อนที่", - "settings.general.row.showCustomAgents.title": "เอเจนต์ที่กำหนดเอง", - "settings.general.row.showCustomAgents.description": "แสดงตัวเลือกเอเจนต์ในช่องเขียนข้อความ", "settings.general.row.reasoningSummaries.title": "แสดงสรุปการใช้เหตุผล", "settings.general.row.reasoningSummaries.description": "แสดงสรุปการใช้เหตุผลของโมเดลในไทม์ไลน์", "settings.general.row.shellToolPartsExpanded.title": "ขยายส่วนเครื่องมือ shell", @@ -804,15 +655,6 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit", "settings.general.row.editToolPartsExpanded.description": "แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์", - "settings.general.row.newInterface.title": "เลย์เอาต์ใหม่", - "settings.general.row.newInterface.badge": "ใหม่", - "settings.general.row.newInterface.description": - "ใช้แท็บใหม่และเลย์เอาต์หน้าแรก คุณสามารถสลับระหว่างเลย์เอาต์ได้ในช่วงเวลาจำกัด", - "settings.general.row.newInterfaceNotice.title": "ขณะนี้คุณกำลังใช้เลย์เอาต์ใหม่", - "settings.general.row.newInterfaceNotice.description": "เลย์เอาต์ก่อนหน้าไม่พร้อมใช้งานอีกต่อไป", - "settings.general.row.newInterfaceNotice.dismiss": "ปิด", - "settings.general.row.pinchZoom.title": "บีบนิ้วเพื่อซูม", - "settings.general.row.pinchZoom.description": "อนุญาตให้ใช้ท่าบีบนิ้วบนแทร็คแพดและ Ctrl-scroll เพื่อซูม", "settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ", "settings.general.row.wayland.description": "ปิดใช้งาน X11 fallback บน Wayland ต้องรีสตาร์ท", "settings.general.row.wayland.tooltip": "บน Linux ที่มีจอภาพรีเฟรชเรตแบบผสม Wayland แบบเนทีฟอาจเสถียรกว่า", @@ -826,8 +668,6 @@ export const dict = { "settings.updates.row.check.description": "ตรวจสอบการอัปเดตด้วยตนเองและติดตั้งหากมี", "settings.updates.action.checkNow": "ตรวจสอบทันที", "settings.updates.action.checking": "กำลังตรวจสอบ...", - "settings.updates.action.downloading": "กำลังดาวน์โหลด...", - "settings.updates.action.installing": "กำลังติดตั้ง...", "settings.updates.toast.latest.title": "คุณเป็นเวอร์ชันล่าสุดแล้ว", "settings.updates.toast.latest.description": "คุณกำลังใช้งาน OpenCode เวอร์ชันล่าสุด", @@ -1017,10 +857,10 @@ export const dict = { "dialog.server.add.usernamePlaceholder": "ชื่อผู้ใช้", "dialog.server.add.passwordPlaceholder": "รหัสผ่าน", "server.row.noUsername": "ไม่มีชื่อผู้ใช้", - "session.review.noVcs.createGit.title": "สร้าง Git รีโพซิทอรี", + "session.review.noVcs.createGit.title": "สร้าง Git repository", "session.review.noVcs.createGit.description": "ติดตาม ตรวจสอบ และเลิกทำสิ่งเปลี่ยนแปลงในโปรเจกต์นี้", - "session.review.noVcs.createGit.actionLoading": "กำลังสร้าง Git รีโพซิทอรี...", - "session.review.noVcs.createGit.action": "สร้าง Git รีโพซิทอรี", + "session.review.noVcs.createGit.actionLoading": "กำลังสร้าง Git repository...", + "session.review.noVcs.createGit.action": "สร้าง Git repository", "session.todo.progress": "เสร็จสิ้น {{done}} จาก {{total}} รายการ", "session.question.progress": "{{current}} จาก {{total}} คำถาม", "session.header.open.finder": "Finder", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 7c123c69aa..9053173599 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -27,9 +27,6 @@ export const dict = { "command.sidebar.toggle": "Kenar çubuğunu aç/kapat", "command.project.open": "Proje aç", - "command.project.previous": "Önceki proje", - "command.project.next": "Sonraki proje", - "command.project.index": "{{index}} numaralı projeye geç", "command.provider.connect": "Sağlayıcı bağla", "command.server.switch": "Sunucu değiştir", "command.settings.open": "Ayarları aç", @@ -52,7 +49,6 @@ export const dict = { "command.session.new": "Yeni oturum", "command.file.open": "Dosya aç", "command.tab.close": "Sekmeyi kapat", - "command.tab.reopenClosed": "Kapatılan sekmeyi yeniden aç", "command.context.addSelection": "Seçimi bağlama ekle", "command.context.addSelection.description": "Mevcut dosyadan seçili satırları ekle", "command.input.focus": "Girişi odakla", @@ -97,7 +93,6 @@ export const dict = { "command.session.unshare.description": "Bu oturumun paylaşımını durdur", "palette.search.placeholder": "Dosya, komut ve oturum ara", - "palette.search.placeholder.home": "Komut ve oturum ara", "palette.empty": "Sonuç bulunamadı", "palette.group.commands": "Komutlar", "palette.group.files": "Dosyalar", @@ -137,7 +132,7 @@ export const dict = { "provider.connect.status.waiting": "Yetkilendirme bekleniyor...", "provider.connect.status.failed": "Yetkilendirme başarısız: {{error}}", "provider.connect.apiKey.description": - "{{provider}} hesabınızı bağlamak ve OpenCode'da {{provider}} modellerini kullanmak için API anahtarınızı girin.", + "{{provider}} hesabınızı bağlamak ve OpenCode'da {{provider}} modellerini kullanmak için {{provider}} API anahtarınızı girin.", "provider.connect.apiKey.label": "{{provider}} API anahtarı", "provider.connect.apiKey.placeholder": "API anahtarı", "provider.connect.apiKey.required": "API anahtarı gerekli", @@ -148,10 +143,10 @@ export const dict = { "provider.connect.opencodeZen.visit.prefix": "", "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", "provider.connect.opencodeZen.visit.suffix": " adresini ziyaret ederek API anahtarınızı alın.", - "provider.connect.oauth.code.visit.prefix": "Yetkilendirme kodunuzu almak için ", + "provider.connect.oauth.code.visit.prefix": + "Hesabınızı bağlamak ve OpenCode'da {{provider}} modellerini kullanmak için ", "provider.connect.oauth.code.visit.link": "bu bağlantıya", - "provider.connect.oauth.code.visit.suffix": - " gidin; ardından hesabınızı bağlayarak OpenCode'da {{provider}} modellerini kullanın.", + "provider.connect.oauth.code.visit.suffix": " tıklayarak yetkilendirme kodunuzu alın.", "provider.connect.oauth.code.label": "{{method}} yetkilendirme kodu", "provider.connect.oauth.code.placeholder": "Yetkilendirme kodu", "provider.connect.oauth.code.required": "Yetkilendirme kodu gerekli", @@ -218,11 +213,6 @@ export const dict = { "model.input.video": "video", "model.input.pdf": "pdf", "model.tooltip.allows": "Kabul eder: {{inputs}}", - "model.tooltip.context.label": "Bağlam", - "model.tooltip.inputs": "Girdiler", - "model.tooltip.model": "Model", - "model.tooltip.provider": "Sağlayıcı", - "model.tooltip.reasoning": "Akıl yürütme", "model.tooltip.reasoning.allowed": "Akıl yürütme destekler", "model.tooltip.reasoning.none": "Akıl yürütme yok", "model.tooltip.context": "Bağlam limiti {{limit}}", @@ -233,7 +223,6 @@ export const dict = { "common.loading": "Yükleniyor", "common.loading.ellipsis": "...", "common.cancel": "İptal", - "common.clear": "Temizle", "common.connect": "Bağlan", "common.disconnect": "Bağlantı Kes", "common.continue": "Gönder", @@ -251,8 +240,6 @@ export const dict = { "prompt.mode.shell": "Kabuk", "prompt.mode.normal": "Komut", "prompt.mode.shell.exit": "çıkmak için esc", - "session.child.promptDisabled": "Alt ajan oturumlarına istem gönderilemez.", - "session.child.backToParent": "Ana oturuma dön.", "prompt.example.1": "Kod tabanındaki bir TODO'yu düzelt", "prompt.example.2": "Bu projenin teknoloji yığını nedir?", @@ -292,17 +279,11 @@ export const dict = { "prompt.context.removeActiveFile": "Aktif dosyayı bağlamdan çıkar", "prompt.context.removeFile": "Dosyayı bağlamdan çıkar", "prompt.action.attachFile": "Dosya ekle", - "prompt.menu.addImagesAndFiles": "Dosya ve daha fazlasını ekle", - "prompt.menu.imagesAndFiles": "Görseller ve dosyalar", - "prompt.menu.commands": "Komutlar", - "prompt.menu.context": "Bağlam", - "prompt.menu.shellCommand": "Kabuk komutu", "prompt.attachment.remove": "Eki kaldır", "prompt.action.send": "Gönder", "prompt.action.stop": "Durdur", "prompt.toast.pasteUnsupported.title": "Desteklenmeyen ek", - "prompt.toast.attachmentDuplicate.title": "Bu dosya zaten yüklendi", "prompt.toast.pasteUnsupported.description": "Buraya yalnızca resimler, PDF'ler veya metin dosyaları eklenebilir.", "prompt.toast.modelAgentRequired.title": "Bir ajan ve model seçin", "prompt.toast.modelAgentRequired.description": "Komut göndermeden önce bir ajan ve model seçin.", @@ -365,75 +346,6 @@ export const dict = { "dialog.server.menu.delete": "Sil", "dialog.server.current": "Mevcut Sunucu", "dialog.server.status.default": "Varsayılan", - "wsl.server.add": "WSL sunucusu ekle", - "wsl.server.addShort": "WSL ekle", - "wsl.server.label": "WSL", - "wsl.server.menu.label": "WSL sunucusu", - "wsl.server.retryStart": "Başlatmayı yeniden dene", - "wsl.server.updating": "Güncelleniyor...", - "wsl.onboarding.step.distro": "Dağıtım seç", - "wsl.onboarding.step.opencode": "OpenCode", - "wsl.onboarding.checkingRuntime": "WSL kontrol ediliyor...", - "wsl.onboarding.restartRequired": "WSL kurulumunu tamamlamak için Windows'un yeniden başlatılması gerekiyor.", - "wsl.onboarding.ready": "WSL hazır.", - "wsl.onboarding.required": "Devam etmek için WSL gerekli.", - "wsl.onboarding.checkingDistros": "Dağıtımlar kontrol ediliyor...", - "wsl.onboarding.installingDistro": "{{distro}} kuruluyor...", - "wsl.onboarding.checkingDistro": "{{distro}} kontrol ediliyor...", - "wsl.onboarding.listingDistros": "Dağıtımlar listeleniyor...", - "wsl.onboarding.distroReady": "{{distro}} hazır.", - "wsl.onboarding.distroNotInstalled": "{{distro}} henüz kurulu değil.", - "wsl.onboarding.openDistroOnce": "Kurulumu tamamlamak için {{distro}} dağıtımını bir kez açın.", - "wsl.onboarding.finishingDistro": "{{distro}} kurulumu tamamlanıyor.", - "wsl.onboarding.pickDistro": "Bir dağıtım seçin veya aşağıdan bir dağıtım kurun.", - "wsl.onboarding.checkingOpencode": "OpenCode kontrol ediliyor...", - "wsl.onboarding.checkingOpencodeIn": "{{distro}} içinde OpenCode kontrol ediliyor...", - "wsl.onboarding.updatingOpencode": "OpenCode güncelleniyor...", - "wsl.onboarding.updatingOpencodeIn": "{{distro}} içinde OpenCode güncelleniyor...", - "wsl.onboarding.updateOpencodeIn": "{{distro}} içindeki OpenCode'u güncelleyin.", - "wsl.onboarding.updateOpencode": "OpenCode'u güncelle", - "wsl.onboarding.opencodeReadyIn": "{{distro}} içinde OpenCode hazır.", - "wsl.onboarding.opencodeReady": "OpenCode hazır.", - "wsl.onboarding.installOpencodeIn": "OpenCode'u {{distro}} içine kurun.", - "wsl.onboarding.installOpencode": "OpenCode'u kur", - "wsl.onboarding.chooseDistroFirst": "Önce bir dağıtım seçin.", - "wsl.onboarding.loadFailed": "WSL durumu yüklenemedi.", - "wsl.onboarding.loading": "Yükleniyor...", - "wsl.onboarding.installedDistros": "Kurulu dağıtımlar", - "wsl.onboarding.checkAgain": "Tekrar kontrol et", - "wsl.onboarding.distroStatus.ready": "Hazır", - "wsl.onboarding.distroStatus.checking": "Kontrol ediliyor...", - "wsl.onboarding.distroStatus.opencodeMissing": "OpenCode kurulu değil", - "wsl.onboarding.distroStatus.missingTools": "bash ve curl eksik", - "wsl.onboarding.distroStatus.unsupported": "Desteklenmiyor · WSL 2 kullanın", - "wsl.onboarding.needAnotherDistro": "Başka bir dağıtım mı gerekiyor?", - "wsl.onboarding.needAnotherDistroHint": "WSL kataloğundan bir Linux dağıtımı kurun", - "wsl.onboarding.wslNotInstalled.title": "WSL kurulu değil", - "wsl.onboarding.wslNotInstalled.description": - "OpenCode'un WSL sunucusu ekleyebilmesi için önce WSL (Linux için Windows Alt Sistemi) kurulmalıdır", - "wsl.onboarding.wslUnavailable.title": "WSL kullanılamıyor", - "wsl.onboarding.wslUnavailable.description": "OpenCode bu makinede WSL'yi doğrulayamadı.", - "wsl.onboarding.installWsl": "WSL'yi kur", - "wsl.onboarding.windowsRestartRequired": - "WSL kurulumunu tamamlamak için Windows'u yeniden başlatın, ardından OpenCode'u tekrar açın.", - "wsl.onboarding.next": "İleri", - "wsl.onboarding.refresh": "Yenile", - "wsl.onboarding.allDistrosAdded": "Kurulu tüm dağıtımlar zaten eklenmiş.", - "wsl.onboarding.noDistros": "Henüz dağıtım algılanmadı.", - "wsl.onboarding.install": "Kur", - "wsl.onboarding.installing": "Kuruluyor...", - "wsl.onboarding.installDistro": "Dağıtım kur", - "wsl.onboarding.searchDistros": "Dağıtım ara", - "wsl.onboarding.wsl2Required": "WSL 2 gerekli.", - "wsl.onboarding.toolsRequired": "Bu dağıtım için bash ve curl gerekli.", - "wsl.onboarding.openTerminal": "Terminali aç", - "wsl.onboarding.path": "Yol: {{path}}", - "wsl.onboarding.notFound": "bulunamadı", - "wsl.onboarding.version": "Sürüm: {{version}}", - "wsl.onboarding.unknown": "bilinmiyor", - "wsl.onboarding.desktopVersion": "masaüstü {{version}}", - "wsl.onboarding.versionMismatch": "Kurulu sürüm masaüstü uygulamasının sürümüyle eşleşmiyor.", - "wsl.onboarding.adding": "Ekleniyor...", "dialog.project.edit.title": "Projeyi düzenle", "dialog.project.edit.name": "Ad", @@ -496,7 +408,6 @@ export const dict = { "language.no": "Norsk", "language.br": "Português (Brasil)", "language.bs": "Bosanski", - "language.uk": "Українська", "language.th": "ไทย", "language.tr": "Türkçe", @@ -545,12 +456,10 @@ export const dict = { "error.page.title": "Bir şeyler yanlış gitti", "error.page.description": "Uygulama yüklenirken bir hata oluştu.", - "error.page.description.localServerStartup": "Yerel sunucu başlatılırken bir hata oluştu.", "error.page.details.label": "Hata Detayları", "error.page.action.restart": "Yeniden Başlat", "error.page.action.report": "Hatayı Bildir", "error.page.action.reported": "Hata Bildirildi", - "error.page.action.exportLogs": "Günlükleri Dışa Aktar", "error.page.action.checking": "Kontrol ediliyor...", "error.page.action.checkUpdates": "Güncellemeleri kontrol et", "error.page.action.updateTo": "{{version}} sürümüne güncelle", @@ -600,32 +509,11 @@ export const dict = { "home.recentProjects": "Son projeler", "home.empty.title": "Son proje yok", "home.empty.description": "Yerel bir proje açarak başlayın", - "home.title": "Ana Sayfa", - "home.projects": "Projeler", - "home.project.add": "Proje ekle", - "home.recentlyClosed": "Yakın zamanda kapatılanlar", - "home.server.collapse": "Sunucu projelerini daralt", - "home.server.expand": "Sunucu projelerini genişlet", - "home.sessions.search.placeholder": "Oturum ara", - "home.sessions.search.placeholder.scoped": "{{scope}} kapsamında oturum ara", - "home.sessions.search.sessions": "Oturumlar", - "home.sessions.search.noResults": "{{query}} için oturum bulunamadı", - "home.sessions.empty": "Henüz burada bir şey yok", - "home.sessions.empty.description": "Başlamak için bir oturum oluşturun", - "home.sessions.group.today": "Bugün", - "home.sessions.group.yesterday": "Dün", - "home.sessions.group.older": "Daha eski", - "home.providerTip": "Claude, GPT, Gemini ve diğer modelleri kullanmak için 75'ten fazla sağlayıcıya bağlanın", "session.tab.session": "Oturum", "session.tab.review": "İnceleme", "session.tab.context": "Bağlam", - "session.tab.unknown": "Bilinmeyen Oturum", "session.panel.reviewAndFiles": "İnceleme ve dosyalar", - "session.error.notFound": "Bu oturum bulunamıyor", - "session.error.notFound.description": "Bu sekme, artık bu sunucuda bulunmayan bir oturuma yönlendiriyor.", - "session.error.notFound.closeTab": "Sekmeyi Kapat", - "session.error.serverConnection": "Bu sunucuya bağlanılamıyor", "session.review.filesChanged": "{{count}} Dosya Değişti", "session.review.change.one": "Değişiklik", "session.review.change.other": "Değişiklik", @@ -635,8 +523,6 @@ export const dict = { "session.review.noSnapshot": "Yapılandırmada anlık görüntü takibi devre dışı bırakıldı, bu nedenle oturum değişiklikleri kullanılamıyor", "session.review.noChanges": "Değişiklik yok", - "session.review.noUncommittedChanges": "Henüz commit edilmemiş değişiklik yok", - "session.review.noBranchChanges": "Henüz dal değişikliği yok", "session.files.selectToOpen": "Açmak için bir dosya seçin", "session.files.all": "Tüm dosyalar", @@ -653,10 +539,6 @@ export const dict = { "session.todo.title": "Görevler", "session.todo.collapse": "Daralt", "session.todo.expand": "Genişlet", - "session.question.minimize": "Soruyu küçült", - "session.question.restore": "Soruyu geri yükle", - "session.question.pending.one": "{{count}} bekleyen soru", - "session.question.pending.other": "{{count}} bekleyen soru", "session.followupDock.summary.one": "{{count}} sıradaki mesaj", "session.followupDock.summary.other": "{{count}} sıradaki mesaj", "session.followupDock.sendNow": "Şimdi gönder", @@ -670,17 +552,9 @@ export const dict = { "session.revertDock.restore": "Mesajı geri yükle", "session.new.title": "İstediğini yap", - "session.new.project.new": "Yeni proje", - "session.new.project.search": "Proje ara", - "session.new.project.add": "Proje ekle", "session.new.worktree.main": "Ana dal", "session.new.worktree.mainWithBranch": "Ana dal ({{branch}})", "session.new.worktree.create": "Yeni çalışma ağacı oluştur", - "session.new.workspace.runIn": "Oturumu şurada çalıştır", - "session.new.workspace.triggerLocal": "Yerel", - "session.new.workspace.local": "Yerel depo", - "session.new.workspace.existing": "Çalışma alanı…", - "session.new.git.none": "Git yok", "session.new.lastModified": "Son değişiklik", "session.header.search.placeholder": "{{project}} ara", @@ -750,8 +624,6 @@ export const dict = { "sidebar.project.recentSessions": "Son oturumlar", "sidebar.project.viewAllSessions": "Tüm oturumları görüntüle", "sidebar.project.clearNotifications": "Bildirimleri temizle", - "sidebar.empty.title": "Açık proje yok", - "sidebar.empty.description": "Başlamak için bir proje açın", "app.name.desktop": "OpenCode Masaüstü", @@ -764,7 +636,6 @@ export const dict = { "settings.desktop.wsl.description": "OpenCode sunucusunu Windows'ta WSL içinde çalıştırın.", "settings.general.section.appearance": "Görünüm", - "settings.general.section.advanced": "Gelişmiş", "settings.general.section.notifications": "Sistem bildirimleri", "settings.general.section.updates": "Güncellemeler", "settings.general.section.sounds": "Ses efektleri", @@ -773,11 +644,6 @@ export const dict = { "settings.general.row.language.title": "Dil", "settings.general.row.language.description": "OpenCode'un görünüm dilini değiştirin", - "settings.general.row.shell.title": "Terminal Kabuğu", - "settings.general.row.shell.description": - "Terminalinizde kullanılacak kabuğu seçin. Uyumlu kabuklar, ajan araç çağrılarında da kullanılır.", - "settings.general.row.shell.autoDefault": "Otomatik (Varsayılan)", - "settings.general.row.shell.terminalOnly": "yalnızca terminal", "settings.general.row.appearance.title": "Görünüm", "settings.general.row.appearance.description": "OpenCode'un cihazınızdaki görünümünü özelleştirin", "settings.general.row.colorScheme.title": "Renk şeması", @@ -787,8 +653,8 @@ export const dict = { "settings.general.row.theme.description": "OpenCode'un temasını özelleştirin.", "settings.general.row.font.title": "Kod Yazı Tipi", "settings.general.row.font.description": "Kod bloklarında kullanılan yazı tipini özelleştirin", - "settings.general.row.terminalFont.title": "Terminal yazı tipi", - "settings.general.row.terminalFont.description": "Terminalde kullanılan yazı tipini özelleştirin", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "Arayüz Yazı Tipi", "settings.general.row.uiFont.description": "Arayüz genelinde kullanılan yazı tipini özelleştirin", "settings.general.row.followup.title": "Takip davranışı", @@ -796,21 +662,6 @@ export const dict = { "Takip komutlarının hemen yönlendirilmesini mi yoksa sırada beklemesini mi istediğinizi seçin", "settings.general.row.followup.option.queue": "Sıra", "settings.general.row.followup.option.steer": "Yönlendir", - "settings.general.row.showFileTree.title": "Dosya ağacı", - "settings.general.row.showFileTree.description": "Oturumlarda dosya ağacı panelini göster", - "settings.general.row.showNavigation.title": "Gezinme denetimleri", - "settings.general.row.showNavigation.description": "Masaüstü başlık çubuğunda geri ve ileri düğmelerini göster", - "settings.general.row.showSearch.title": "Komut paleti", - "settings.general.row.showSearch.description": "Başlık çubuğunda arama ve komut paleti düğmesini göster", - "settings.general.row.showTerminal.title": "Terminal", - "settings.general.row.showTerminal.description": "Masaüstü başlık çubuğunda terminal düğmesini göster", - "settings.general.row.showStatus.title": "Sunucu durumu", - "settings.general.row.showStatus.description": "Başlık çubuğunda sunucu durumu düğmesini göster", - "settings.general.row.mobileTitlebarBottom.title": "Alt gezinme", - "settings.general.row.mobileTitlebarBottom.description": - "Mobil cihazlarda başlık çubuğunu ve oturum sekmelerini ekranın altına yerleştir", - "settings.general.row.showCustomAgents.title": "Özel ajanlar", - "settings.general.row.showCustomAgents.description": "Yazma alanında ajan seçiciyi göster", "settings.general.row.reasoningSummaries.title": "Akıl yürütme özetlerini göster", "settings.general.row.reasoningSummaries.description": "Zaman çizelgesinde model akıl yürütme özetlerini görüntüle", "settings.general.row.shellToolPartsExpanded.title": "Kabuk araç bileşenlerini genişlet", @@ -819,16 +670,6 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Düzenleme araç bileşenlerini genişlet", "settings.general.row.editToolPartsExpanded.description": "Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster", - "settings.general.row.newInterface.title": "Yeni düzen", - "settings.general.row.newInterface.badge": "Yeni", - "settings.general.row.newInterface.description": - "Yeni sekmeleri ve ana sayfa düzenini kullanın. Sınırlı bir süre boyunca düzenler arasında geçiş yapabilirsiniz.", - "settings.general.row.newInterfaceNotice.title": "Artık yeni düzeni kullanıyorsunuz", - "settings.general.row.newInterfaceNotice.description": "Önceki düzen artık kullanılamıyor", - "settings.general.row.newInterfaceNotice.dismiss": "Kapat", - "settings.general.row.pinchZoom.title": "Sıkıştırarak yakınlaştır", - "settings.general.row.pinchZoom.description": - "İzleme dörtgeninde sıkıştırma ve Ctrl-kaydırma hareketleriyle yakınlaştırmaya izin ver", "settings.general.row.wayland.title": "Yerel Wayland kullan", "settings.general.row.wayland.description": @@ -845,8 +686,6 @@ export const dict = { "settings.updates.row.check.description": "Elle güncelleme kontrolü yap ve varsa yükle", "settings.updates.action.checkNow": "Şimdi kontrol et", "settings.updates.action.checking": "Kontrol ediliyor...", - "settings.updates.action.downloading": "İndiriliyor...", - "settings.updates.action.installing": "Kuruluyor...", "settings.updates.toast.latest.title": "Güncelsiniz", "settings.updates.toast.latest.description": "OpenCode'un en son sürümünü kullanıyorsunuz.", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index d8c9ae8e43..d875766e88 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -48,7 +48,6 @@ export const dict = { "command.session.new": "Нова сесія", "command.file.open": "Відкрити файл", "command.tab.close": "Закрити вкладку", - "command.tab.reopenClosed": "Повторно відкрити закриту вкладку", "command.context.addSelection": "Додати виділення до контексту", "command.context.addSelection.description": "Додати вибрані рядки з поточного файлу", "command.input.focus": "Фокус на полі введення", @@ -93,7 +92,6 @@ export const dict = { "command.session.unshare.description": "Припинити поширення цієї сесії", "palette.search.placeholder": "Пошук файлів, команд і сесій", - "palette.search.placeholder.home": "Пошук команд і сесій", "palette.empty": "Результатів не знайдено", "palette.group.commands": "Команди", "palette.group.files": "Файли", @@ -214,11 +212,6 @@ export const dict = { "model.input.video": "відео", "model.input.pdf": "pdf", "model.tooltip.allows": "Дозволяє: {{inputs}}", - "model.tooltip.context.label": "Контекст", - "model.tooltip.inputs": "Вхідні дані", - "model.tooltip.model": "Модель", - "model.tooltip.provider": "Провайдер", - "model.tooltip.reasoning": "Мислення", "model.tooltip.reasoning.allowed": "Підтримує мислення", "model.tooltip.reasoning.none": "Без мислення", "model.tooltip.context": "Ліміт контексту {{limit}}", @@ -229,7 +222,6 @@ export const dict = { "common.loading": "Завантаження", "common.loading.ellipsis": "...", "common.cancel": "Скасувати", - "common.clear": "Очистити", "common.open": "Відкрити", "common.connect": "Підключити", "common.disconnect": "Відключити", @@ -289,17 +281,11 @@ export const dict = { "prompt.context.removeActiveFile": "Видалити активний файл з контексту", "prompt.context.removeFile": "Видалити файл з контексту", "prompt.action.attachFile": "Додати файли", - "prompt.menu.addImagesAndFiles": "Додати файли та інше", - "prompt.menu.imagesAndFiles": "Зображення та файли", - "prompt.menu.commands": "Команди", - "prompt.menu.context": "Контекст", - "prompt.menu.shellCommand": "Команда оболонки", "prompt.attachment.remove": "Видалити вкладення", "prompt.action.send": "Надіслати", "prompt.action.stop": "Зупинити", "prompt.toast.pasteUnsupported.title": "Непідтримуване вкладення", - "prompt.toast.attachmentDuplicate.title": "Цей файл уже завантажено", "prompt.toast.pasteUnsupported.description": "Сюди можна прикріплювати лише зображення, PDF або текстові файли.", "prompt.toast.modelAgentRequired.title": "Виберіть агента та модель", "prompt.toast.modelAgentRequired.description": "Виберіть агента та модель перед надсиланням запиту.", @@ -368,75 +354,6 @@ export const dict = { "dialog.server.menu.delete": "Видалити", "dialog.server.current": "Поточний сервер", "dialog.server.status.default": "За замовчуванням", - "wsl.server.add": "Додати сервер WSL", - "wsl.server.addShort": "Додати WSL", - "wsl.server.label": "WSL", - "wsl.server.menu.label": "Сервер WSL", - "wsl.server.retryStart": "Повторити запуск", - "wsl.server.updating": "Оновлення...", - "wsl.onboarding.step.distro": "Вибрати дистрибутив", - "wsl.onboarding.step.opencode": "OpenCode", - "wsl.onboarding.checkingRuntime": "Перевірка WSL...", - "wsl.onboarding.restartRequired": "Щоб завершити встановлення WSL, потрібно перезапустити Windows.", - "wsl.onboarding.ready": "WSL готова до роботи.", - "wsl.onboarding.required": "Для продовження потрібна WSL.", - "wsl.onboarding.checkingDistros": "Перевірка дистрибутивів...", - "wsl.onboarding.installingDistro": "Встановлення {{distro}}...", - "wsl.onboarding.checkingDistro": "Перевірка {{distro}}...", - "wsl.onboarding.listingDistros": "Отримання списку дистрибутивів...", - "wsl.onboarding.distroReady": "{{distro}} готовий до роботи.", - "wsl.onboarding.distroNotInstalled": "{{distro}} ще не встановлено.", - "wsl.onboarding.openDistroOnce": "Відкрийте {{distro}} один раз, щоб завершити налаштування.", - "wsl.onboarding.finishingDistro": "Завершення налаштування {{distro}}.", - "wsl.onboarding.pickDistro": "Виберіть дистрибутив або встановіть один із наведених нижче.", - "wsl.onboarding.checkingOpencode": "Перевірка OpenCode...", - "wsl.onboarding.checkingOpencodeIn": "Перевірка OpenCode у {{distro}}...", - "wsl.onboarding.updatingOpencode": "Оновлення OpenCode...", - "wsl.onboarding.updatingOpencodeIn": "Оновлення OpenCode у {{distro}}...", - "wsl.onboarding.updateOpencodeIn": "Оновіть OpenCode у {{distro}}.", - "wsl.onboarding.updateOpencode": "Оновити OpenCode", - "wsl.onboarding.opencodeReadyIn": "OpenCode готовий до роботи у {{distro}}.", - "wsl.onboarding.opencodeReady": "OpenCode готовий до роботи.", - "wsl.onboarding.installOpencodeIn": "Встановіть OpenCode у {{distro}}.", - "wsl.onboarding.installOpencode": "Встановити OpenCode", - "wsl.onboarding.chooseDistroFirst": "Спочатку виберіть дистрибутив.", - "wsl.onboarding.loadFailed": "Не вдалося завантажити стан WSL.", - "wsl.onboarding.loading": "Завантаження...", - "wsl.onboarding.installedDistros": "Встановлені дистрибутиви", - "wsl.onboarding.checkAgain": "Перевірити ще раз", - "wsl.onboarding.distroStatus.ready": "Готовий", - "wsl.onboarding.distroStatus.checking": "Перевірка...", - "wsl.onboarding.distroStatus.opencodeMissing": "OpenCode не встановлено", - "wsl.onboarding.distroStatus.missingTools": "Немає bash і curl", - "wsl.onboarding.distroStatus.unsupported": "Не підтримується · Використовуйте WSL 2", - "wsl.onboarding.needAnotherDistro": "Потрібен інший дистрибутив?", - "wsl.onboarding.needAnotherDistroHint": "Встановіть дистрибутив Linux із каталогу WSL", - "wsl.onboarding.wslNotInstalled.title": "WSL не встановлено", - "wsl.onboarding.wslNotInstalled.description": - "Для додавання сервера WSL в OpenCode потрібна WSL (Підсистема Windows для Linux)", - "wsl.onboarding.wslUnavailable.title": "WSL недоступна", - "wsl.onboarding.wslUnavailable.description": "OpenCode не вдалося перевірити WSL на цьому комп'ютері.", - "wsl.onboarding.installWsl": "Встановити WSL", - "wsl.onboarding.windowsRestartRequired": - "Перезапустіть Windows, щоб завершити встановлення WSL, а потім знову відкрийте OpenCode.", - "wsl.onboarding.next": "Далі", - "wsl.onboarding.refresh": "Оновити", - "wsl.onboarding.allDistrosAdded": "Усі встановлені дистрибутиви вже додано.", - "wsl.onboarding.noDistros": "Дистрибутивів поки не виявлено.", - "wsl.onboarding.install": "Встановити", - "wsl.onboarding.installing": "Встановлення...", - "wsl.onboarding.installDistro": "Встановити дистрибутив", - "wsl.onboarding.searchDistros": "Пошук дистрибутивів", - "wsl.onboarding.wsl2Required": "Для роботи потрібна WSL 2.", - "wsl.onboarding.toolsRequired": "Для цього дистрибутива потрібні bash і curl.", - "wsl.onboarding.openTerminal": "Відкрити термінал", - "wsl.onboarding.path": "Шлях: {{path}}", - "wsl.onboarding.notFound": "не знайдено", - "wsl.onboarding.version": "Версія: {{version}}", - "wsl.onboarding.unknown": "невідомо", - "wsl.onboarding.desktopVersion": "десктопний застосунок {{version}}", - "wsl.onboarding.versionMismatch": "Встановлена версія не відповідає версії десктопного застосунку.", - "wsl.onboarding.adding": "Додавання...", "server.row.noUsername": "без імені користувача", "dialog.project.edit.title": "Редагувати проєкт", @@ -556,12 +473,10 @@ export const dict = { "error.page.title": "Щось пішло не так", "error.page.description": "Під час завантаження програми сталася помилка.", - "error.page.description.localServerStartup": "Під час запуску локального сервера сталася помилка.", "error.page.details.label": "Деталі помилки", "error.page.action.restart": "Перезапустити", "error.page.action.report": "Повідомити про помилку", "error.page.action.reported": "Помилку повідомлено", - "error.page.action.exportLogs": "Експортувати журнали", "error.page.action.checking": "Перевірка...", "error.page.action.checkUpdates": "Перевірити оновлення", "error.page.action.updateTo": "Оновити до {{version}}", @@ -618,33 +533,11 @@ export const dict = { "home.recentProjects": "Нещодавні проєкти", "home.empty.title": "Немає нещодавніх проєктів", "home.empty.description": "Почніть, відкривши локальний проєкт", - "home.title": "Головна", - "home.projects": "Проєкти", - "home.project.add": "Додати проєкт", - "home.recentlyClosed": "Нещодавно закриті", - "home.server.collapse": "Згорнути проєкти сервера", - "home.server.expand": "Розгорнути проєкти сервера", - "home.sessions.search.placeholder": "Пошук сесій", - "home.sessions.search.placeholder.scoped": "Пошук сесій у {{scope}}", - "home.sessions.search.sessions": "Сесії", - "home.sessions.search.noResults": "Сесій за запитом {{query}} не знайдено", - "home.sessions.empty": "Тут поки нічого немає", - "home.sessions.empty.description": "Створіть сесію, щоб почати", - "home.sessions.group.today": "Сьогодні", - "home.sessions.group.yesterday": "Учора", - "home.sessions.group.older": "Раніше", - "home.providerTip": - "Підключіться до понад 75 провайдерів, щоб використовувати інші моделі, зокрема Claude, GPT, Gemini та інші", "session.tab.session": "Сесія", "session.tab.review": "Огляд", "session.tab.context": "Контекст", - "session.tab.unknown": "Невідома сесія", "session.panel.reviewAndFiles": "Огляд і файли", - "session.error.notFound": "Цю сесію не знайдено", - "session.error.notFound.description": "Ця вкладка посилається на сесію, якої більше немає на цьому сервері.", - "session.error.notFound.closeTab": "Закрити вкладку", - "session.error.serverConnection": "Не вдається підключитися до цього сервера", "session.review.filesChanged": "Змінено файлів: {{count}}", "session.review.change.one": "Зміна", "session.review.change.other": "Зміни", @@ -677,10 +570,6 @@ export const dict = { "session.todo.expand": "Розгорнути", "session.todo.progress": "Виконано {{done}} з {{total}} завдань", "session.question.progress": "{{current}} з {{total}} запитань", - "session.question.minimize": "Згорнути запитання", - "session.question.restore": "Відновити запитання", - "session.question.pending.one": "{{count}} запитання очікує відповіді", - "session.question.pending.other": "Запитання, що очікують відповіді: {{count}}", "session.followupDock.summary.one": "{{count}} повідомлення в черзі", "session.followupDock.summary.other": "{{count}} повідомлень у черзі", "session.followupDock.sendNow": "Надіслати зараз", @@ -694,17 +583,9 @@ export const dict = { "session.revertDock.restore": "Відновити повідомлення", "session.new.title": "Створити що завгодно", - "session.new.project.new": "Новий проєкт", - "session.new.project.search": "Пошук проєктів", - "session.new.project.add": "Додати проєкт", "session.new.worktree.main": "Основна гілка", "session.new.worktree.mainWithBranch": "Основна гілка ({{branch}})", "session.new.worktree.create": "Створити нове робоче дерево", - "session.new.workspace.runIn": "Запустити сесію в", - "session.new.workspace.triggerLocal": "Локально", - "session.new.workspace.local": "Локальний репозиторій", - "session.new.workspace.existing": "Робоча область…", - "session.new.git.none": "Немає Git", "session.new.lastModified": "Востаннє змінено", "session.header.search.placeholder": "Пошук {{project}}", @@ -892,11 +773,6 @@ export const dict = { "settings.general.row.showTerminal.description": "Показувати кнопку термінала в заголовку робочого столу", "settings.general.row.showStatus.title": "Статус сервера", "settings.general.row.showStatus.description": "Показувати кнопку статусу сервера в заголовку", - "settings.general.row.mobileTitlebarBottom.title": "Нижня навігація", - "settings.general.row.mobileTitlebarBottom.description": - "Розмістити заголовок і вкладки сесії внизу екрана на мобільних пристроях", - "settings.general.row.showCustomAgents.title": "Користувацькі агенти", - "settings.general.row.showCustomAgents.description": "Показувати вибір агента в редакторі запиту", "settings.general.row.reasoningSummaries.title": "Показувати підсумки мислення", "settings.general.row.reasoningSummaries.description": "Відображати підсумки мислення моделі на часовій шкалі", "settings.general.row.shellToolPartsExpanded.title": "Розгортати частини інструменту оболонки", @@ -905,16 +781,6 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Розгортати частини інструменту редагування", "settings.general.row.editToolPartsExpanded.description": "Показувати частини інструментів редагування, запису та патчів розгорнутими за замовчуванням на часовій шкалі", - "settings.general.row.newInterface.title": "Новий макет", - "settings.general.row.newInterface.badge": "Нове", - "settings.general.row.newInterface.description": - "Використовуйте нові вкладки та макет головної сторінки. Протягом обмеженого часу можна перемикатися між макетами.", - "settings.general.row.newInterfaceNotice.title": "Тепер ви використовуєте новий макет", - "settings.general.row.newInterfaceNotice.description": "Попередній макет більше недоступний", - "settings.general.row.newInterfaceNotice.dismiss": "Відхилити", - "settings.general.row.pinchZoom.title": "Масштабування щипком", - "settings.general.row.pinchZoom.description": - "Дозволити масштабування жестом щипка на трекпаді та прокручуванням із Ctrl", "settings.general.row.wayland.title": "Використовувати нативний Wayland", "settings.general.row.wayland.description": "Вимкнути резервний X11 на Wayland. Потребує перезапуску.", @@ -930,8 +796,6 @@ export const dict = { "settings.updates.row.check.description": "Вручну перевірити наявність оновлень і встановити, якщо доступні", "settings.updates.action.checkNow": "Перевірити зараз", "settings.updates.action.checking": "Перевірка...", - "settings.updates.action.downloading": "Завантаження...", - "settings.updates.action.installing": "Встановлення...", "settings.updates.toast.latest.title": "У вас актуальна версія", "settings.updates.toast.latest.description": "Ви використовуєте останню версію OpenCode.", "sound.option.none": "Немає", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 724bcc1175..15d873ee04 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -28,9 +28,6 @@ export const dict = { "command.sidebar.toggle": "切换侧边栏", "command.project.open": "打开项目", - "command.project.previous": "上一个项目", - "command.project.next": "下一个项目", - "command.project.index": "切换到项目 {{index}}", "command.provider.connect": "连接提供商", @@ -59,7 +56,6 @@ export const dict = { "command.file.open": "打开文件", "command.tab.close": "关闭标签页", - "command.tab.reopenClosed": "重新打开已关闭的标签页", "command.context.addSelection": "将所选内容添加到上下文", "command.context.addSelection.description": "添加当前文件中选中的行", @@ -120,7 +116,6 @@ export const dict = { "command.session.unshare.description": "停止分享此会话", "palette.search.placeholder": "搜索文件、命令和会话", - "palette.search.placeholder.home": "搜索命令和会话", "palette.empty": "未找到结果", "palette.group.commands": "命令", "palette.group.files": "文件", @@ -235,11 +230,6 @@ export const dict = { "model.input.video": "视频", "model.input.pdf": "pdf", "model.tooltip.allows": "支持:{{inputs}}", - "model.tooltip.context.label": "上下文", - "model.tooltip.inputs": "输入", - "model.tooltip.model": "模型", - "model.tooltip.provider": "提供商", - "model.tooltip.reasoning": "推理", "model.tooltip.reasoning.allowed": "支持推理", "model.tooltip.reasoning.none": "不支持推理", "model.tooltip.context": "上下文上限 {{limit}}", @@ -250,7 +240,6 @@ export const dict = { "common.loading": "加载中", "common.loading.ellipsis": "...", "common.cancel": "取消", - "common.clear": "清除", "common.connect": "连接", "common.disconnect": "断开连接", "common.continue": "提交", @@ -268,8 +257,6 @@ export const dict = { "prompt.mode.shell": "Shell", "prompt.mode.normal": "Prompt", "prompt.mode.shell.exit": "按 esc 退出", - "session.child.promptDisabled": "无法向子智能体会话发送提示词。", - "session.child.backToParent": "返回主会话。", "prompt.example.1": "修复代码库中的一个 TODO", "prompt.example.2": "这个项目的技术栈是什么?", "prompt.example.3": "修复失败的测试", @@ -307,16 +294,10 @@ export const dict = { "prompt.context.removeActiveFile": "从上下文移除活动文件", "prompt.context.removeFile": "从上下文移除文件", "prompt.action.attachFile": "附加文件", - "prompt.menu.addImagesAndFiles": "添加文件及更多内容", - "prompt.menu.imagesAndFiles": "图片和文件", - "prompt.menu.commands": "命令", - "prompt.menu.context": "上下文", - "prompt.menu.shellCommand": "shell 命令", "prompt.attachment.remove": "移除附件", "prompt.action.send": "发送", "prompt.action.stop": "停止", "prompt.toast.pasteUnsupported.title": "不支持的附件", - "prompt.toast.attachmentDuplicate.title": "此文件已上传", "prompt.toast.pasteUnsupported.description": "此处仅能附加图片、PDF 或文本文件。", "prompt.toast.modelAgentRequired.title": "请选择智能体和模型", "prompt.toast.modelAgentRequired.description": "发送提示前请先选择智能体和模型。", @@ -378,73 +359,6 @@ export const dict = { "dialog.server.menu.delete": "删除", "dialog.server.current": "当前服务器", "dialog.server.status.default": "默认", - "wsl.server.add": "添加 WSL 服务器", - "wsl.server.addShort": "添加 WSL", - "wsl.server.label": "WSL", - "wsl.server.menu.label": "WSL 服务器", - "wsl.server.retryStart": "重试启动", - "wsl.server.updating": "正在更新...", - "wsl.onboarding.step.distro": "选择发行版", - "wsl.onboarding.step.opencode": "OpenCode", - "wsl.onboarding.checkingRuntime": "正在检查 WSL...", - "wsl.onboarding.restartRequired": "Windows 需要重启才能完成 WSL 安装。", - "wsl.onboarding.ready": "WSL 已就绪。", - "wsl.onboarding.required": "需要 WSL 才能继续。", - "wsl.onboarding.checkingDistros": "正在检查发行版...", - "wsl.onboarding.installingDistro": "正在安装 {{distro}}...", - "wsl.onboarding.checkingDistro": "正在检查 {{distro}}...", - "wsl.onboarding.listingDistros": "正在列出发行版...", - "wsl.onboarding.distroReady": "{{distro}} 已就绪。", - "wsl.onboarding.distroNotInstalled": "{{distro}} 尚未安装。", - "wsl.onboarding.openDistroOnce": "打开一次 {{distro}} 以完成设置。", - "wsl.onboarding.finishingDistro": "正在完成 {{distro}} 的设置。", - "wsl.onboarding.pickDistro": "选择一个发行版,或在下方安装。", - "wsl.onboarding.checkingOpencode": "正在检查 OpenCode...", - "wsl.onboarding.checkingOpencodeIn": "正在检查 {{distro}} 中的 OpenCode...", - "wsl.onboarding.updatingOpencode": "正在更新 OpenCode...", - "wsl.onboarding.updatingOpencodeIn": "正在更新 {{distro}} 中的 OpenCode...", - "wsl.onboarding.updateOpencodeIn": "更新 {{distro}} 中的 OpenCode。", - "wsl.onboarding.updateOpencode": "更新 OpenCode", - "wsl.onboarding.opencodeReadyIn": "{{distro}} 中的 OpenCode 已就绪。", - "wsl.onboarding.opencodeReady": "OpenCode 已就绪。", - "wsl.onboarding.installOpencodeIn": "在 {{distro}} 中安装 OpenCode。", - "wsl.onboarding.installOpencode": "安装 OpenCode", - "wsl.onboarding.chooseDistroFirst": "请先选择发行版。", - "wsl.onboarding.loadFailed": "加载 WSL 状态失败。", - "wsl.onboarding.loading": "加载中...", - "wsl.onboarding.installedDistros": "已安装的发行版", - "wsl.onboarding.checkAgain": "重新检查", - "wsl.onboarding.distroStatus.ready": "已就绪", - "wsl.onboarding.distroStatus.checking": "检查中...", - "wsl.onboarding.distroStatus.opencodeMissing": "未安装 OpenCode", - "wsl.onboarding.distroStatus.missingTools": "缺少 bash、curl", - "wsl.onboarding.distroStatus.unsupported": "不支持 · 请使用 WSL 2", - "wsl.onboarding.needAnotherDistro": "需要其他发行版?", - "wsl.onboarding.needAnotherDistroHint": "从 WSL 目录安装 Linux 发行版", - "wsl.onboarding.wslNotInstalled.title": "未安装 WSL", - "wsl.onboarding.wslNotInstalled.description": "OpenCode 需要 WSL(Windows Subsystem for Linux)才能添加 WSL 服务器", - "wsl.onboarding.wslUnavailable.title": "WSL 不可用", - "wsl.onboarding.wslUnavailable.description": "OpenCode 无法验证此设备上的 WSL。", - "wsl.onboarding.installWsl": "安装 WSL", - "wsl.onboarding.windowsRestartRequired": "重启 Windows 以完成 WSL 安装,然后重新打开 OpenCode。", - "wsl.onboarding.next": "下一步", - "wsl.onboarding.refresh": "刷新", - "wsl.onboarding.allDistrosAdded": "所有已安装的发行版均已添加。", - "wsl.onboarding.noDistros": "尚未检测到发行版。", - "wsl.onboarding.install": "安装", - "wsl.onboarding.installing": "正在安装...", - "wsl.onboarding.installDistro": "安装发行版", - "wsl.onboarding.searchDistros": "搜索发行版", - "wsl.onboarding.wsl2Required": "需要 WSL 2。", - "wsl.onboarding.toolsRequired": "此发行版需要 bash 和 curl。", - "wsl.onboarding.openTerminal": "打开终端", - "wsl.onboarding.path": "路径:{{path}}", - "wsl.onboarding.notFound": "未找到", - "wsl.onboarding.version": "版本:{{version}}", - "wsl.onboarding.unknown": "未知", - "wsl.onboarding.desktopVersion": "桌面版 {{version}}", - "wsl.onboarding.versionMismatch": "已安装版本与桌面应用版本不一致。", - "wsl.onboarding.adding": "正在添加...", "dialog.project.edit.title": "编辑项目", "dialog.project.edit.name": "名称", @@ -504,7 +418,6 @@ export const dict = { "language.no": "Norsk", "language.br": "Português (Brasil)", "language.bs": "Bosanski", - "language.uk": "Українська", "language.th": "ไทย", "language.tr": "Türkçe", @@ -543,12 +456,10 @@ export const dict = { "error.page.title": "出了点问题", "error.page.description": "加载应用程序时发生错误。", - "error.page.description.localServerStartup": "启动本地服务器时发生错误。", "error.page.details.label": "错误详情", "error.page.action.restart": "重启", "error.page.action.report": "上报错误", "error.page.action.reported": "错误已上报", - "error.page.action.exportLogs": "导出日志", "error.page.action.checking": "检查中...", "error.page.action.checkUpdates": "检查更新", "error.page.action.updateTo": "更新到 {{version}}", @@ -595,9 +506,6 @@ export const dict = { "home.title": "主页", "home.projects": "项目", "home.project.add": "添加项目", - "home.recentlyClosed": "最近关闭", - "home.server.collapse": "折叠服务器项目", - "home.server.expand": "展开服务器项目", "home.sessions.search.placeholder": "搜索会话", "home.sessions.search.placeholder.scoped": "在 {{scope}} 中搜索会话", "home.sessions.search.sessions": "会话", @@ -607,17 +515,11 @@ export const dict = { "home.sessions.group.today": "今天", "home.sessions.group.yesterday": "昨天", "home.sessions.group.older": "更早", - "home.providerTip": "连接 75 个以上的提供商,使用 Claude、GPT、Gemini 等其他模型", "session.tab.session": "会话", "session.tab.review": "审查", "session.tab.context": "上下文", - "session.tab.unknown": "未知会话", "session.panel.reviewAndFiles": "审查和文件", - "session.error.notFound": "找不到此会话", - "session.error.notFound.description": "此标签页指向的会话已不存在于此服务器上。", - "session.error.notFound.closeTab": "关闭标签页", - "session.error.serverConnection": "无法连接到此服务器", "session.review.filesChanged": "{{count}} 个文件变更", "session.review.change.one": "更改", "session.review.change.other": "更改", @@ -626,8 +528,6 @@ export const dict = { "session.review.noVcs": "未检测到 Git 版本控制系统,无法显示更改", "session.review.noSnapshot": "配置中已禁用快照跟踪,因此会话更改不可用", "session.review.noChanges": "无更改", - "session.review.noUncommittedChanges": "尚无未提交的更改", - "session.review.noBranchChanges": "尚无分支更改", "session.files.selectToOpen": "选择要打开的文件", "session.files.all": "所有文件", "session.files.empty": "无文件", @@ -641,10 +541,6 @@ export const dict = { "session.todo.title": "待办事项", "session.todo.collapse": "折叠", "session.todo.expand": "展开", - "session.question.minimize": "最小化问题", - "session.question.restore": "恢复问题", - "session.question.pending.one": "{{count}} 个待处理问题", - "session.question.pending.other": "{{count}} 个待处理问题", "session.followupDock.summary.one": "{{count}} 条排队消息", "session.followupDock.summary.other": "{{count}} 条排队消息", "session.followupDock.sendNow": "立即发送", @@ -657,17 +553,9 @@ export const dict = { "session.revertDock.expand": "展开已回滚消息", "session.revertDock.restore": "恢复消息", "session.new.title": "构建任何东西", - "session.new.project.new": "新建项目", - "session.new.project.search": "搜索项目", - "session.new.project.add": "添加项目", "session.new.worktree.main": "主分支", "session.new.worktree.mainWithBranch": "主分支({{branch}})", "session.new.worktree.create": "创建新的 worktree", - "session.new.workspace.runIn": "运行会话的位置", - "session.new.workspace.triggerLocal": "本地", - "session.new.workspace.local": "本地仓库", - "session.new.workspace.existing": "工作区…", - "session.new.git.none": "无 Git", "session.new.lastModified": "最后修改", "session.header.search.placeholder": "搜索 {{project}}", "session.header.searchFiles": "搜索文件", @@ -735,8 +623,6 @@ export const dict = { "sidebar.project.recentSessions": "最近会话", "sidebar.project.viewAllSessions": "查看全部会话", "sidebar.project.clearNotifications": "清除通知", - "sidebar.empty.title": "没有打开的项目", - "sidebar.empty.description": "打开一个项目以开始使用", "app.name.desktop": "OpenCode Desktop", @@ -751,7 +637,6 @@ export const dict = { "settings.desktop.wsl.description": "在 Windows 的 WSL 环境中运行 OpenCode 服务器。", "settings.general.section.appearance": "外观", - "settings.general.section.advanced": "高级", "settings.general.section.notifications": "系统通知", "settings.general.section.updates": "更新", "settings.general.section.sounds": "音效", @@ -759,10 +644,6 @@ export const dict = { "settings.general.section.display": "显示", "settings.general.row.language.title": "语言", "settings.general.row.language.description": "更改 OpenCode 的显示语言", - "settings.general.row.shell.title": "终端 Shell", - "settings.general.row.shell.description": "选择终端使用的 shell。兼容的 shell 也会用于智能体工具调用。", - "settings.general.row.shell.autoDefault": "自动(默认)", - "settings.general.row.shell.terminalOnly": "仅终端", "settings.general.row.appearance.title": "外观", "settings.general.row.appearance.description": "自定义 OpenCode 在你的设备上的外观", "settings.general.row.colorScheme.title": "配色方案", @@ -771,42 +652,20 @@ export const dict = { "settings.general.row.theme.description": "自定义 OpenCode 的主题。", "settings.general.row.font.title": "代码字体", "settings.general.row.font.description": "自定义代码块使用的字体", - "settings.general.row.terminalFont.title": "终端字体", - "settings.general.row.terminalFont.description": "自定义终端使用的字体", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "界面字体", "settings.general.row.uiFont.description": "自定义整个界面使用的字体", "settings.general.row.followup.title": "跟进消息行为", "settings.general.row.followup.description": "选择跟进提示是立即引导还是在队列中等待", "settings.general.row.followup.option.queue": "排队", "settings.general.row.followup.option.steer": "引导", - "settings.general.row.showFileTree.title": "文件树", - "settings.general.row.showFileTree.description": "在会话中显示文件树面板", - "settings.general.row.showNavigation.title": "导航控件", - "settings.general.row.showNavigation.description": "在桌面版标题栏中显示前进和后退按钮", - "settings.general.row.showSearch.title": "命令面板", - "settings.general.row.showSearch.description": "在标题栏中显示搜索和命令面板按钮", - "settings.general.row.showTerminal.title": "终端", - "settings.general.row.showTerminal.description": "在桌面版标题栏中显示终端按钮", - "settings.general.row.showStatus.title": "服务器状态", - "settings.general.row.showStatus.description": "在标题栏中显示服务器状态按钮", - "settings.general.row.mobileTitlebarBottom.title": "底部导航", - "settings.general.row.mobileTitlebarBottom.description": "在移动设备上将标题栏和会话标签页置于屏幕底部", - "settings.general.row.showCustomAgents.title": "自定义智能体", - "settings.general.row.showCustomAgents.description": "在输入框中显示智能体选择器", "settings.general.row.reasoningSummaries.title": "显示推理摘要", "settings.general.row.reasoningSummaries.description": "在时间线中显示模型推理摘要", "settings.general.row.shellToolPartsExpanded.title": "展开 shell 工具部分", "settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分", "settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分", "settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分", - "settings.general.row.newInterface.title": "新布局", - "settings.general.row.newInterface.badge": "新", - "settings.general.row.newInterface.description": "使用新的标签页和主页布局。在有限时间内可在不同布局之间切换。", - "settings.general.row.newInterfaceNotice.title": "你现在使用的是新布局", - "settings.general.row.newInterfaceNotice.description": "之前的布局不再可用", - "settings.general.row.newInterfaceNotice.dismiss": "忽略", - "settings.general.row.pinchZoom.title": "双指缩放", - "settings.general.row.pinchZoom.description": "允许使用触控板双指捏合和 Ctrl-scroll 手势缩放", "settings.general.row.wayland.title": "使用原生 Wayland", "settings.general.row.wayland.description": "在 Wayland 上禁用 X11 回退。需要重启。", "settings.general.row.wayland.tooltip": "在混合刷新率显示器的 Linux 系统上,原生 Wayland 可能更稳定。", @@ -819,8 +678,6 @@ export const dict = { "settings.updates.row.check.description": "手动检查更新并在有更新时安装", "settings.updates.action.checkNow": "立即检查", "settings.updates.action.checking": "正在检查...", - "settings.updates.action.downloading": "正在下载...", - "settings.updates.action.installing": "正在安装...", "settings.updates.toast.latest.title": "已是最新版本", "settings.updates.toast.latest.description": "你正在使用最新版本的 OpenCode。", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 6f66822165..7998dde3ef 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -27,9 +27,6 @@ export const dict = { "command.sidebar.toggle": "切換側邊欄", "command.project.open": "開啟專案", - "command.project.previous": "上一個專案", - "command.project.next": "下一個專案", - "command.project.index": "切換至專案 {{index}}", "command.provider.connect": "連接提供者", "command.server.switch": "切換伺服器", "command.settings.open": "開啟設定", @@ -52,7 +49,6 @@ export const dict = { "command.session.new": "新增工作階段", "command.file.open": "開啟檔案", "command.tab.close": "關閉分頁", - "command.tab.reopenClosed": "重新開啟已關閉的分頁", "command.context.addSelection": "將選取內容加入上下文", "command.context.addSelection.description": "加入目前檔案中選取的行", "command.input.focus": "聚焦輸入框", @@ -97,7 +93,6 @@ export const dict = { "command.session.unshare.description": "停止分享此工作階段", "palette.search.placeholder": "搜尋檔案、命令和工作階段", - "palette.search.placeholder.home": "搜尋命令和工作階段", "palette.empty": "找不到結果", "palette.group.commands": "命令", "palette.group.files": "檔案", @@ -214,11 +209,6 @@ export const dict = { "model.input.video": "影片", "model.input.pdf": "pdf", "model.tooltip.allows": "支援: {{inputs}}", - "model.tooltip.context.label": "上下文", - "model.tooltip.inputs": "輸入", - "model.tooltip.model": "模型", - "model.tooltip.provider": "供應商", - "model.tooltip.reasoning": "推理", "model.tooltip.reasoning.allowed": "支援推理", "model.tooltip.reasoning.none": "不支援推理", "model.tooltip.context": "上下文上限 {{limit}}", @@ -228,7 +218,6 @@ export const dict = { "common.loading": "載入中", "common.loading.ellipsis": "...", "common.cancel": "取消", - "common.clear": "清除", "common.connect": "連線", "common.disconnect": "中斷連線", "common.continue": "提交", @@ -246,8 +235,6 @@ export const dict = { "prompt.mode.shell": "Shell", "prompt.mode.normal": "Prompt", "prompt.mode.shell.exit": "按 esc 退出", - "session.child.promptDisabled": "無法在子代理程式工作階段中輸入提示詞。", - "session.child.backToParent": "返回主要工作階段。", "prompt.example.1": "修復程式碼庫中的一個 TODO", "prompt.example.2": "這個專案的技術堆疊是什麼?", @@ -287,17 +274,11 @@ export const dict = { "prompt.context.removeActiveFile": "從上下文移除目前檔案", "prompt.context.removeFile": "從上下文移除檔案", "prompt.action.attachFile": "附加檔案", - "prompt.menu.addImagesAndFiles": "新增檔案及更多內容", - "prompt.menu.imagesAndFiles": "圖片和檔案", - "prompt.menu.commands": "命令", - "prompt.menu.context": "上下文", - "prompt.menu.shellCommand": "shell 命令", "prompt.attachment.remove": "移除附件", "prompt.action.send": "傳送", "prompt.action.stop": "停止", "prompt.toast.pasteUnsupported.title": "不支援的附件", - "prompt.toast.attachmentDuplicate.title": "此檔案已上傳", "prompt.toast.pasteUnsupported.description": "此處僅能附加圖片、PDF 或文字檔案。", "prompt.toast.modelAgentRequired.title": "請選擇代理程式和模型", "prompt.toast.modelAgentRequired.description": "傳送提示前請先選擇代理程式和模型。", @@ -359,73 +340,6 @@ export const dict = { "dialog.server.menu.delete": "刪除", "dialog.server.current": "目前伺服器", "dialog.server.status.default": "預設", - "wsl.server.add": "新增 WSL 伺服器", - "wsl.server.addShort": "新增 WSL", - "wsl.server.label": "WSL", - "wsl.server.menu.label": "WSL 伺服器", - "wsl.server.retryStart": "重試啟動", - "wsl.server.updating": "更新中...", - "wsl.onboarding.step.distro": "選擇發行版", - "wsl.onboarding.step.opencode": "OpenCode", - "wsl.onboarding.checkingRuntime": "正在檢查 WSL...", - "wsl.onboarding.restartRequired": "Windows 需要重新啟動,才能完成 WSL 安裝。", - "wsl.onboarding.ready": "WSL 已就緒。", - "wsl.onboarding.required": "需要 WSL 才能繼續。", - "wsl.onboarding.checkingDistros": "正在檢查發行版...", - "wsl.onboarding.installingDistro": "正在安裝 {{distro}}...", - "wsl.onboarding.checkingDistro": "正在檢查 {{distro}}...", - "wsl.onboarding.listingDistros": "正在列出發行版...", - "wsl.onboarding.distroReady": "{{distro}} 已就緒。", - "wsl.onboarding.distroNotInstalled": "尚未安裝 {{distro}}。", - "wsl.onboarding.openDistroOnce": "開啟 {{distro}} 一次以完成設定。", - "wsl.onboarding.finishingDistro": "正在完成 {{distro}} 的設定。", - "wsl.onboarding.pickDistro": "選擇發行版,或在下方安裝。", - "wsl.onboarding.checkingOpencode": "正在檢查 OpenCode...", - "wsl.onboarding.checkingOpencodeIn": "正在檢查 {{distro}} 中的 OpenCode...", - "wsl.onboarding.updatingOpencode": "正在更新 OpenCode...", - "wsl.onboarding.updatingOpencodeIn": "正在更新 {{distro}} 中的 OpenCode...", - "wsl.onboarding.updateOpencodeIn": "更新 {{distro}} 中的 OpenCode。", - "wsl.onboarding.updateOpencode": "更新 OpenCode", - "wsl.onboarding.opencodeReadyIn": "{{distro}} 中的 OpenCode 已就緒。", - "wsl.onboarding.opencodeReady": "OpenCode 已就緒。", - "wsl.onboarding.installOpencodeIn": "在 {{distro}} 中安裝 OpenCode。", - "wsl.onboarding.installOpencode": "安裝 OpenCode", - "wsl.onboarding.chooseDistroFirst": "請先選擇發行版。", - "wsl.onboarding.loadFailed": "無法載入 WSL 狀態。", - "wsl.onboarding.loading": "載入中...", - "wsl.onboarding.installedDistros": "已安裝的發行版", - "wsl.onboarding.checkAgain": "再次檢查", - "wsl.onboarding.distroStatus.ready": "就緒", - "wsl.onboarding.distroStatus.checking": "檢查中...", - "wsl.onboarding.distroStatus.opencodeMissing": "未安裝 OpenCode", - "wsl.onboarding.distroStatus.missingTools": "缺少 bash、curl", - "wsl.onboarding.distroStatus.unsupported": "不支援 · 請使用 WSL 2", - "wsl.onboarding.needAnotherDistro": "需要其他發行版?", - "wsl.onboarding.needAnotherDistroHint": "從 WSL 目錄安裝 Linux 發行版", - "wsl.onboarding.wslNotInstalled.title": "未安裝 WSL", - "wsl.onboarding.wslNotInstalled.description": "新增 WSL 伺服器前,必須先安裝 WSL(Windows Subsystem for Linux)", - "wsl.onboarding.wslUnavailable.title": "WSL 無法使用", - "wsl.onboarding.wslUnavailable.description": "OpenCode 無法確認此電腦上的 WSL。", - "wsl.onboarding.installWsl": "安裝 WSL", - "wsl.onboarding.windowsRestartRequired": "重新啟動 Windows 以完成 WSL 安裝,然後重新開啟 OpenCode。", - "wsl.onboarding.next": "下一步", - "wsl.onboarding.refresh": "重新整理", - "wsl.onboarding.allDistrosAdded": "所有已安裝的發行版都已新增。", - "wsl.onboarding.noDistros": "尚未偵測到發行版。", - "wsl.onboarding.install": "安裝", - "wsl.onboarding.installing": "安裝中...", - "wsl.onboarding.installDistro": "安裝發行版", - "wsl.onboarding.searchDistros": "搜尋發行版", - "wsl.onboarding.wsl2Required": "需要 WSL 2。", - "wsl.onboarding.toolsRequired": "此發行版需要 bash 和 curl。", - "wsl.onboarding.openTerminal": "開啟終端機", - "wsl.onboarding.path": "路徑: {{path}}", - "wsl.onboarding.notFound": "找不到", - "wsl.onboarding.version": "版本: {{version}}", - "wsl.onboarding.unknown": "未知", - "wsl.onboarding.desktopVersion": "桌面版 {{version}}", - "wsl.onboarding.versionMismatch": "已安裝版本與桌面應用程式版本不符。", - "wsl.onboarding.adding": "新增中...", "dialog.project.edit.title": "編輯專案", "dialog.project.edit.name": "名稱", @@ -488,7 +402,6 @@ export const dict = { "language.no": "Norsk", "language.br": "Português (Brasil)", "language.bs": "Bosanski", - "language.uk": "Українська", "language.th": "ไทย", "language.tr": "Türkçe", @@ -536,12 +449,10 @@ export const dict = { "error.page.title": "出了點問題", "error.page.description": "載入應用程式時發生錯誤。", - "error.page.description.localServerStartup": "啟動本機伺服器時發生錯誤。", "error.page.details.label": "錯誤詳情", "error.page.action.restart": "重新啟動", "error.page.action.report": "回報錯誤", "error.page.action.reported": "已回報錯誤", - "error.page.action.exportLogs": "匯出日誌", "error.page.action.checking": "檢查中...", "error.page.action.checkUpdates": "檢查更新", "error.page.action.updateTo": "更新到 {{version}}", @@ -587,40 +498,17 @@ export const dict = { "home.recentProjects": "最近專案", "home.empty.title": "沒有最近專案", "home.empty.description": "透過開啟本地專案開始使用", - "home.title": "首頁", - "home.projects": "專案", - "home.project.add": "新增專案", - "home.recentlyClosed": "最近關閉", - "home.server.collapse": "收合伺服器專案", - "home.server.expand": "展開伺服器專案", - "home.sessions.search.placeholder": "搜尋工作階段", - "home.sessions.search.placeholder.scoped": "在 {{scope}} 中搜尋工作階段", - "home.sessions.search.sessions": "工作階段", - "home.sessions.search.noResults": "找不到符合 {{query}} 的工作階段", - "home.sessions.empty": "這裡還沒有內容", - "home.sessions.empty.description": "建立工作階段以開始使用", - "home.sessions.group.today": "今天", - "home.sessions.group.yesterday": "昨天", - "home.sessions.group.older": "更早", - "home.providerTip": "連接 75 個以上的供應商,使用 Claude、GPT、Gemini 等其他模型", "session.tab.session": "工作階段", "session.tab.review": "審查", "session.tab.context": "上下文", - "session.tab.unknown": "未知的工作階段", "session.panel.reviewAndFiles": "審查與檔案", - "session.error.notFound": "找不到此工作階段", - "session.error.notFound.description": "此分頁指向的工作階段已不存在於此伺服器。", - "session.error.notFound.closeTab": "關閉分頁", - "session.error.serverConnection": "無法連線至此伺服器", "session.review.filesChanged": "{{count}} 個檔案變更", "session.review.change.one": "變更", "session.review.change.other": "變更", "session.review.loadingChanges": "正在載入變更...", "session.review.empty": "此工作階段暫無變更", "session.review.noChanges": "沒有變更", - "session.review.noUncommittedChanges": "尚無未提交的變更", - "session.review.noBranchChanges": "尚無分支變更", "session.review.noVcs": "未偵測到 Git 版本控制系統,無法顯示變更", "session.review.noSnapshot": "設定中已停用快照追蹤,因此無法使用工作階段變更", "session.files.selectToOpen": "選取要開啟的檔案", @@ -637,10 +525,6 @@ export const dict = { "session.todo.title": "待辦事項", "session.todo.collapse": "折疊", "session.todo.expand": "展開", - "session.question.minimize": "最小化問題", - "session.question.restore": "還原問題", - "session.question.pending.one": "{{count}} 個待處理問題", - "session.question.pending.other": "{{count}} 個待處理問題", "session.followupDock.summary.one": "{{count}} 則佇列訊息", "session.followupDock.summary.other": "{{count}} 則佇列訊息", "session.followupDock.sendNow": "立即傳送", @@ -654,17 +538,9 @@ export const dict = { "session.revertDock.restore": "還原訊息", "session.new.title": "建構任何東西", - "session.new.project.new": "新專案", - "session.new.project.search": "搜尋專案", - "session.new.project.add": "新增專案", "session.new.worktree.main": "主分支", "session.new.worktree.mainWithBranch": "主分支 ({{branch}})", "session.new.worktree.create": "建立新的 worktree", - "session.new.workspace.runIn": "工作階段執行位置", - "session.new.workspace.triggerLocal": "本機", - "session.new.workspace.local": "本機儲存庫", - "session.new.workspace.existing": "工作區…", - "session.new.git.none": "無 Git", "session.new.lastModified": "最後修改", "session.header.search.placeholder": "搜尋 {{project}}", @@ -732,8 +608,6 @@ export const dict = { "sidebar.project.recentSessions": "最近工作階段", "sidebar.project.viewAllSessions": "查看全部工作階段", "sidebar.project.clearNotifications": "清除通知", - "sidebar.empty.title": "未開啟任何專案", - "sidebar.empty.description": "開啟專案以開始使用", "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "桌面", @@ -745,7 +619,6 @@ export const dict = { "settings.desktop.wsl.description": "在 Windows 上的 WSL 中執行 OpenCode 伺服器。", "settings.general.section.appearance": "外觀", - "settings.general.section.advanced": "進階", "settings.general.section.notifications": "系統通知", "settings.general.section.updates": "更新", "settings.general.section.sounds": "音效", @@ -754,10 +627,6 @@ export const dict = { "settings.general.row.language.title": "語言", "settings.general.row.language.description": "變更 OpenCode 的顯示語言", - "settings.general.row.shell.title": "終端機 Shell", - "settings.general.row.shell.description": "選擇終端機使用的 Shell。相容的 Shell 也會用於代理程式工具呼叫。", - "settings.general.row.shell.autoDefault": "自動(預設)", - "settings.general.row.shell.terminalOnly": "僅限終端機", "settings.general.row.appearance.title": "外觀", "settings.general.row.appearance.description": "自訂 OpenCode 在你的裝置上的外觀", "settings.general.row.colorScheme.title": "配色方案", @@ -766,28 +635,14 @@ export const dict = { "settings.general.row.theme.description": "自訂 OpenCode 的主題。", "settings.general.row.font.title": "程式碼字型", "settings.general.row.font.description": "自訂程式碼區塊使用的字型", - "settings.general.row.terminalFont.title": "終端機字型", - "settings.general.row.terminalFont.description": "自訂終端機使用的字型", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "介面字型", "settings.general.row.uiFont.description": "自訂整個介面使用的字型", "settings.general.row.followup.title": "後續追問行為", "settings.general.row.followup.description": "選擇後續追問提示是立即引導還是進入佇列等待", "settings.general.row.followup.option.queue": "佇列", "settings.general.row.followup.option.steer": "引導", - "settings.general.row.showFileTree.title": "檔案樹", - "settings.general.row.showFileTree.description": "在工作階段中顯示檔案樹面板", - "settings.general.row.showNavigation.title": "導覽控制項", - "settings.general.row.showNavigation.description": "在桌面標題列中顯示上一頁和下一頁按鈕", - "settings.general.row.showSearch.title": "命令面板", - "settings.general.row.showSearch.description": "在標題列中顯示搜尋和命令面板按鈕", - "settings.general.row.showTerminal.title": "終端機", - "settings.general.row.showTerminal.description": "在桌面標題列中顯示終端機按鈕", - "settings.general.row.showStatus.title": "伺服器狀態", - "settings.general.row.showStatus.description": "在標題列中顯示伺服器狀態按鈕", - "settings.general.row.mobileTitlebarBottom.title": "底部導覽", - "settings.general.row.mobileTitlebarBottom.description": "在行動裝置上將標題列和工作階段分頁置於畫面底部", - "settings.general.row.showCustomAgents.title": "自訂代理程式", - "settings.general.row.showCustomAgents.description": "在輸入區顯示代理程式選擇器", "settings.general.row.reasoningSummaries.title": "顯示推理摘要", "settings.general.row.reasoningSummaries.description": "在時間軸中顯示模型推理摘要", @@ -795,14 +650,6 @@ export const dict = { "settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊", "settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊", "settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊", - "settings.general.row.newInterface.title": "新版面", - "settings.general.row.newInterface.badge": "新", - "settings.general.row.newInterface.description": "使用新的分頁和首頁版面。在限定時間內可切換版面。", - "settings.general.row.newInterfaceNotice.title": "你現在使用的是新版面", - "settings.general.row.newInterfaceNotice.description": "先前的版面已無法使用", - "settings.general.row.newInterfaceNotice.dismiss": "忽略", - "settings.general.row.pinchZoom.title": "雙指縮放", - "settings.general.row.pinchZoom.description": "允許使用觸控板雙指開合和 Ctrl-捲動手勢縮放", "settings.general.row.wayland.title": "使用原生 Wayland", "settings.general.row.wayland.description": "在 Wayland 上停用 X11 後備模式。需要重新啟動。", "settings.general.row.wayland.tooltip": "在混合更新率螢幕的 Linux 系統上,原生 Wayland 可能更穩定。", @@ -816,8 +663,6 @@ export const dict = { "settings.updates.row.check.description": "手動檢查更新並在有更新時安裝", "settings.updates.action.checkNow": "立即檢查", "settings.updates.action.checking": "檢查中...", - "settings.updates.action.downloading": "下載中...", - "settings.updates.action.installing": "安裝中...", "settings.updates.toast.latest.title": "已是最新版本", "settings.updates.toast.latest.description": "你正在使用最新版本的 OpenCode。", diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 78a9e103a7..bc6866f976 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -224,26 +224,7 @@ [data-slot="model-selector-scroll"]::before { top: 0; - height: 12px; - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--v2-background-bg-layer-01) 80%, transparent) 0%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 78.3336%, transparent) 4.7%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 76.4824%, transparent) 8.9%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 74.368%, transparent) 12.8%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 71.9123%, transparent) 16.56%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 69.037%, transparent) 20.37%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 65.664%, transparent) 24.4%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 61.715%, transparent) 28.83%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 57.1117%, transparent) 33.84%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 51.776%, transparent) 39.6%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 45.6296%, transparent) 46.3%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 38.5944%, transparent) 54.1%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 30.592%, transparent) 63.2%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 21.5443%, transparent) 73.76%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 11.373%, transparent) 85.97%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 0%, transparent) 100% - ); + background: linear-gradient(to bottom, var(--v2-background-bg-layer-01), transparent); } [data-slot="manage-models-scroll"]::before { @@ -258,26 +239,7 @@ [data-slot="model-selector-scroll"]::after { bottom: 0; - height: 12px; - background: linear-gradient( - 0deg, - color-mix(in srgb, var(--v2-background-bg-layer-01) 80%, transparent) 0%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 78.3336%, transparent) 4.7%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 76.4824%, transparent) 8.9%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 74.368%, transparent) 12.8%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 71.9123%, transparent) 16.56%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 69.037%, transparent) 20.37%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 65.664%, transparent) 24.4%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 61.715%, transparent) 28.83%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 57.1117%, transparent) 33.84%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 51.776%, transparent) 39.6%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 45.6296%, transparent) 46.3%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 38.5944%, transparent) 54.1%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 30.592%, transparent) 63.2%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 21.5443%, transparent) 73.76%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 11.373%, transparent) 85.97%, - color-mix(in srgb, var(--v2-background-bg-layer-01) 0%, transparent) 100% - ); + background: linear-gradient(to top, var(--v2-background-bg-layer-01), transparent); } @supports (animation-timeline: --home-projects-scroll) and (timeline-scope: --home-projects-scroll) { diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 8d36086d9f..2bc9262fde 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,11 +1,4 @@ export { AppBaseProviders, AppInterface } from "./app" -export { useLayout } from "./context/layout" -export { useServerSDK } from "./context/server-sdk" -export { useServerSync } from "./context/server-sync" -export { useServer } from "./context/server" -export { useSettings } from "./context/settings" -export { useTabs } from "./context/tabs" -export { useProviders } from "./hooks/use-providers" export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { useCommand } from "./context/command" export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language" @@ -27,4 +20,4 @@ export { type WslServersState, } from "./wsl/types" export { ServerConnection } from "./context/server" -export { createDraftStore, type DraftStore } from "./utils/draft-store" +export { handleNotificationClick } from "./utils/notification-click" diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index a0b3576396..77c120e57b 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -63,7 +63,6 @@ export function DirectoryDataProvider( navigate(href(sessionID))} onSessionHref={href} > diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index 2fc6095412..86a997b39d 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -354,7 +354,7 @@ export const ErrorPage: Component = (props) => {
+ ) +} + +function HomeProjectColumn(props: { + projects: LocalProject[] + selected: HomeProjectSelection + focusServer: (server: ServerConnection.Any) => void + selectProject: (server: ServerConnection.Any, directory: string) => void + openNewSession: (server: ServerConnection.Any, directory: string) => void + chooseProject: (server: ServerConnection.Any) => void + editProject: (server: ServerConnection.Any, project: LocalProject) => void + closeProject: (server: ServerConnection.Any, directory: string) => void + clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void + unseenCount: (server: ServerConnection.Any, project: LocalProject) => number + openSettings: () => void + openHelp: () => void + language: ReturnType +}) { + const global = useGlobal() + const dialog = useDialog() + const controller = useServerManagementController({ navigateOnAdd: false }) + const [_state, setState, _, ready] = persisted( + Persist.global("home.servers", ["home.servers.v1"]), + createStore({ collapsed: {} as Record }), + ) + const [state] = createResource( + () => ready.promise ?? Promise.resolve(), + (p) => p.then(() => _state), + { initialValue: _state }, + ) + + return ( + + ) +} + +function HomeUtilityNav(props: { + class?: string + openSettings: () => void + openHelp: () => void + language: ReturnType +}) { + return ( +
+ + +
+ ) +} + +function HomeServerRow(props: { + server: ServerConnection.Any + selected: boolean + collapsed: boolean + health: ServerHealth | undefined + controller: ReturnType + focusServer: (server: ServerConnection.Any) => void + chooseProject: (server: ServerConnection.Any) => void + openEdit: (server: ServerConnection.Http) => void + toggleCollapsed: () => void + language: ReturnType +}) { + const global = useGlobal() + const [state, setState] = createStore({ menuOpen: false }) + const healthy = () => !!props.health?.healthy + const canToggle = () => healthy() && global.ensureServerCtx(props.server).projects.list().length > 0 + return ( +
+ +
+ setState("menuOpen", open)} + /> + + } + aria-label={props.language.t("home.project.add")} + disabled={props.health?.healthy === false} + onClick={() => props.chooseProject(props.server)} + /> + +
+
+ ) +} + +function HomeProjectList(props: { + server: ServerConnection.Any + projects: LocalProject[] + selected: HomeProjectSelection + selectProject: (server: ServerConnection.Any, directory: string) => void + openNewSession: (server: ServerConnection.Any, directory: string) => void + editProject: (server: ServerConnection.Any, project: LocalProject) => void + closeProject: (server: ServerConnection.Any, directory: string) => void + clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void + unseenCount: (server: ServerConnection.Any, project: LocalProject) => number + language: ReturnType +}) { + return ( +
+ + {(project) => ( + + )} + +
+ ) +} + +function HomeProjectRow(props: { + project: LocalProject + server: ServerConnection.Any + selected: boolean + unseenCount: number + selectProject: (server: ServerConnection.Any, directory: string) => void + openNewSession: (server: ServerConnection.Any, directory: string) => void + editProject: (server: ServerConnection.Any, project: LocalProject) => void + closeProject: (server: ServerConnection.Any, directory: string) => void + clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void + language: ReturnType +}) { + const global = useGlobal() + const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false + const [state, setState] = createStore({ menuOpen: false }) + return ( +
+ +
+ setState("menuOpen", open)} + > + } + aria-label={props.language.t("common.moreOptions")} + /> + + + props.openNewSession(props.server, props.project.worktree)}> + {props.language.t("command.session.new")} + + props.editProject(props.server, props.project)}> + {props.language.t("dialog.project.edit.title")} + + props.clearNotifications(props.server, props.project)} + > + {props.language.t("sidebar.project.clearNotifications")} + + + props.closeProject(props.server, props.project.worktree)}> + {props.language.t("common.close")} + + + + + } + aria-label={props.language.t("command.session.new")} + onClick={() => props.openNewSession(props.server, props.project.worktree)} + /> +
+
+ ) +} + +function HomeProjectAvatar(props: { project: LocalProject }) { + const name = createMemo(() => displayName(props.project)) + return ( + + ) +} + +function HomeSessionLeading(props: { + project: LocalProject + session: Session + server: ServerConnection.Key + activeServer: boolean + revealProjectOnHover: boolean +}) { + const tabs = useTabs() + const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session)) + return ( +
+ + + +
+ ) +} + +function HomeSessionSearch(props: { + value: string + placeholder: string + open: boolean + loading: boolean + results: HomeSessionRecord[] + showProjectName: boolean + server: ServerConnection.Key + activeServer: boolean + noResultsLabel: string + bindFocus: (focus: () => void) => void + onInput: (value: string) => void + onFocus: () => void + onClose: () => void + onSelect: (session: Session) => void +}) { + const language = useLanguage() + const [store, setStore] = createStore({ active: "" }) + let root: HTMLDivElement | undefined + let input: HTMLInputElement | undefined + let listRef: HTMLDivElement | undefined + + const focusInput = () => { + input?.focus() + props.onFocus() + } + + onMount(() => { + props.bindFocus(focusInput) + }) + + const syncActive = (results: HomeSessionRecord[]) => { + if (results.length === 0) { + setStore("active", "") + return + } + if (!results.some((record) => homeSessionSearchKey(record) === store.active)) { + setStore("active", homeSessionSearchKey(results[0])) + } + } + + createEffect(() => syncActive(props.results)) + + createEffect( + on( + () => props.value, + () => syncActive(props.results), + ), + ) + + const scrollActiveIntoView = () => { + const key = store.active + if (!key || !listRef) return + const element = listRef.querySelector(`[data-key="${key}"]`) + element?.scrollIntoView({ block: "nearest" }) + } + + const moveActive = (delta: number) => { + const results = props.results + if (results.length === 0) return + const index = results.findIndex((record) => homeSessionSearchKey(record) === store.active) + const start = index === -1 ? 0 : index + const next = (start + delta + results.length) % results.length + setStore("active", homeSessionSearchKey(results[next])) + scrollActiveIntoView() + } + + const selectActive = () => { + const record = props.results.find((item) => homeSessionSearchKey(item) === store.active) + if (!record) return + props.onSelect(record.session) + } + + onCleanup( + makeEventListener(document, "pointerdown", (event) => { + if (!props.open) return + const target = event.target + if (!(target instanceof Node)) return + if (root?.contains(target)) return + props.onClose() + }), + ) + + return ( +
+
+ +
+
+
+ + +
+ } + > + 0} + fallback={ +

+ {props.noResultsLabel} +

+ } + > +
+

+ {language.t("home.sessions.search.sessions")} +

+ (listRef = el)}> +
+ + {(record) => ( + setStore("active", homeSessionSearchKey(record))} + onSelect={(session) => props.onSelect(session)} + /> + )} + +
+
+
+
+ +
+
+
+ + +
+
+ ) +} + +function HomeSessionSearchResultRow(props: { + record: HomeSessionRecord + showProjectName: boolean + server: ServerConnection.Key + activeServer: boolean + selected: boolean + onHighlight: () => void + onSelect: (session: Session) => void +}) { + const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) + const showProjectName = () => props.showProjectName && props.record.projectName + + const key = () => homeSessionSearchKey(props.record) + + return ( + + ) +} + +function HomeSessionGroupHeader(props: { + title: string + titleOpacity: number + ref: ComponentProps<"div">["ref"] + elevated?: boolean +}) { + return ( +
+ +
+ ) +} + +function HomeSessionRow(props: { + record: HomeSessionRecord + showProjectName: boolean + server: ServerConnection.Key + activeServer: boolean + openSession: (session: Session) => void + archiveSession: (session: Session) => Promise +}) { + const language = useLanguage() + const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) + const showProjectName = () => props.showProjectName && props.record.projectName + + return ( +
+ + +
+ + } + aria-label={language.t("common.archive")} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + void props.archiveSession(props.record.session) + }} + /> + +
+
+
+ ) +} + +function HomeSessionsEmpty(props: { onNewSession?: () => void }) { + const language = useLanguage() + return ( +
+
+ {language.t("home.sessions.empty")} +
+

+ {language.t("home.sessions.empty.description")} +

+ + {(onNewSession) => ( + + {language.t("command.session.new")} + + )} + +
+ ) +} + +function HomeSessionSkeleton(props: { label: string }) { + return ( +
+
+ +
+ + ) +} + +function groupSessions(records: HomeSessionRecord[], language: ReturnType): HomeSessionGroup[] { + const now = DateTime.local() + const yesterday = now.minus({ days: 1 }) + const todaySessions = records.filter((record) => + DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"), + ) + const yesterdaySessions = records.filter((record) => + DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"), + ) + const olderSessions = records.filter((record) => { + const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created) + return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day") + }) + const olderTitle = + todaySessions.length === 0 && yesterdaySessions.length === 0 + ? language.t("sidebar.project.recentSessions") + : language.t("home.sessions.group.older") + + return [ + { id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions }, + { id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions }, + { id: "older" as const, title: olderTitle, sessions: olderSessions }, + ].filter((group) => group.sessions.length > 0) +} + +export function LegacyHome() { + const sync = useServerSync() + const platform = usePlatform() + const pickDirectory = useDirectoryPicker() + const dialog = useDialog() + const navigate = useNavigate() + const global = useGlobal() + const server = useServer() + const language = useLanguage() + const homedir = createMemo(() => sync().data.path.home) + const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false) + const recent = createMemo(() => { + return sync() + .data.project.slice() + .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) + .slice(0, 5) + }) + + const serverDotClass = createMemo(() => { + const healthy = global.servers.health[server.key]?.healthy + if (healthy === true) return "bg-icon-success-base" + if (healthy === false) return "bg-icon-critical-base" + return "bg-border-weak-base" + }) + + function openProject(server: ServerConnection.Any, directory: string) { + const serverCtx = global.ensureServerCtx(server) + serverCtx.projects.open(directory) + serverCtx.projects.touch(directory) + navigate(`/${base64Encode(directory)}`) + } + + function chooseProject() { + if (serverUnreachable()) return + const s = server.current + if (!s) return + + const resolve = (result: string | string[] | null) => { + if (Array.isArray(result)) { + for (const directory of result) { + openProject(s, directory) + } + } else if (result) { + openProject(s, result) + } + } + + pickDirectory({ + server: s, + title: language.t("command.project.open"), + multiple: true, + onSelect: resolve, + }) + } + + return ( +
+ + + + 0}> +
+
+
{language.t("home.recentProjects")}
+ +
+
    + + {(project) => ( + + )} + +
+
+
+ +
+
{language.t("common.loading")}
+ +
+
+ +
+ +
+
{language.t("home.empty.title")}
+
{language.t("home.empty.description")}
+
+ +
+
+
) } diff --git a/packages/app/src/pages/home/home-controller.ts b/packages/app/src/pages/home/home-controller.ts deleted file mode 100644 index 5e7e50cfc4..0000000000 --- a/packages/app/src/pages/home/home-controller.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { useGlobal } from "@/context/global" -import { type HomeProjectSelection, useLayout } from "@/context/layout" -import { ServerConnection, useServer } from "@/context/server" -import { useServerSync } from "@/context/server-sync" -import { useTabs } from "@/context/tabs" -import { toggleHomeProjectSelection } from "@/pages/layout/helpers" -import { createEffect, createMemo } from "solid-js" - -export function createHomeController() { - const sync = useServerSync() - const layout = useLayout() - const server = useServer() - const global = useGlobal() - const tabs = useTabs() - const selection = layout.home.selection - const focusedServer = createMemo( - () => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current, - ) - const focusedServerCtx = createMemo(() => { - const conn = focusedServer() - if (!conn) return undefined - return global.ensureServerCtx(conn) - }) - const focusedSync = () => focusedServerCtx()?.sync ?? sync() - const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list()) - const recentlyClosed = createMemo( - () => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(), - ) - const homedir = createMemo(() => focusedSync().data.path.home ?? "") - const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory)) - const newSessionProject = createMemo( - () => - selectedProject() ?? - projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ?? - projects()[0], - ) - - createEffect(() => { - const list = global.servers.list() - if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return - const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0] - if (conn) setSelection({ server: ServerConnection.key(conn) }) - }) - - function setSelection(next: HomeProjectSelection) { - layout.home.setSelection(next) - } - - function openProjectNewSession(conn: ServerConnection.Any, directory: string) { - const ctx = global.ensureServerCtx(conn) - ctx.projects.open(directory) - ctx.projects.touch(directory) - void tabs.newDraft({ server: ServerConnection.key(conn), directory }) - } - - return { - selection: { - value: selection, - set: setSelection, - focusServer: (conn: ServerConnection.Any) => setSelection({ server: ServerConnection.key(conn) }), - }, - server: { - list: global.servers.list, - health: (conn: ServerConnection.Any) => global.servers.health[ServerConnection.key(conn)], - context: (conn: ServerConnection.Any) => global.ensureServerCtx(conn), - focused: focusedServer, - focusedContext: focusedServerCtx, - focusedSync, - }, - project: { - list: projects, - recentlyClosed, - homedir, - selected: selectedProject, - newSession: newSessionProject, - forServer: (conn: ServerConnection.Any) => global.ensureServerCtx(conn).projects.list(), - select: (conn: ServerConnection.Any, directory: string) => { - const key = ServerConnection.key(conn) - if (global.servers.health[key]?.healthy === false) return - if ( - !global - .ensureServerCtx(conn) - .projects.list() - .some((project) => project.worktree === directory) - ) - return - setSelection(toggleHomeProjectSelection(selection(), key, directory)) - }, - add: (conn: ServerConnection.Any, directories: string[]) => { - const directory = directories[0] - if (!directory) return - const ctx = global.ensureServerCtx(conn) - directories.forEach((item) => ctx.projects.open(item)) - ctx.projects.touch(directory) - setSelection({ server: ServerConnection.key(conn), directory }) - }, - openNewSession: () => { - const conn = focusedServer() - const project = newSessionProject() - if (!conn || !project) return - openProjectNewSession(conn, project.worktree) - }, - openProjectNewSession, - }, - } -} - -export type HomeController = ReturnType diff --git a/packages/app/src/pages/home/home-projects-controller.tsx b/packages/app/src/pages/home/home-projects-controller.tsx deleted file mode 100644 index 18c58bd290..0000000000 --- a/packages/app/src/pages/home/home-projects-controller.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { useDirectoryPicker } from "@/components/directory-picker" -import { useServerManagementController } from "@/components/dialog-select-server" -import { useSettingsCommand } from "@/components/settings-dialog" -import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2" -import { type LocalProject } from "@/context/layout" -import { useLanguage } from "@/context/language" -import { useNotification } from "@/context/notification" -import { usePlatform } from "@/context/platform" -import { ServerConnection } from "@/context/server" -import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/pages/layout/helpers" -import { Persist, persisted } from "@/utils/persist" -import { showToast } from "@/utils/toast" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { createResource } from "solid-js" -import { createStore } from "solid-js/store" -import type { HomeController } from "./home-controller" - -export function createHomeProjectsController(home: HomeController) { - const platform = usePlatform() - const pickDirectory = useDirectoryPicker() - const dialog = useDialog() - const language = useLanguage() - const notification = useNotification() - const openSettings = useSettingsCommand() - const serverManagement = useServerManagementController({ navigateOnAdd: false }) - const [_state, setState, _, ready] = persisted( - Persist.global("home.servers", ["home.servers.v1"]), - createStore({ collapsed: {} as Record }), - ) - const [state] = createResource( - () => ready.promise ?? Promise.resolve(), - (promise) => promise.then(() => _state), - { initialValue: _state }, - ) - function directories(project: LocalProject) { - return [project.worktree, ...(project.sandboxes ?? [])] - } - - function canRevealProject(conn: ServerConnection.Any) { - return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn) - } - - return { - copy: { - language, - }, - selection: { - value: home.selection.value, - }, - server: { - list: home.server.list, - health: home.server.health, - projects: home.project.forServer, - collapsed: (conn: ServerConnection.Any) => state().collapsed[ServerConnection.key(conn)] ?? false, - toggleCollapsed: (conn: ServerConnection.Any) => { - const key = ServerConnection.key(conn) - setState("collapsed", key, !state().collapsed[key]) - }, - canDefault: serverManagement.canDefault, - defaultKey: serverManagement.defaultKey, - setDefault: (conn: ServerConnection.Any | undefined) => - serverManagement.setDefault(conn ? ServerConnection.key(conn) : null), - remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)), - edit: (conn: ServerConnection.Http) => dialog.show(() => ), - focus: home.selection.focusServer, - }, - project: { - list: home.project.list, - recentlyClosed: home.project.recentlyClosed, - homedir: home.project.homedir, - select: home.project.select, - add: home.project.add, - openNewSession: home.project.openProjectNewSession, - edit: (conn: ServerConnection.Any, project: LocalProject) => { - void import("@/components/dialog-edit-project-v2").then(({ DialogEditProjectV2 }) => { - void dialog.show(() => ) - }) - }, - unseenCount: (conn: ServerConnection.Any, project: LocalProject) => { - const state = notification.ensureServerState(ServerConnection.key(conn)) - return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0) - }, - clearNotifications: (conn: ServerConnection.Any, project: LocalProject) => { - const state = notification.ensureServerState(ServerConnection.key(conn)) - directories(project) - .filter((directory) => state.project.unseenCount(directory) > 0) - .forEach((directory) => state.project.markViewed(directory)) - }, - choose: (conn: ServerConnection.Any) => { - if (home.server.health(conn)?.healthy === false) return - pickDirectory({ - server: conn, - title: language.t("command.project.open"), - multiple: true, - onSelect: (result) => home.project.add(conn, homeProjectDirectories(result)), - }) - }, - close: (conn: ServerConnection.Any, directory: string) => { - const next = closeHomeProject( - home.selection.value(), - ServerConnection.key(conn), - home.server.context(conn).projects, - directory, - ) - if (next) home.selection.set(next) - }, - move: (conn: ServerConnection.Any, worktree: string, index: number) => { - home.server.context(conn).projects.move(worktree, index) - }, - canReveal: canRevealProject, - reveal: (conn: ServerConnection.Any, project: LocalProject) => { - if (!platform.openPath || !canRevealProject(conn)) return - platform.openPath(project.worktree).catch((cause: unknown) => - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(cause, language.t("common.requestFailed")), - }), - ) - }, - }, - utility: { - settings: openSettings, - help: () => platform.openExternal("https://opencode.ai/desktop-feedback"), - }, - } -} - -export type HomeProjectsController = ReturnType diff --git a/packages/app/src/pages/home/home-projects-view.tsx b/packages/app/src/pages/home/home-projects-view.tsx deleted file mode 100644 index d80d4a942f..0000000000 --- a/packages/app/src/pages/home/home-projects-view.tsx +++ /dev/null @@ -1,615 +0,0 @@ -import { type Accessor, createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js" -import { createStore } from "solid-js/store" -import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" -import { isSortable, useSortable } from "@dnd-kit/solid/sortable" -import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom" -import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers" -import { RestrictToElement } from "@dnd-kit/dom/modifiers" -import { ScrollView } from "@opencode-ai/ui/scroll-view" -import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" -import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/context/layout" -import { ServerConnection } from "@/context/server" -import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" -import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" -import { ServerRowMenuView, serverMenuLabels } from "@/components/server/server-row-menu" -import { ServerHealthIndicator } from "@/components/server/server-row" -import { type ServerHealth } from "@/utils/server-health" -import { fileManagerApp } from "@/utils/file-manager" - -const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap" - -const serverContextMenuID = (server: ServerConnection.Any) => `server:${ServerConnection.key(server)}` -const projectContextMenuID = (server: ServerConnection.Any, directory: string) => - `project:${ServerConnection.key(server)}:${directory}` - -export type HomeProjectsViewProps = { - language: ReturnType - servers: Accessor - projects: Accessor - recentlyClosed: Accessor - selection: Accessor - homedir: Accessor - serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined - projectsForServer: (server: ServerConnection.Any) => LocalProject[] - collapsed: (server: ServerConnection.Any) => boolean - canDefaultServer: Accessor - defaultServerKey: Accessor - canRevealProject: (server: ServerConnection.Any) => boolean - unseenCount: (server: ServerConnection.Any, project: LocalProject) => number - onWheel: (event: WheelEvent) => void - onChooseProject: (server: ServerConnection.Any) => void - onFocusServer: (server: ServerConnection.Any) => void - onToggleCollapsed: (server: ServerConnection.Any) => void - onEditServer: (server: ServerConnection.Http) => void - onSetDefaultServer: (server: ServerConnection.Any | undefined) => void - onRemoveServer: (server: ServerConnection.Any) => void - onMoveProject: (server: ServerConnection.Any, worktree: string, index: number) => void - onSelectProject: (server: ServerConnection.Any, directory: string) => void - onAddProjects: (server: ServerConnection.Any, directories: string[]) => void - onOpenProjectNewSession: (server: ServerConnection.Any, directory: string) => void - onEditProject: (server: ServerConnection.Any, project: LocalProject) => void - onRevealProject: (server: ServerConnection.Any, project: LocalProject) => void - onClearNotifications: (server: ServerConnection.Any, project: LocalProject) => void - onCloseProject: (server: ServerConnection.Any, directory: string) => void - onOpenSettings: () => void - onOpenHelp: () => void -} - -export function HomeProjectsView(props: HomeProjectsViewProps) { - const [contextMenu, setContextMenu] = createStore({ open: undefined as string | undefined }) - const contextMenuProps = { - contextMenuOpen: (id: string) => contextMenu.open === id, - onSetContextMenuOpen: (id: string, open: boolean) => setContextMenu("open", open ? id : undefined), - } - return ( - - ) -} - -export function HomeUtilityNav(props: { - class?: string - onOpenSettings: () => void - onOpenHelp: () => void - language: ReturnType -}) { - return ( -
- - - {props.language.t("sidebar.settings")} - - - - {props.language.t("sidebar.help")} - -
- ) -} - -function HomeServerRow(props: { - language: HomeProjectsViewProps["language"] - projectsForServer: HomeProjectsViewProps["projectsForServer"] - contextMenuOpen: HomeProjectsContextMenuProps["contextMenuOpen"] - canDefaultServer: HomeProjectsViewProps["canDefaultServer"] - defaultServerKey: HomeProjectsViewProps["defaultServerKey"] - onFocusServer: HomeProjectsViewProps["onFocusServer"] - onToggleCollapsed: HomeProjectsViewProps["onToggleCollapsed"] - onEditServer: HomeProjectsViewProps["onEditServer"] - onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"] - onRemoveServer: HomeProjectsViewProps["onRemoveServer"] - onSetContextMenuOpen: HomeProjectsContextMenuProps["onSetContextMenuOpen"] - onChooseProject: HomeProjectsViewProps["onChooseProject"] - server: ServerConnection.Any - selected: boolean - collapsed: boolean - health: ServerHealth | undefined -}) { - const healthy = () => !!props.health?.healthy - const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0 - const contextMenuID = () => serverContextMenuID(props.server) - onCleanup(() => { - const id = contextMenuID() - if (props.contextMenuOpen(id)) props.onSetContextMenuOpen(id, false) - }) - return ( -
- props.onFocusServer(props.server)} - > - { - event.preventDefault() - event.stopPropagation() - if (!canToggle()) return - props.onToggleCollapsed(props.server) - }} - onPointerDown={(event) => event.preventDefault()} - > - - -
- -
- - {props.server.displayName ?? new URL(props.server.http.url).host} - - {(label) => ( - - {label()} - - )} - - -
-
- props.onSetDefaultServer(props.server)} - onRemoveDefault={() => props.onSetDefaultServer(undefined)} - onRemove={() => props.onRemoveServer(props.server)} - open={props.contextMenuOpen(contextMenuID())} - onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)} - /> - - } - aria-label={props.language.t("home.project.add")} - disabled={props.health?.healthy === false} - onClick={() => props.onChooseProject(props.server)} - /> - -
-
- ) -} - -type HomeProjectsContextMenuProps = { - contextMenuOpen: (id: string) => boolean - onSetContextMenuOpen: (id: string, open: boolean) => void -} - -type HomeProjectListProps = HomeProjectsViewProps & - HomeProjectsContextMenuProps & { - server: ServerConnection.Any - items: LocalProject[] - } - -function HomeProjectList(props: HomeProjectListProps) { - let listRef!: HTMLDivElement - - return ( - [ - ...defaults.filter((sensor) => sensor !== PointerSensor), - PointerSensor.configure({ - activationConstraints: (event) => - event.pointerType === "touch" - ? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })] - : [new PointerActivationConstraints.Distance({ value: 4 })], - preventActivation: (event) => event.target instanceof Element && !!event.target.closest("[data-action]"), - }), - ]} - modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]} - plugins={(defaults) => [ - ...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback), - AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }), - Feedback.configure({ dropAnimation: null }), - ]} - onDragEnd={(event) => { - const source = event.operation.source - if (event.canceled || !isSortable(source)) return - if (source.initialIndex !== source.index) props.onMoveProject(props.server, source.id.toString(), source.index) - if (props.selection().server !== ServerConnection.key(props.server)) - props.onSelectProject(props.server, source.id.toString()) - }} - > -
- {/* Keyed on worktree strings: the enriched project objects are - recreated on every store or sync update, so iterating them directly - remounts all rows — killing any in-flight drag activation (the - row's sortable unregisters on unmount) and discarding animations. - String keys keep row elements alive and move them on reorder. */} - project.worktree)}> - {(worktree, index) => } - -
-
- ) -} - -function HomeProjectSlot( - props: HomeProjectListProps & { - worktree: string - index: () => number - }, -) { - const initial = props.items.find((item) => item.worktree === props.worktree) - if (!initial) return - const project = createMemo( - (previous) => props.items.find((item) => item.worktree === props.worktree) ?? previous, - initial, - ) - - return ( - - ) -} - -function HomeProjectEmpty( - props: HomeProjectsViewProps & { - server: ServerConnection.Any - items: LocalProject[] - }, -) { - const unreachable = () => props.serverHealth(props.server)?.healthy === false - return ( -
- props.onChooseProject(props.server)} - > - - {props.language.t("home.project.add")} - - 0}> -
-
{props.language.t("home.recentlyClosed")}
-
- - {(project) => } - -
-
- ) -} - -function HomeRecentlyClosedRow( - props: HomeProjectsViewProps & { - project: LocalProject - server: ServerConnection.Any - }, -) { - const unreachable = () => props.serverHealth(props.server)?.healthy === false - const path = () => { - const home = props.homedir() - const worktree = props.project.worktree - if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}` - return worktree - } - return ( - - props.onAddProjects(props.server, [props.project.worktree])} - > - - {displayName(props.project)} - - - ) -} - -function HomeProjectRow( - props: HomeProjectsViewProps & - HomeProjectsContextMenuProps & { - project: LocalProject - server: ServerConnection.Any - index: () => number - serverSelected: boolean - selected: boolean - unseen: number - }, -) { - const platform = usePlatform() - const serverUnreachable = () => props.serverHealth(props.server)?.healthy === false - const sortable = useSortable({ - get id() { - return props.project.worktree - }, - get index() { - return props.index() - }, - }) - let pointerDownSelected: boolean | undefined - const contextMenuID = () => projectContextMenuID(props.server, props.project.worktree) - onCleanup(() => { - const id = contextMenuID() - if (props.contextMenuOpen(id)) props.onSetContextMenuOpen(id, false) - }) - return ( -
- { - // Same-server mouse selection happens on pointerdown (like tabs), - // but only ever selects; selectProject toggles, and deselecting here - // would fire on every drag before the threshold is met. Cross-server - // selection waits for click so reordering a remote server's projects - // does not focus that server and load its session index. Touch is - // excluded so flick-scrolling the list cannot select rows. - pointerDownSelected = undefined - if (event.button !== 0 || event.pointerType === "touch") return - if (!props.serverSelected) return - pointerDownSelected = props.selected - if (!props.selected) props.onSelectProject(props.server, props.project.worktree) - }} - onClick={(event) => { - // The drag sensor calls preventDefault on post-drag clicks; never - // toggle selection as part of a reorder. - if (event.defaultPrevented) return - // Keyboard activation and touch taps keep the original toggle. - if (event.detail === 0 || pointerDownSelected === undefined) { - props.onSelectProject(props.server, props.project.worktree) - return - } - // Mouse: pointerdown already selected unselected rows; a plain click - // on an already-selected row toggles it off. - if (pointerDownSelected) props.onSelectProject(props.server, props.project.worktree) - pointerDownSelected = undefined - }} - > - - {displayName(props.project)} - -
- props.onSetContextMenuOpen(contextMenuID(), open)} - > - } - aria-label={props.language.t("common.moreOptions")} - /> - - - props.onOpenProjectNewSession(props.server, props.project.worktree)}> - {props.language.t("command.session.new")} - - props.onEditProject(props.server, props.project)}> - {props.language.t("dialog.project.edit.title")} - - - props.onRevealProject(props.server, props.project)}> - {props.language.t( - fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown") - .actionLabel, - )} - - - props.onClearNotifications(props.server, props.project)} - > - {props.language.t("sidebar.project.clearNotifications")} - - - props.onCloseProject(props.server, props.project.worktree)}> - {props.language.t("common.close")} - - - - - } - aria-label={props.language.t("command.session.new")} - onClick={() => props.onOpenProjectNewSession(props.server, props.project.worktree)} - /> -
-
- ) -} - -function HomeProjectNavButton(props: JSX.ButtonHTMLAttributes) { - const [local, rest] = splitProps(props, ["class", "classList", "children"]) - return ( - - ) -} - -function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) { - const name = createMemo(() => displayName(props.project)) - return ( - - ) -} diff --git a/packages/app/src/pages/home/home-projects.tsx b/packages/app/src/pages/home/home-projects.tsx deleted file mode 100644 index ff2abf7c16..0000000000 --- a/packages/app/src/pages/home/home-projects.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { HomeProjectsController } from "./home-projects-controller" -import { HomeProjectsView } from "./home-projects-view" -import type { HomeScrollController } from "./home-scroll-controller" - -export function HomeProjects(props: { projects: HomeProjectsController; scroll: HomeScrollController }) { - return ( - - ) -} diff --git a/packages/app/src/pages/home/home-scroll-controller.ts b/packages/app/src/pages/home/home-scroll-controller.ts deleted file mode 100644 index 96b0cf46d3..0000000000 --- a/packages/app/src/pages/home/home-scroll-controller.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js" -import { createStore } from "solid-js/store" -import type { HomeSessionGroup } from "./home-sessions-controller" - -const HOME_SESSION_HEADER_STICKY_TOP = 12 -const HOME_SESSION_HEADER_TEXT_HEIGHT = 16 -const HOME_SESSION_HEADER_FADE_DISTANCE = 16 - -export function createHomeScrollController(groups: Accessor) { - const [thumbTrack, setThumbTrack] = createSignal() - const [hoverTarget, setHoverTarget] = createSignal() - const [state, setState] = createStore({ - titleOpacity: {} as Partial>, - }) - const headerRefs = new Map() - const headerOffsets = new Map() - let viewport: HTMLDivElement | undefined - let content: HTMLDivElement | undefined - let positionFrame: number | undefined - let resizeObserver: ResizeObserver | undefined - let stickyTop = HOME_SESSION_HEADER_STICKY_TOP - - createEffect(() => { - const items = groups() - const ids = new Set(items.map((group) => group.id)) - headerRefs.forEach((_, id) => { - if (!ids.has(id)) headerRefs.delete(id) - }) - headerOffsets.forEach((_, id) => { - if (!ids.has(id)) headerOffsets.delete(id) - }) - if (items.length === 0) { - content = undefined - bindResizeObserver() - } - queuePositionUpdate() - }) - - onCleanup(() => { - if (positionFrame !== undefined) cancelAnimationFrame(positionFrame) - resizeObserver?.disconnect() - }) - - function queuePositionUpdate() { - if (typeof requestAnimationFrame === "undefined") { - updatePositionCache() - return - } - if (positionFrame !== undefined) return - positionFrame = requestAnimationFrame(() => { - positionFrame = undefined - updatePositionCache() - }) - } - - function updatePositionCache() { - if (!viewport) return - const header = groups() - .map((group) => headerRefs.get(group.id)) - .find((element) => element !== undefined) - if (header && typeof getComputedStyle === "function") { - const top = Number.parseFloat(getComputedStyle(header).top) - if (Number.isFinite(top)) stickyTop = top - } - groups().forEach((group) => { - const element = headerRefs.get(group.id) - if (element) headerOffsets.set(group.id, element.offsetTop) - }) - update(viewport.scrollTop) - } - - function update(scrollTop: number) { - const items = groups() - items.forEach((group, index) => { - const nextOffset = items - .slice(index + 1) - .map((item) => headerOffsets.get(item.id)) - .find((offset) => offset !== undefined) - const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT - const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop - const opacity = - nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE)) - setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000) - }) - } - - function bindResizeObserver() { - resizeObserver?.disconnect() - if (typeof ResizeObserver === "undefined") return - resizeObserver = new ResizeObserver(queuePositionUpdate) - if (viewport) resizeObserver.observe(viewport) - if (content) resizeObserver.observe(content) - } - - function containWheel(event: WheelEvent) { - if (!viewport) return - if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return - if (!(event.target instanceof Element)) return - const scrollable = event.target.closest("[data-scrollable]") - if ( - scrollable !== viewport && - scrollable && - (event.deltaY < 0 - ? scrollable.scrollTop > 0 - : scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight) - ) - return - event.preventDefault() - } - - return { - viewport: { - thumbTrack, - hoverTarget, - setThumbTrack, - setHoverTarget, - setViewport: (element: HTMLDivElement) => { - viewport = element - bindResizeObserver() - queuePositionUpdate() - }, - update, - containWheel, - containOuterWheel: (event: WheelEvent) => { - if (!viewport) return - if (event.target instanceof Node && viewport.contains(event.target)) return - containWheel(event) - }, - }, - header: { - setContent: (element: HTMLDivElement) => { - content = element - bindResizeObserver() - queuePositionUpdate() - }, - setHeader: (id: HomeSessionGroup["id"], element: HTMLDivElement) => { - headerRefs.set(id, element) - queuePositionUpdate() - }, - titleOpacity: (id: HomeSessionGroup["id"]) => state.titleOpacity[id] ?? 1, - }, - } -} - -export type HomeScrollController = ReturnType diff --git a/packages/app/src/pages/home/home-session-search-controller.ts b/packages/app/src/pages/home/home-session-search-controller.ts deleted file mode 100644 index 5e55c4b746..0000000000 --- a/packages/app/src/pages/home/home-session-search-controller.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { useCommand } from "@/context/command" -import { useLanguage } from "@/context/language" -import { serverName } from "@/context/server" -import { displayName } from "@/pages/layout/helpers" -import { makeEventListener } from "@solid-primitives/event-listener" -import { createMemo, onCleanup } from "solid-js" -import { createStore } from "solid-js/store" -import type { HomeController } from "./home-controller" -import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./home-sessions-controller" - -type HomeSessionSearchSource = Pick - -export function createHomeSessionSearchController(home: HomeController, sessions: HomeSessionSearchSource) { - const command = useCommand() - const language = useLanguage() - const [state, setState] = createStore({ value: "", focused: false, highlighted: "" }) - let root: HTMLDivElement | undefined - let input: HTMLInputElement | undefined - let list: HTMLDivElement | undefined - const query = createMemo(() => state.value.trim()) - const results = createMemo(() => { - const value = query().toLowerCase() - if (!value) return [] - return sessions.data - .searchRecords() - .filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value)) - }) - const active = createMemo(() => { - const records = results() - if (records.some((record) => homeSessionSearchKey(record) === state.highlighted)) return state.highlighted - return records[0] ? homeSessionSearchKey(records[0]) : "" - }) - const open = createMemo(() => state.focused && query().length > 0) - const placeholder = createMemo(() => { - const project = home.project.selected() - if (project) return language.t("home.sessions.search.placeholder.scoped", { scope: displayName(project) }) - if (home.server.list().length > 1) { - const conn = home.server.focused() - if (conn) return language.t("home.sessions.search.placeholder.scoped", { scope: serverName(conn) }) - } - return language.t("home.sessions.search.placeholder") - }) - - onCleanup( - makeEventListener(document, "pointerdown", (event) => { - if (!open()) return - const target = event.target - if (!(target instanceof Node) || root?.contains(target)) return - close() - }), - ) - - command.register("home.search", () => [ - { - id: "home.sessions.search.focus", - title: placeholder(), - keybind: "mod+f", - hidden: true, - onSelect: focus, - }, - ]) - - function focus() { - input?.focus() - setState("focused", true) - } - - function close() { - setState({ value: "", focused: false }) - } - - function select(record: HomeSessionRecord, options?: { background?: boolean }) { - sessions.session.open(record.session, options) - if (!options?.background) close() - } - - return { - query: { - value: () => state.value, - placeholder, - open, - focus, - input: (value: string) => setState({ value, highlighted: "" }), - close, - }, - result: { - loading: sessions.data.loading, - list: results, - active, - noResultsLabel: () => language.t("home.sessions.search.noResults", { query: query() }), - highlight: (record: HomeSessionRecord) => setState("highlighted", homeSessionSearchKey(record)), - move: (delta: number) => { - const records = results() - if (records.length === 0) return - const index = records.findIndex((record) => homeSessionSearchKey(record) === active()) - const next = ((index === -1 ? 0 : index) + delta + records.length) % records.length - setState("highlighted", homeSessionSearchKey(records[next])) - list?.querySelector(`[data-key="${state.highlighted}"]`)?.scrollIntoView({ block: "nearest" }) - }, - select, - selectActive: () => { - const record = results().find((item) => homeSessionSearchKey(item) === active()) - if (record) select(record) - }, - }, - element: { - setRoot: (element: HTMLDivElement) => (root = element), - setInput: (element: HTMLInputElement) => (input = element), - setList: (element: HTMLDivElement) => (list = element), - }, - } -} - -export type HomeSessionSearchController = ReturnType diff --git a/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app/src/pages/home/home-sessions-controller.tsx deleted file mode 100644 index a12e9800e9..0000000000 --- a/packages/app/src/pages/home/home-sessions-controller.tsx +++ /dev/null @@ -1,320 +0,0 @@ -import type { Session } from "@opencode-ai/sdk/v2/client" -import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useMarked } from "@opencode-ai/ui/context/marked" -import { useQuery } from "@tanstack/solid-query" -import { DateTime } from "luxon" -import { type Accessor, createEffect, createMemo, createRoot, type JSX, startTransition } from "solid-js" -import { produce } from "solid-js/store" -import { useCommand } from "@/context/command" -import { - loadHomeSessionIndex, - retainHomeSessions, - type HomeSessionEvents, -} from "@/context/global-sync/home-session-index" -import type { LocalProject } from "@/context/layout" -import { useLanguage } from "@/context/language" -import { ServerConnection } from "@/context/server" -import { sessionHasOpenTab, useTabs } from "@/context/tabs" -import { displayName, errorMessage, projectForSession } from "@/pages/layout/helpers" -import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" -import { pathKey } from "@/utils/path-key" -import { showToast } from "@/utils/toast" -import { Binary } from "@opencode-ai/core/util/binary" -import { archiveHomeSession } from "../home-session-archive" -import type { HomeController } from "./home-controller" - -const HOME_SESSION_LIMIT = 64 -export type HomeSessionRecord = { - session: Session - project: LocalProject - projectName: string -} - -export type HomeSessionGroup = { - id: "today" | "yesterday" | "older" - title: string - sessions: HomeSessionRecord[] -} - -export type OpenSessionOptions = { background?: boolean } - -export function createHomeSessionsController(home: HomeController) { - const tabs = useTabs() - const command = useCommand() - const dialog = useDialog() - const language = useLanguage() - const marked = useMarked() - const projectDirectories = createMemo(() => { - const project = home.project.selected() - if (!project) return home.project.list().flatMap(directories) - return directories(project) - }) - const projectByID = createMemo( - () => new Map(home.project.list().flatMap((project) => (project.id ? [[project.id, project] as const] : []))), - ) - const homeSessions = () => home.server.focusedSync().homeSessions - const sessionEventLoad = useQuery(() => ({ - queryKey: homeSessions().eventsKey, - queryFn: async (): Promise => ({ sequence: 0, entries: [] }), - initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents, - enabled: false, - })) - const sessionLoad = useQuery(() => ({ - queryKey: homeSessions().indexKey, - enabled: !!home.server.focusedContext(), - queryFn: async ({ signal }) => { - const ctx = home.server.focusedContext() - if (!ctx) return { sessions: [], eventSequence: 0 } - const cache = homeSessions() - const eventSequence = cache.eventSequence() - const index = await loadHomeSessionIndex( - (input, options) => ctx.sdk.client.v2.session.list(input, options), - eventSequence, - signal, - ) - cache.complete(eventSequence) - return index - }, - retry: false, - staleTime: 30_000, - refetchOnMount: true, - refetchOnReconnect: true, - })) - const indexedSessions = createMemo(() => - retainHomeSessions( - homeSessions().sessions(sessionLoad.data, sessionEventLoad.data), - HOME_SESSION_LIMIT, - Date.now(), - ), - ) - const allRecords = createMemo(() => - buildHomeSessionRecords({ - sessions: indexedSessions, - projectDirectories, - projects: home.project.list, - projectByID, - }), - ) - const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT)) - const groups = createMemo(() => groupSessions(records(), language)) - const prefetched = new Set() - - createEffect(() => { - const ctx = home.server.focusedContext() - const conn = home.server.focused() - if (!ctx || !conn) return - records() - .slice(0, 2) - .forEach((record) => { - const key = `${ServerConnection.key(conn)}\0${record.session.id}` - if (prefetched.has(key)) return - prefetched.add(key) - createRoot((dispose) => { - try { - void ctx.sync.session - .sync(record.session.id) - .then(() => - Promise.all( - (ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) => - (ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => { - if (part.type !== "text" || !part.text) return [] - return preloadMarkdown(part.text, part.id, marked) - }), - ), - ), - ) - .catch(() => {}) - .finally(dispose) - } catch { - dispose() - } - }) - }) - }) - - command.register("home.palette", () => [ - { - id: "command.palette", - title: language.t("command.palette"), - hidden: true, - onSelect: async () => { - const conn = home.server.focused() - if (!conn) return - const ctx = home.server.focusedContext() - if (!ctx) return - const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2") - void dialog.show(() => ( - { - if (!entry.sessionID || !entry.directory || !entry.server) return - const sessionID = entry.sessionID - const server = entry.server - const directory = entry.project?.worktree ?? entry.directory - ctx.projects.open(directory) - ctx.projects.touch(directory) - void startTransition(() => { - const tab = tabs.addSessionTab({ server, sessionId: sessionID }) - tabs.select(tab) - }) - }} - /> - )) - }, - }, - ]) - - return { - copy: { - language, - }, - data: { - records, - groups, - loading: () => sessionLoad.isLoading, - searchRecords: allRecords, - }, - session: { - showProjectName: () => !home.project.selected(), - server: () => home.selection.value().server, - canCreate: () => !!home.project.newSession(), - create: home.project.openNewSession, - open: (session: Session, options?: OpenSessionOptions) => { - const directoryKey = pathKey(session.directory) - const project = - home.project - .list() - .find( - (item) => - pathKey(item.worktree) === directoryKey || - item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey), - ) ?? projectForSession(session, home.project.list(), projectByID()) - const conn = home.server.focused() - if (!conn) return - const directory = project?.worktree ?? session.directory - const ctx = home.server.focusedContext() - if (!ctx) return - ctx.projects.open(directory) - if (options?.background) { - tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) - return - } - ctx.projects.touch(directory) - void startTransition(() => { - const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) - tabs.select(tab) - }) - }, - archive: async (session: Session) => { - const conn = home.server.focused() - const ctx = home.server.focusedContext() - if (!conn || !ctx) return - const [, setStore] = ctx.sync.child(session.directory) - if ((await ctx.sdk.protocol) !== "v1") return - await archiveHomeSession({ - server: ServerConnection.key(conn), - session, - archive: (sessionID) => - ctx.sdk.client.session.update({ - sessionID, - directory: session.directory, - time: { archived: Date.now() }, - }), - remove: () => - setStore( - produce((draft) => { - const match = Binary.search(draft.session, session.id, (item) => item.id) - if (match.found) draft.session.splice(match.index, 1) - }), - ), - onError: (cause) => - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(cause, language.t("common.requestFailed")), - }), - }) - }, - }, - tab: { - isOpen: (record: HomeSessionRecord) => - sessionHasOpenTab(tabs.store, home.selection.value().server, record.session), - }, - } -} - -function directories(project: LocalProject) { - return [project.worktree, ...(project.sandboxes ?? [])] -} - -function buildHomeSessionRecords(input: { - sessions: () => Session[] - projectDirectories: () => string[] - projects: () => LocalProject[] - projectByID: () => Map -}) { - const directories = new Set(input.projectDirectories().map(pathKey)) - const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory))) - return [...new Map(sessions.map((session) => [session.id, session] as const)).values()] - .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) - .flatMap((session) => { - const directory = pathKey(session.directory) - const project = - input - .projects() - .find( - (item) => - pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory), - ) ?? projectForSession(session, input.projects(), input.projectByID()) - if (!project) return [] - return { session, project, projectName: displayName(project) } - }) -} - -export function homeSessionSearchKey(record: HomeSessionRecord) { - return `${pathKey(record.session.directory)}:${record.session.id}` -} - -function groupSessions(records: HomeSessionRecord[], language: ReturnType): HomeSessionGroup[] { - const now = DateTime.local() - const yesterday = now.minus({ days: 1 }) - const todaySessions = records.filter((record) => - DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"), - ) - const yesterdaySessions = records.filter((record) => - DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"), - ) - const olderSessions = records.filter((record) => { - const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created) - return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day") - }) - const olderTitle = - todaySessions.length === 0 && yesterdaySessions.length === 0 - ? language.t("sidebar.project.recentSessions") - : language.t("home.sessions.group.older") - return [ - { id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions }, - { id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions }, - { id: "older" as const, title: olderTitle, sessions: olderSessions }, - ].filter((group) => group.sessions.length > 0) -} - -export type HomeSessionsController = ReturnType - -export function HomeSessionStatusController(props: { - server: Accessor - record: HomeSessionRecord - isOpenTab: (record: HomeSessionRecord) => boolean - render: (state: { unread: Accessor; loading: Accessor; open: Accessor }) => JSX.Element -}) { - const avatar = useSessionTabAvatarState( - props.server, - () => props.record.session.directory, - () => props.record.session.id, - ) - return props.render({ - unread: avatar.unread, - loading: avatar.loading, - open: () => props.isOpenTab(props.record), - }) -} diff --git a/packages/app/src/pages/home/home-sessions-view.tsx b/packages/app/src/pages/home/home-sessions-view.tsx deleted file mode 100644 index 72f2f37616..0000000000 --- a/packages/app/src/pages/home/home-sessions-view.tsx +++ /dev/null @@ -1,550 +0,0 @@ -import type { Session } from "@opencode-ai/sdk/v2/client" -import { type Accessor, createMemo, For, Show, Suspense } from "solid-js" -import { Spinner } from "@opencode-ai/ui/spinner" -import { ScrollView } from "@opencode-ai/ui/scroll-view" -import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { useLanguage } from "@/context/language" -import { ServerConnection } from "@/context/server" -import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar" -import { sessionTitle } from "@/utils/session-title" -import { shouldOpenSessionInBackground } from "../home-session-open" -import { - HomeSessionStatusController, - homeSessionSearchKey, - type HomeSessionGroup, - type HomeSessionRecord, - type OpenSessionOptions, -} from "./home-sessions-controller" - -const SHOW_HOME_SESSION_ARCHIVE = false -const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]" -const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results" - -// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session -// tab in the background without navigating, matching browser conventions. -function isBackgroundOpen(event: MouseEvent) { - return shouldOpenSessionInBackground({ - button: event.button, - mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform), - meta: event.metaKey, - ctrl: event.ctrlKey, - shift: event.shiftKey, - alt: event.altKey, - }) -} - -export type HomeSessionsViewProps = { - language: ReturnType - groups: Accessor - showProjectName: Accessor - server: Accessor - canCreateSession: Accessor - searchValue: Accessor - searchPlaceholder: Accessor - searchOpen: Accessor - searchLoading: Accessor - searchResults: Accessor - searchActive: Accessor - searchNoResultsLabel: Accessor - titleOpacity: (id: HomeSessionGroup["id"]) => number - isOpenTab: (record: HomeSessionRecord) => boolean - onCreateSession: () => void - onOpenSession: (session: Session, options?: OpenSessionOptions) => void - onArchiveSession: (session: Session) => Promise - onSetHoverTarget: (element: HTMLElement) => void - onSetThumbTrack: (element: HTMLDivElement) => void - onSetContent: (element: HTMLDivElement) => void - onSetHeader: (id: HomeSessionGroup["id"], element: HTMLDivElement) => void - onWheel: (event: WheelEvent) => void - onSetSearchRoot: (element: HTMLDivElement) => void - onSetSearchInput: (element: HTMLInputElement) => void - onSetSearchList: (element: HTMLDivElement) => void - onSearchFocus: () => void - onSearchInput: (value: string) => void - onSearchClose: () => void - onSearchMove: (delta: number) => void - onSearchSelectActive: () => void - onSearchHighlight: (record: HomeSessionRecord) => void - onSearchSelect: (record: HomeSessionRecord, options?: OpenSessionOptions) => void -} - -export function HomeSessionsView(props: HomeSessionsViewProps) { - return ( -
-
- - - 0 && props.canCreateSession()}> -
- - {props.language.t("command.session.new")} - -
-
-
-
- -
- ) -} - -function HomeSessionLeadingController(props: { - server: HomeSessionsViewProps["server"] - isOpenTab: HomeSessionsViewProps["isOpenTab"] - record: HomeSessionRecord - revealProjectOnHover: boolean -}) { - return ( - ( - - )} - /> - ) -} - -function HomeSessionLeading(props: { - record: HomeSessionRecord - revealProjectOnHover: boolean - open: boolean - unread: boolean - loading: boolean -}) { - return ( -
- - - -
- ) -} - -function HomeSessionSearch(props: HomeSessionsViewProps) { - return ( -
-
- -
-
-
- - -
- } - > - 0} - fallback={ -

- {props.searchNoResultsLabel()} -

- } - > -
-

- {props.language.t("home.sessions.search.sessions")} -

- -
- - {(record) => ( - - )} - -
-
-
-
- -
-
-
- - -
-
- ) -} - -function HomeSessionSearchResultRow( - props: HomeSessionsViewProps & { - record: HomeSessionRecord - selected: boolean - }, -) { - const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) - const showProjectName = () => props.showProjectName() && props.record.projectName - const key = () => homeSessionSearchKey(props.record) - - return ( - - ) -} - -function HomeSessionGroupHeader(props: { - title: string - titleOpacity: number - onSetRef: (element: HTMLDivElement) => void - elevated?: boolean -}) { - return ( -
- -
- ) -} - -function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) { - const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) - const showProjectName = () => props.showProjectName() && props.record.projectName - - return ( -
- - -
- - } - aria-label={props.language.t("common.archive")} - onClick={(event) => { - event.preventDefault() - event.stopPropagation() - void props.onArchiveSession(props.record.session) - }} - /> - -
-
-
- ) -} - -function HomeSessionTitle(props: { title: string; showProjectName: boolean; search?: boolean }) { - return ( - - {props.title} - - ) -} - -function HomeSessionProjectName(props: { name: string; search?: boolean }) { - return ( - - {props.name} - - ) -} - -function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnType }) { - return ( -
-
- {props.language.t("home.sessions.empty")} -
-

- {props.language.t("home.sessions.empty.description")} -

- - {(onNewSession) => ( - - {props.language.t("command.session.new")} - - )} - -
- ) -} - -function HomeSessionSkeleton(props: { label: string }) { - return ( -
-
- -
- - ) -} diff --git a/packages/app/src/pages/home/home-sessions.tsx b/packages/app/src/pages/home/home-sessions.tsx deleted file mode 100644 index 7bda4dd363..0000000000 --- a/packages/app/src/pages/home/home-sessions.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import type { HomeScrollController } from "./home-scroll-controller" -import type { HomeSessionSearchController } from "./home-session-search-controller" -import type { HomeSessionsController } from "./home-sessions-controller" -import { HomeSessionsView } from "./home-sessions-view" - -export function HomeSessions(props: { - sessions: HomeSessionsController - search: HomeSessionSearchController - scroll: HomeScrollController -}) { - return ( - - ) -} diff --git a/packages/app/src/pages/home/legacy-home.tsx b/packages/app/src/pages/home/legacy-home.tsx deleted file mode 100644 index 0556f7cf60..0000000000 --- a/packages/app/src/pages/home/legacy-home.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { DialogSelectServer } from "@/components/dialog-select-server" -import { useDirectoryPicker } from "@/components/directory-picker" -import { useGlobal } from "@/context/global" -import { useLanguage } from "@/context/language" -import { type ServerConnection, useServer } from "@/context/server" -import { useServerSync } from "@/context/server-sync" -import { base64Encode } from "@opencode-ai/core/util/encode" -import { Button } from "@opencode-ai/ui/button" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { Icon } from "@opencode-ai/ui/icon" -import { Logo } from "@opencode-ai/ui/logo" -import { useNavigate } from "@solidjs/router" -import { DateTime } from "luxon" -import { createMemo, For, Match, Switch } from "solid-js" - -export function LegacyHome() { - const sync = useServerSync() - const pickDirectory = useDirectoryPicker() - const dialog = useDialog() - const navigate = useNavigate() - const global = useGlobal() - const server = useServer() - const language = useLanguage() - const homedir = createMemo(() => sync().data.path.home) - const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false) - const recent = createMemo(() => { - return sync() - .data.project.slice() - .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) - .slice(0, 5) - }) - - const serverDotClass = createMemo(() => { - const healthy = global.servers.health[server.key]?.healthy - if (healthy === true) return "bg-icon-success-base" - if (healthy === false) return "bg-icon-critical-base" - return "bg-border-weak-base" - }) - - function openProject(conn: ServerConnection.Any, directory: string) { - const serverCtx = global.ensureServerCtx(conn) - serverCtx.projects.open(directory) - serverCtx.projects.touch(directory) - navigate(`/${base64Encode(directory)}`) - } - - function chooseProject() { - if (serverUnreachable()) return - const conn = server.current - if (!conn) return - - const resolve = (result: string | string[] | null) => { - if (Array.isArray(result)) { - result.forEach((directory) => openProject(conn, directory)) - return - } - if (result) openProject(conn, result) - } - - pickDirectory({ - server: conn, - title: language.t("command.project.open"), - multiple: true, - onSelect: resolve, - }) - } - - return ( -
- - - - 0}> -
-
-
{language.t("home.recentProjects")}
- -
-
    - - {(project) => ( - - )} - -
-
-
- -
-
{language.t("common.loading")}
- -
-
- -
- -
-
{language.t("home.empty.title")}
-
{language.t("home.empty.description")}
-
- -
-
-
-
- ) -} diff --git a/packages/app/src/pages/layout-new.tsx b/packages/app/src/pages/layout-new.tsx index 6d76ca8401..329b7c56b0 100644 --- a/packages/app/src/pages/layout-new.tsx +++ b/packages/app/src/pages/layout-new.tsx @@ -1,14 +1,16 @@ import { createEffect, Suspense, type ParentProps } from "solid-js" -import { createStore } from "solid-js/store" +import { useNavigate } from "@solidjs/router" import { DebugBar } from "@/components/debug-bar" -import { TabsInfoPopup } from "@/components/help-button" +import { HelpButton } from "@/components/help-button" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { usePlatform } from "@/context/platform" +import { setNavigate } from "@/utils/notification-click" import { setV2Toast, ToastRegion } from "@/utils/toast" export default function NewLayout(props: ParentProps) { const platform = usePlatform() - const [state, setState] = createStore({ debugTools: true }) + const navigate = useNavigate() + setNavigate(navigate) createEffect(() => setV2Toast(true)) @@ -30,19 +32,12 @@ export default function NewLayout(props: ParentProps) { "padding-bottom": "env(safe-area-inset-bottom, 0px)", }} > - setState("debugTools", (value) => !value) } - : undefined - } - /> +
{props.children}
- {import.meta.env.DEV && state.debugTools && } - + {import.meta.env.DEV && } +
) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 963a542f88..690460d3af 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -33,9 +33,9 @@ import { createStore, produce, reconcile } from "solid-js/store" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" import { useProviders } from "@/hooks/use-providers" -import { dismissToast, setV2Toast, showToast, ToastRegion } from "@/utils/toast" +import { toaster } from "@opencode-ai/ui/toast" +import { setV2Toast, showToast, ToastRegion } from "@/utils/toast" import { useServerSDK } from "@/context/server-sdk" -import { normalizeProjectInfo } from "@/context/global-sync/utils" import { clearWorkspaceTerminals } from "@/context/terminal" import { pickSessionCacheEvictions } from "@/context/global-sync/session-cache" import { useNotification } from "@/context/notification" @@ -44,17 +44,17 @@ import { Binary } from "@opencode-ai/core/util/binary" import { retry } from "@opencode-ai/core/util/retry" import { playSoundById } from "@/utils/sound" import { createAim } from "@/utils/aim" +import { setNavigate } from "@/utils/notification-click" import { Worktree as WorktreeState } from "@/utils/worktree" import { setSessionHandoff } from "@/pages/session/handoff" import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope" -import { listAllSessions } from "@/utils/session" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd" import { DebugBar } from "@/components/debug-bar" -import { TabsInfoPopup } from "@/components/help-button" +import { HelpButton } from "@/components/help-button" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { useDirectoryPicker } from "@/components/directory-picker" import { ServerConnection, useServer } from "@/context/server" @@ -116,7 +116,8 @@ export default function LegacyLayout(props: ParentProps) { const notification = useNotification() const permission = usePermission() const navigate = useNavigate() - const providers = useProviders(() => undefined) + setNavigate(navigate) + const providers = useProviders() const dialog = useDialog() const command = useCommand() const theme = useTheme() @@ -155,7 +156,6 @@ export default function LegacyLayout(props: ParentProps) { sizing: false, peek: undefined as string | undefined, peeked: false, - debugTools: true, }) const updateVersion = () => { @@ -381,7 +381,7 @@ export default function LegacyLayout(props: ParentProps) { const dismissSessionAlert = (sessionKey: string) => { const toastId = toastBySession.get(sessionKey) if (toastId === undefined) return - dismissToast(toastId) + toaster.dismiss(toastId) toastBySession.delete(sessionKey) alertedAtBySession.delete(sessionKey) } @@ -446,13 +446,13 @@ export default function LegacyLayout(props: ParentProps) { void playSoundById(settings.sounds.permissions()) } if (settings.notifications.permissions()) { - void platform.notify(title, description, () => navigate(href)) + void platform.notify(title, description, href) } } if (e.details.type === "question.asked") { if (settings.notifications.agent()) { - void platform.notify(title, description, () => navigate(href)) + void platform.notify(title, description, href) } } @@ -869,15 +869,14 @@ export default function LegacyLayout(props: ParentProps) { } async function archiveSession(session: Session) { - if ((await serverSDK().protocol) !== "v1") return const [store, setStore] = serverSync().child(session.directory) const sessions = store.session ?? [] const index = sessions.findIndex((s) => s.id === session.id) const nextSession = sessions[index + 1] ?? sessions[index - 1] await serverSDK().client.session.update({ - sessionID: session.id, directory: session.directory, + sessionID: session.id, time: { archived: Date.now() }, }) setStore( @@ -1096,9 +1095,9 @@ export default function LegacyLayout(props: ParentProps) { function connectProvider() { const run = ++dialogRun - void import("@/components/dialog-connect-provider").then((x) => { + void import("@/components/dialog-select-provider").then((x) => { if (dialogDead || dialogRun !== run) return - void dialog.show(() => ) + dialog.show(() => ) }) } @@ -1185,12 +1184,9 @@ export default function LegacyLayout(props: ParentProps) { } const refreshDirs = async (target?: string) => { if (!target || target === root || canOpen(target)) return canOpen(target) - const listed = await Promise.resolve( - project?.id ?? serverSDK().api.project.current({ location: { directory: root } }), - ) - .then((value) => (typeof value === "string" ? value : value.id)) - .then((projectID) => serverSDK().api.project.directories({ projectID, location: { directory: root } })) - .then((items) => items.map((item) => item.directory).filter((item) => pathKey(item) !== pathKey(root))) + const listed = await serverSDK() + .client.worktree.list({ directory: root }) + .then((x) => x.data ?? []) .catch(() => [] as string[]) dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root]) return canOpen(target) @@ -1234,11 +1230,10 @@ export default function LegacyLayout(props: ParentProps) { await Promise.all( dirs.map(async (item) => ({ path: { directory: item }, - session: await listAllSessions(serverSDK().api.session, { - directory: item, - parentID: null, - order: "desc", - }).catch(() => []), + session: await serverSDK() + .client.session.list({ directory: item }) + .then((x) => x.data ?? []) + .catch(() => []), })), ), Date.now(), @@ -1298,16 +1293,7 @@ export default function LegacyLayout(props: ParentProps) { const name = next === getFilename(project.worktree) ? "" : next if (project.id && project.id !== "global") { - const sdk = serverSDK() - if ((await sdk.protocol) !== "v1") return - const result = await sdk.client.project - .update({ projectID: project.id, directory: project.worktree, name }) - .then((response) => response.data) - if (!result) return - // const result = await serverSDK().api.project.update({ projectID: project.id, name }) - serverSync().set("project", (items) => - items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)), - ) + await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name }) return } @@ -1456,9 +1442,12 @@ export default function LegacyLayout(props: ParentProps) { title: language.t("workspace.resetting.title"), description: language.t("workspace.resetting.description"), }) - const dismiss = () => dismissToast(progress) + const dismiss = () => toaster.dismiss(progress) - const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => []) + const sessions: Session[] = await serverSDK() + .client.session.list({ directory }) + .then((x) => x.data ?? []) + .catch(() => []) clearWorkspaceTerminals( directory, @@ -1487,20 +1476,20 @@ export default function LegacyLayout(props: ParentProps) { return } - if ((await serverSDK().protocol) === "v1") - await Promise.all( - sessions - .filter((session) => session.time.archived === undefined) - .map((session) => - serverSDK() - .client.session.update({ - sessionID: session.id, - directory: session.directory, - time: { archived: Date.now() }, - }) - .catch(() => undefined), - ), - ) + const archivedAt = Date.now() + await Promise.all( + sessions + .filter((session) => session.time.archived === undefined) + .map((session) => + serverSDK() + .client.session.update({ + sessionID: session.id, + directory: session.directory, + time: { archived: archivedAt }, + }) + .catch(() => undefined), + ), + ) setBusy(directory, false) dismiss() @@ -1534,9 +1523,9 @@ export default function LegacyLayout(props: ParentProps) { onMount(() => { serverSDK() - .api.vcs.status({ location: { directory: props.directory } }) - .then((result) => { - const files = result.data + .client.vcs.status({ directory: props.directory }) + .then((x) => { + const files = x.data ?? [] const dirty = files.length > 0 setData({ status: "ready", dirty }) }) @@ -1592,19 +1581,19 @@ export default function LegacyLayout(props: ParentProps) { }) const refresh = async () => { - const sessions = await listAllSessions(serverSDK().api.session, { - directory: props.directory, - order: "desc", - }).catch(() => []) + const sessions = await serverSDK() + .client.session.list({ directory: props.directory }) + .then((x) => x.data ?? []) + .catch(() => []) const active = sessions.filter((session) => session.time.archived === undefined) setState({ sessions: active }) } onMount(() => { serverSDK() - .api.vcs.status({ location: { directory: props.directory } }) - .then((result) => { - const files = result.data + .client.vcs.status({ directory: props.directory }) + .then((x) => { + const files = x.data ?? [] const dirty = files.length > 0 setState({ status: "ready", dirty }) void refresh() @@ -2249,7 +2238,7 @@ export default function LegacyLayout(props: ParentProps) { settingsKeybind={() => command.keybind("settings.open")} onOpenSettings={openSettings} helpLabel={() => language.t("sidebar.help")} - onOpenHelp={() => platform.openExternal("https://opencode.ai/desktop-feedback")} + onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} renderPanel={() => mobile ? : } @@ -2259,14 +2248,7 @@ export default function LegacyLayout(props: ParentProps) { return (
{autoselecting() ?? ""} - setState("debugTools", (value) => !value) } - : undefined - } - /> + @@ -2411,9 +2393,9 @@ export default function LegacyLayout(props: ParentProps) {
- {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && state.debugTools && } + {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && }
- +
) @@ -2447,7 +2429,7 @@ function UpdateAvailableToast(props: { onCleanup(() => { if (toastId === undefined) return - dismissToast(toastId) + toaster.dismiss(toastId) }) return null diff --git a/packages/app/src/pages/layout/project-avatar-state.ts b/packages/app/src/pages/layout/project-avatar-state.ts index 236f6bd405..b7abd2480e 100644 --- a/packages/app/src/pages/layout/project-avatar-state.ts +++ b/packages/app/src/pages/layout/project-avatar-state.ts @@ -1,49 +1,29 @@ import { createMemo, type Accessor } from "solid-js" -import { useGlobal } from "@/context/global" +import { useServerSync } from "@/context/server-sync" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" -import { sessionPermissionRequest, sessionQuestionRequest } from "@/pages/session/composer/session-request-tree" -import { ServerConnection } from "@/context/server" +import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree" export function useSessionTabAvatarState( - server: Accessor, directory: Accessor, sessionId: Accessor, + active: Accessor = () => true, ) { - const global = useGlobal() + const globalSync = useServerSync() const notification = useNotification() const permission = usePermission() - const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server())) - const sync = createMemo(() => { - const conn = connection() - if (conn) return global.ensureServerCtx(conn).sync - }) const hasPermissions = createMemo(() => { - const serverSync = sync() - if (!serverSync) return false - const permissionState = permission.ensureServerState(server()) - const [store] = serverSync.child(directory(), { bootstrap: false }) - return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => { - return !permissionState.autoResponds(item, directory()) + if (!active()) return false + const [store] = globalSync().child(directory(), { bootstrap: false }) + return !!sessionPermissionRequest(store.session, globalSync().session.data.permission, sessionId(), (item) => { + return !permission.autoResponds(item, directory()) }) }) - const hasQuestions = createMemo(() => { - const serverSync = sync() - if (!serverSync) return false - const [store] = serverSync.child(directory(), { bootstrap: false }) - return !!sessionQuestionRequest(store.session, serverSync.session.data.question, sessionId()) - }) - const needsAttention = createMemo(() => hasPermissions() || hasQuestions()) - const notificationState = createMemo(() => { - if (!connection()) return - return notification.ensureServerState(server()) - }) - const unread = createMemo(() => needsAttention() || (notificationState()?.session.unseenCount(sessionId()) ?? 0) > 0) + const unread = createMemo(() => active() && (hasPermissions() || notification.session.unseenCount(sessionId()) > 0)) const loading = createMemo(() => { - const serverSync = sync() - if (!serverSync) return false - if (needsAttention()) return false - return serverSync.session.data.session_working(sessionId()) + if (!active()) return false + if (hasPermissions()) return false + return globalSync().session.data.session_working(sessionId()) }) return { unread, loading } } diff --git a/packages/app/src/pages/layout/session-tab-avatar.tsx b/packages/app/src/pages/layout/session-tab-avatar.tsx index 0902173643..9124f3216f 100644 --- a/packages/app/src/pages/layout/session-tab-avatar.tsx +++ b/packages/app/src/pages/layout/session-tab-avatar.tsx @@ -1,6 +1,5 @@ import type { LocalProject } from "@/context/layout" import { getProjectAvatarVariant } from "@/context/layout" -import type { ServerConnection } from "@/context/server" import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" @@ -11,42 +10,22 @@ export function SessionTabAvatar(props: { project?: LocalProject directory: string sessionId: string - server: ServerConnection.Key + activeServer: boolean revealProjectOnHover?: boolean }) { - const state = useSessionTabAvatarState( - () => props.server, - () => props.directory, - () => props.sessionId, - ) - return ( - - ) -} - -export function SessionTabAvatarView(props: { - project?: LocalProject - directory: string - revealProjectOnHover?: boolean - unread: boolean - loading: boolean -}) { + const directory = () => props.directory + const sessionId = () => props.sessionId + const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer) const projectAvatar = () => ( ) return ( - + () + + useComposerCommands() + useSettingsCommand() + + let inputRef: HTMLDivElement | undefined + + const inputController = createPromptInputController({ + sessionKey: route.sessionKey, + sessionID: () => route.params.id, + queryOptions: serverSync().queryOptions, }) - const project = createPromptProjectController({ - controls: draft.project.controls, - onDone: draft.input.restoreFocus, + const projectControls = createPromptProjectControls() + const projectController = createPromptProjectController({ + controls: projectControls, + onDone: () => inputRef?.focus(), }) - useNewSessionCommands({ - restoreFocus: draft.input.restoreFocus, - project: { - empty: project.empty, - open: () => project.setOpen(true), - }, + + const [store, setStore] = createStore<{ worktree?: string }>({}) + + const newSessionWorktree = createMemo(() => { + if (store.worktree) return store.worktree + const project = sync().project + if (project && sdk().directory !== project.worktree) return sdk().directory + return "main" }) + const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory) + const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch) + const selectedBranch = createMemo(() => { + const worktree = newSessionWorktree() + if (worktree === "main" || worktree === "create") return localBranch() + return serverSync().child(worktree)[0].vcs?.branch ?? localBranch() + }) + createEffect(() => { - if (!draft.prompt.ready()) return - draft.input.restoreFocus() + if (!prompt.ready()) return + untrack(() => { + const text = searchParams.prompt + if (!text) return + prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + setSearchParams({ ...searchParams, prompt: undefined }) + }) + }) + + createEffect(() => { + if (!prompt.ready()) return + requestAnimationFrame(() => inputRef?.focus()) }) const ready = Promise.resolve() - const [suspendUntilPromptReady] = createResource( - () => draft.prompt.readyPromise() ?? ready, + const [promptReady] = createResource( + () => prompt.ready.promise ?? ready, (promise) => promise.then(() => true), ) return (
- {suspendUntilPromptReady()} -
- +
+
+ +
+ + {language.t("prompt.loading")} +
+ } + > +
+ { + inputRef = el + }} + newSessionWorktree={newSessionWorktree()} + onNewSessionWorktreeReset={() => setStore("worktree", undefined)} + onSubmit={() => comments.clear()} + toolbar={ + + + + } + /> + +
+ + + + setStore( + "worktree", + value === "main" && sync().project?.worktree !== sdk().directory + ? sync().project?.worktree + : value, + ) + } + onDone={() => inputRef?.focus()} + /> + +
+
+
+ +
+ +
+
) diff --git a/packages/app/src/pages/new-session/new-session-draft-controller.ts b/packages/app/src/pages/new-session/new-session-draft-controller.ts deleted file mode 100644 index bf22834e48..0000000000 --- a/packages/app/src/pages/new-session/new-session-draft-controller.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { useSearchParams } from "@solidjs/router" -import { createEffect, untrack } from "solid-js" -import { usePromptInputV2Controller } from "@/components/prompt-input-v2" -import { useComments } from "@/context/comments" -import { useLocal } from "@/context/local" -import { usePrompt } from "@/context/prompt" -import { useServerSync } from "@/context/server-sync" -import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer" -import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection" -import { useSessionKey } from "@/pages/session/session-layout" -import { useComposerCommands } from "@/pages/session/use-composer-commands" - -export function createNewSessionDraftController(workspace: { worktree: () => string; resetWorktree: () => void }) { - const prompt = usePrompt() - const serverSync = useServerSync() - const comments = useComments() - const local = useLocal() - const route = useSessionKey() - const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() - const model = createPromptModelSelection({ agent: () => local.agent.current() }) - - useComposerCommands({ model }) - - const controls = createPromptInputController({ - sessionKey: route.sessionKey, - sessionID: () => route.params.id, - queryOptions: serverSync().queryOptions, - model, - }) - const projectControls = createPromptProjectControls() - const input = usePromptInputV2Controller({ - get controls() { - return controls() - }, - get newSessionWorktree() { - return workspace.worktree() - }, - onNewSessionWorktreeReset: workspace.resetWorktree, - onSubmit: comments.clear, - }) - - createEffect(() => { - if (!prompt.ready()) return - untrack(() => { - const text = searchParams.prompt - if (!text) return - prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) - setSearchParams({ ...searchParams, prompt: undefined }) - }) - }) - - return { - input, - prompt: { - ready: prompt.ready, - readyPromise: () => prompt.ready.promise, - }, - project: { - controls: projectControls, - }, - } -} - -export type NewSessionDraftController = ReturnType diff --git a/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app/src/pages/new-session/new-session-view.tsx deleted file mode 100644 index 5960e64335..0000000000 --- a/packages/app/src/pages/new-session/new-session-view.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { Tooltip } from "@opencode-ai/ui/tooltip" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2" -import { Show, createMemo, createSignal, type Accessor } from "solid-js" -import { createStore } from "solid-js/store" -import { Portal } from "solid-js/web" -import createPresence from "solid-presence" -import { PromptInputV2Composer } from "@/components/prompt-input-v2" -import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector" -import { - PromptProjectAddButton, - PromptProjectSelector, - type PromptProjectController, -} from "@/components/prompt-project-selector" -import { StatusPopoverV2 } from "@/components/status-popover" -import { useLanguage } from "@/context/language" -import { useSDK } from "@/context/sdk" -import { useServerSync } from "@/context/server-sync" -import { useProviders } from "@/hooks/use-providers" -import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" -import { Persist, persisted } from "@/utils/persist" -import type { NewSessionDraftController } from "./new-session-draft-controller" -import type { NewSessionWorkspaceController } from "./new-session-workspace-controller" - -const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000 - -export function NewSessionView(props: { - input: NewSessionDraftController["input"] - project: PromptProjectController - workspace: NewSessionWorkspaceController -}) { - return ( -
-
-
-
- -
- - - - - -
- - - } - > - - -
-
-
-
-
- -
-
- ) -} - -export function NewSessionStatus(props: { mount: Accessor; visible: Accessor }) { - const language = useLanguage() - - return ( - - {(mount) => ( - - - - - - - - )} - - ) -} - -function ProviderTip() { - const language = useLanguage() - const dialog = useDialog() - const sdk = useSDK() - const serverSync = useServerSync() - const providers = useProviders(() => sdk().directory) - const [persistedState, setPersistedState, , persistedReady] = persisted( - Persist.global("new-session.provider-tip"), - createStore({ dismissedAt: 0 }), - ) - const visible = createMemo( - () => - serverSync().child(sdk().directory)[0].provider_ready && - persistedReady() && - providers.paid().length === 0 && - Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration, - ) - const [ref, setRef] = createSignal() - const presence = createPresence({ - show: visible, - element: () => ref() ?? null, - }) - const openProviders = () => { - void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => { - void dialog.show(() => sdk().directory} />) - }) - } - - return ( - -
-
- - - - -
-
-
- ) -} diff --git a/packages/app/src/pages/new-session/new-session-workspace-controller.test.ts b/packages/app/src/pages/new-session/new-session-workspace-controller.test.ts deleted file mode 100644 index 2a79ae77fa..0000000000 --- a/packages/app/src/pages/new-session/new-session-workspace-controller.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { - normalizeNewSessionWorktree, - resolveNewSessionBranch, - resolveNewSessionWorktree, -} from "./new-session-workspace-controller" - -describe("new session workspace selection", () => { - test("uses main when the workspace bar is unavailable", () => { - expect( - resolveNewSessionWorktree({ - enabled: false, - selected: "/project/feature", - directory: "/project/feature", - projectWorktree: "/project", - }), - ).toBe("main") - }) - - test("derives an existing worktree from the current directory", () => { - expect( - resolveNewSessionWorktree({ enabled: true, directory: "/project/feature", projectWorktree: "/project" }), - ).toBe("/project/feature") - expect(resolveNewSessionWorktree({ enabled: true, directory: "/project", projectWorktree: "/project" })).toBe( - "main", - ) - }) - - test("normalizes main to the project root outside the main worktree", () => { - expect(normalizeNewSessionWorktree("main", "/project/feature", "/project")).toBe("/project") - expect(normalizeNewSessionWorktree("main", "/project", "/project")).toBe("main") - }) - - test("falls back to the local branch for main, create, and unknown worktrees", () => { - const branch = (worktree: string) => (worktree === "/project/feature" ? "feature" : undefined) - expect(resolveNewSessionBranch({ worktree: "main", local: "dev", worktreeBranch: branch })).toBe("dev") - expect(resolveNewSessionBranch({ worktree: "create", local: "dev", worktreeBranch: branch })).toBe("dev") - expect(resolveNewSessionBranch({ worktree: "/project/feature", local: "dev", worktreeBranch: branch })).toBe( - "feature", - ) - expect(resolveNewSessionBranch({ worktree: "/missing", local: "dev", worktreeBranch: branch })).toBe("dev") - }) -}) diff --git a/packages/app/src/pages/new-session/new-session-workspace-controller.ts b/packages/app/src/pages/new-session/new-session-workspace-controller.ts deleted file mode 100644 index f3fc9b2708..0000000000 --- a/packages/app/src/pages/new-session/new-session-workspace-controller.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { createMemo, createSignal } from "solid-js" -import { useSDK } from "@/context/sdk" -import { useServerSync } from "@/context/server-sync" -import { useSync } from "@/context/sync" - -const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" - -export function resolveNewSessionWorktree(input: { - enabled: boolean - selected?: string - directory: string - projectWorktree?: string -}) { - if (!input.enabled) return "main" - if (input.selected) return input.selected - if (input.projectWorktree && input.directory !== input.projectWorktree) return input.directory - return "main" -} - -export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) { - if (value === "main" && projectWorktree !== directory) return projectWorktree - return value -} - -export function resolveNewSessionBranch(input: { - worktree: string - local?: string - worktreeBranch: (worktree: string) => string | undefined -}) { - if (input.worktree === "main" || input.worktree === "create") return input.local - return input.worktreeBranch(input.worktree) ?? input.local -} - -export function createNewSessionWorkspaceController() { - const sdk = useSDK() - const sync = useSync() - const serverSync = useServerSync() - const [worktree, setWorktree] = createSignal() - const visible = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git") - const value = createMemo(() => - resolveNewSessionWorktree({ - enabled: visible(), - selected: worktree(), - directory: sdk().directory, - projectWorktree: sync().project?.worktree, - }), - ) - const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory) - const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch) - const branch = createMemo(() => - resolveNewSessionBranch({ - worktree: value(), - local: localBranch(), - worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch, - }), - ) - - return { - selection: { - value, - reset: () => setWorktree(), - set: (worktree: string) => - setWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)), - }, - project: { - root: projectRoot, - workspaces: () => sync().project?.sandboxes ?? [], - git: () => sync().project?.vcs === "git", - }, - bar: { - visible, - branch, - }, - } -} - -export type NewSessionWorkspaceController = ReturnType diff --git a/packages/app/src/pages/new-session/use-new-session-commands.tsx b/packages/app/src/pages/new-session/use-new-session-commands.tsx deleted file mode 100644 index a0d835f97f..0000000000 --- a/packages/app/src/pages/new-session/use-new-session-commands.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useSettingsCommand } from "@/components/settings-dialog" -import { useCommand } from "@/context/command" -import { useLanguage } from "@/context/language" - -export function useNewSessionCommands(input: { - restoreFocus: () => void - project: { - empty: () => boolean - open: () => void - } -}) { - const command = useCommand() - const dialog = useDialog() - const language = useLanguage() - - useSettingsCommand() - command.register("new-session", () => [ - { - id: "command.palette", - title: language.t("command.palette"), - hidden: true, - onSelect: async () => { - const { DialogSelectFile } = await import("@/components/dialog-select-file") - void dialog.show(() => ) - }, - }, - { - id: "input.focus", - title: language.t("command.input.focus"), - category: language.t("command.category.view"), - keybind: "ctrl+l", - onSelect: input.restoreFocus, - }, - { - id: "project.select", - title: language.t("session.new.project.search"), - category: language.t("command.category.project"), - keybind: "mod+shift+o", - disabled: input.project.empty(), - onSelect: input.project.open, - }, - ]) -} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index c6e3a5fcbb..36be14546b 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,19 +1,17 @@ -import type { FilePart, Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" -import { getFilename } from "@opencode-ai/core/util/path" +import type { Project, UserMessage } from "@opencode-ai/sdk/v2" import { useDialog } from "@opencode-ai/ui/context/dialog" import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query" import { batch, ErrorBoundary, onCleanup, - Suspense, Show, Match, Switch, createMemo, createEffect, createComputed, - createSignal, + createResource, on, onMount, type ParentProps, @@ -26,11 +24,8 @@ import { debounce } from "@solid-primitives/scheduled" import { useLocal } from "@/context/local" import { FileProvider, selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { createStore } from "solid-js/store" -import type { SessionReviewLineComment } from "@opencode-ai/session-ui/session-review" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Select } from "@opencode-ai/ui/select" -import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" -import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view" import { Tabs } from "@opencode-ai/ui/tabs" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { createAutoScroll } from "@opencode-ai/ui/hooks" @@ -38,17 +33,17 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b import { Button } from "@opencode-ai/ui/button" import { showToast } from "@/utils/toast" import { base64Encode, checksum } from "@opencode-ai/core/util/encode" -import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router" +import { Navigate, useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router" import { NewSessionView, SessionHeader } from "@/components/session" import { ErrorPage } from "@/pages/error" import { CommentsProvider, useComments } from "@/context/comments" -import { useCommand } from "@/context/command" import { DirectoryDataProvider } from "@/pages/directory-layout" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { ModelsProvider } from "@/context/models" import { useNotification } from "@/context/notification" +import { PermissionProvider } from "@/context/permission" import { PromptProvider, usePrompt } from "@/context/prompt" import { usePlatform } from "@/context/platform" import { SDKProvider, useSDK } from "@/context/sdk" @@ -59,10 +54,7 @@ import { useSync } from "@/context/sync" import { useTabs } from "@/context/tabs" import { TerminalProvider, useTerminal } from "@/context/terminal" import { PromptInput } from "@/components/prompt-input" -import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2" import { useSettingsCommand } from "@/components/settings-dialog" -import { setCursorPosition } from "@/components/prompt-input/editor-dom" -import { promptLength } from "@/components/prompt-input/history" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { createPromptInputController, @@ -70,27 +62,21 @@ import { createSessionComposerRegionController, SessionComposerRegion, } from "@/pages/session/composer" -import { createOpenReviewFile, createSessionTabs, createSizing, shouldShowFileTree } from "@/pages/session/helpers" +import { + createOpenReviewFile, + createSessionTabs, + createSizing, + focusTerminalById, + shouldFocusTerminalOnKeyDown, + shouldShowFileTree, +} from "@/pages/session/helpers" import { MessageTimeline } from "@/pages/session/timeline/message-timeline" import { createTimelineModel } from "@/pages/session/timeline/model" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { useSessionLayout } from "@/pages/session/session-layout" -import { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers" -import { - clampSessionPanelWidth, - SESSION_PANEL_WIDTH_MIN, - sessionPanelWidthMax, -} from "@/pages/session/session-panel-width" +import { syncSessionModel } from "@/pages/session/session-model-helpers" import { SessionSidePanel } from "@/pages/session/session-side-panel" -import { sessionPanelLayout } from "@/pages/session/session-panel-layout" -import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2" -import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2" -import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2" -import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2" -import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" -import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds" import { TerminalPanel } from "@/pages/session/terminal-panel" -import { TerminalPanelV2 } from "@/pages/session/terminal-panel-v2" import { useComposerCommands } from "@/pages/session/use-composer-commands" import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" @@ -98,11 +84,10 @@ import { Identifier } from "@/utils/id" import { diffs as list } from "@/utils/diffs" import { Persist, persisted } from "@/utils/persist" import { extractPromptFromParts } from "@/utils/prompt" -import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors" -import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" +import { formatServerError, isSessionNotFoundError } from "@/utils/server-errors" +import { legacySessionHref, requireServerKey, selectSessionLineage, sessionHref } from "@/utils/session-route" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" import { createSessionOwnership } from "./session/session-ownership" -import { createSessionLineage } from "./session/session-lineage" type FollowupItem = FollowupDraft & { id: string } type FollowupEdit = Pick @@ -114,8 +99,13 @@ type VcsMode = "git" | "branch" const sessionViewState = () => ({ messageId: undefined as string | undefined, mobileTab: "session" as "session" | "changes", + changes: "git" as ChangeMode, }) +function isLocalSessionNotFoundError(error: unknown, sessionID: string) { + return error instanceof Error && error.message === `Session not found: ${sessionID}` +} + function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) { if (!sessionID) return false return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID) @@ -152,32 +142,18 @@ export function SessionPage() { ) } -// Rendered under app.tsx's TargetSessionRoute, which owns the per-server keyed -// remount around the server-scoped providers. Nothing here may key on the -// session ID: session tabs on the same server share this route instance, and -// workspace-scoped state (terminal, directory providers) lives below. -export function TargetSessionRouteContent() { +export function TargetSessionRoute() { const params = useParams<{ serverKey: string; id: string }>() - const serverSync = useServerSync() - const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory) return ( - // Settings must keep the target-server SDK, sync, and models context and remain registered - // when session content falls back to the route error boundary. - params.id}> - + - + ) } -function TargetSessionSettingsCommand() { - useSettingsCommand() - return null -} - -export function SessionRouteErrorBoundary( +function SessionRouteErrorBoundary( props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>, ) { const settings = useSettings() @@ -245,13 +221,19 @@ function SessionErrorFallback(props: { error: unknown; sessionID?: string; serve function ResolvedTargetSessionRoute() { const params = useParams<{ serverKey: string; id: string }>() + const settings = useSettings() const tabs = useTabs() const sync = useServerSync() const serverKey = createMemo(() => requireServerKey(params.serverKey)) - const current = createSessionLineage( - () => params.id, - () => sync().session.lineage, + const cached = createMemo(() => sync().session.lineage.peek(params.id)) + const [resolved] = createResource( + () => { + if (cached()) return + return { id: params.id, sync: sync() } + }, + ({ id, sync }) => sync.session.lineage.resolve(id), ) + const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved())) const directory = createMemo(() => current()?.session.directory) const targetDirectory = () => directory()! @@ -265,23 +247,23 @@ function ResolvedTargetSessionRoute() { }) return ( - // Non-keyed: closes only while the target's directory is unknown (uncached - // lineage mid-resolution), which tears down the workspace subtree including - // the terminal. Same-workspace tab switches keep it open because warm - // targets resolve synchronously from the sync cache. - - - - - - - + params.id}> + + } + > + + + + + + + + ) } -// Owns the workspace-identity remount. Must not include the session ID in the -// key: SessionPage handles session changes reactively, and remounting here -// destroys workspace-scoped state (terminal PTYs, file/prompt providers). function TargetSessionPage() { const sdk = useSDK() const serverSDK = useServerSDK() @@ -296,10 +278,10 @@ function TargetServerScopedProviders( props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>, ) { return ( - <> + {props.children} - + ) } @@ -365,14 +347,11 @@ export default function Page() { const platform = usePlatform() const prompt = usePrompt() const comments = useComments() - const command = useCommand() const terminal = useTerminal() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const location = useLocation() const navigate = useNavigate() const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() - const reviewMode = () => view().review.mode() ?? "git" - const reviewFile = () => view().review.file() const sessionOwnership = createSessionOwnership(sessionKey) const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns()) @@ -406,7 +385,6 @@ export default function Page() { }) const workspaceTabs = createMemo(() => layout.tabs(workspaceKey)) - const sessionPanelKey = createMemo(() => (params.id ? `${serverSDK().scope}\0${params.id}` : undefined)) createEffect( on( @@ -448,12 +426,6 @@ export default function Page() { const isDesktop = createMediaQuery("(min-width: 768px)") const size = createSizing() const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) - const desktopV2ReviewOpen = createMemo(() => newSessionDesign() && desktopReviewOpen() && !!params.id) - const terminalOpen = createMemo(() => view().terminal.opened()) - const desktopTerminalOpen = createMemo(() => isDesktop() && terminalOpen()) - const desktopInlineTerminalOnlyOpen = createMemo( - () => newSessionDesign() && desktopTerminalOpen() && !desktopV2ReviewOpen(), - ) const desktopFileTreeOpen = createMemo( () => isDesktop() && @@ -462,53 +434,13 @@ export default function Page() { opened: layout.fileTree.opened(), }), ) - const desktopSessionResizeOpen = createMemo(() => - newSessionDesign() ? desktopV2ReviewOpen() || desktopTerminalOpen() : desktopReviewOpen(), - ) - const desktopSidePanelOpen = createMemo(() => desktopSessionResizeOpen() || desktopFileTreeOpen()) - let panelRow: HTMLDivElement | undefined - const [panelRowWidth, setPanelRowWidth] = createSignal() - createResizeObserver( - () => panelRow, - ({ width }) => setPanelRowWidth(width), - ) - const splitReview = createMemo( - () => (newSessionDesign() ? desktopV2ReviewOpen() : desktopReviewOpen()) && layout.review.diffStyle() === "split", - ) - // The observer reports the content-box width, which already excludes the row - // padding; only the flex gap between the panels remains to subtract. - const sessionPanelAvailable = createMemo(() => { - const width = panelRowWidth() - if (width === undefined) return undefined - return width - (settings.general.newLayoutDesigns() ? 8 : 0) - }) - const sessionPanelMax = createMemo(() => { - const available = sessionPanelAvailable() - if (available === undefined) return 1000 - return sessionPanelWidthMax({ available, split: splitReview() }) - }) - // Clamp at render time so window or sidebar resizes squeeze the chat panel - // instead of the review pane, without overwriting the persisted width. - const sessionPanelResizedWidth = createMemo(() => - clampSessionPanelWidth({ - width: layout.session.width(), - available: sessionPanelAvailable(), - split: splitReview(), - }), - ) + const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen()) const sessionPanelWidth = createMemo(() => { if (!desktopSidePanelOpen()) return "100%" - if (desktopSessionResizeOpen()) return `${sessionPanelResizedWidth()}px` + if (desktopReviewOpen()) return `${layout.session.width()}px` return `calc(100% - ${layout.fileTree.width()}px)` }) - const centered = createMemo(() => isDesktop() && (newSessionDesign() || !desktopReviewOpen())) - const desktopV2PanelLayout = createMemo(() => - sessionPanelLayout({ - review: desktopV2ReviewOpen(), - terminal: desktopTerminalOpen(), - files: desktopFileTreeOpen(), - }), - ) + const centered = createMemo(() => isDesktop() && !desktopReviewOpen()) function normalizeTab(tab: string) { if (!tab.startsWith("file://")) return tab @@ -533,6 +465,7 @@ export default function Page() { const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) const isChildSession = createMemo(() => !!info()?.parentID) + const diffs = createMemo(() => (params.id ? list(sync().data.session_diff[params.id]) : [])) const canReview = createMemo(() => !!sync().project) const reviewTab = createMemo(() => isDesktop()) const tabState = createSessionTabs({ @@ -574,17 +507,6 @@ export default function Page() { ), ) - let restoredModelSession: string | undefined - createEffect(() => { - const id = params.id - if (!id || !prompt.ready() || !local.session.ready()) return - if (restoredModelSession !== id) { - restoredModelSession = id - if (restorePromptModel(local, prompt)) return - } - syncPromptModel(local, prompt) - }) - createEffect( on( () => ({ dir: sdk().directory, id: params.id }), @@ -668,13 +590,11 @@ export default function Page() { const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") const wantsReview = createMemo(() => isDesktop() - ? desktopFileTreeOpen() || - (desktopReviewOpen() && (activeTab() === "review" || (newSessionDesign() && !!activeFileTab()))) + ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") : store.mobileTab === "changes", ) const vcsMode = createMemo(() => { - const mode = reviewMode() - if (mode === "git" || mode === "branch") return mode + if (store.changes === "git" || store.changes === "branch") return store.changes }) const vcsKey = createMemo( () => @@ -690,8 +610,8 @@ export default function Page() { queryFn: mode ? () => sdk() - .api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode }) - .then((result) => result.data) + .client.vcs.diff({ mode }) + .then((result) => list(result.data)) .catch((error) => { console.debug("[session-review] failed to load vcs diff", { mode, error }) return [] @@ -701,67 +621,17 @@ export default function Page() { }) const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100) const reviewDiffs = () => { - if (reviewMode() === "git" || reviewMode() === "branch") + if (store.changes === "git" || store.changes === "branch") // avoids suspense return vcsQuery.isFetched ? (vcsQuery.data ?? []) : [] return turnDiffs() } - const activeReviewFile = () => { - const diffs = reviewDiffs() - const selected = reviewFile() - if (selected && diffs.some((diff) => diff.file === selected)) return selected - return diffs[0]?.file - } const reviewCount = () => reviewDiffs().length const hasReview = () => reviewCount() > 0 const reviewReady = () => { - if (reviewMode() === "git" || reviewMode() === "branch") return !vcsQuery.isPending + if (store.changes === "git" || store.changes === "branch") return !vcsQuery.isPending return true } - const loadReviewDiff = async (file: string, version?: number): Promise => { - const mode = vcsMode() - if (!mode) return - const root = reviewRootDirectory(sync().project?.worktree ?? sdk().directory) - const directory = reviewDiffDirectory(root, file) - const source = reviewDiffs().find((diff) => diff.file === file) - const valid = (diff: VcsFileDiff | undefined) => { - if (!diff || !source) return - if (diff.additions !== source.additions || diff.deletions !== source.deletions) return - if (reviewDiffNeedsLoad(diff)) return - return diff - } - const request = (scope: string, context?: number) => - queryClient - .fetchQuery({ - queryKey: [serverSDK().scope, ...vcsKey(), mode, "directory", scope, context, version] as const, - staleTime: Number.POSITIVE_INFINITY, - retry: 2, - queryFn: () => - sdk() - .api.vcs.diff({ - location: { directory: scope }, - mode: mode === "git" ? "working" : mode, - context, - }) - .then((result) => result.data), - }) - .then((diffs) => diffs.find((diff) => diff.file === file)) - - if (directory !== root) { - try { - const scoped = valid(await request(directory)) - if (scoped) return scoped - } catch (error) { - console.debug("[session-review] failed to load scoped vcs diff", { mode, file, directory, error }) - } - } - try { - const bounded = valid(await request(root, 3)) - if (bounded) return bounded - } catch (error) { - console.debug("[session-review] failed to load bounded vcs diff", { mode, file, root, error }) - } - } const newSessionWorktree = createMemo(() => { if (store.newSessionWorktree === "create") return "create" @@ -950,11 +820,10 @@ export default function Page() { ) const stopVcs = sdk().event.listen((evt) => { - const details = evt.details as { type: string; properties?: unknown } - if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return + if (evt.details.type !== "file.watcher.updated") return const props = - typeof details.properties === "object" && details.properties - ? (details.properties as Record) + typeof evt.details.properties === "object" && evt.details.properties + ? (evt.details.properties as Record) : undefined const file = typeof props?.file === "string" ? props.file : undefined if (!file || file.startsWith(".git/")) return @@ -1065,33 +934,31 @@ export default function Page() { return } - const key = scrollKey(event) - if (key) { - if (!scroller || !isScrollKeyTarget(target ?? null, key)) return - if (scrollKeyOwner(scroller, target ?? null, key) !== scroller) return - markScrollGesture(scroller) + // Prefer the open terminal over the composer when it can take focus + if (view().terminal.opened()) { + const id = terminal.active() + if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return + } + + // Only treat explicit scroll keys as potential "user scroll" gestures. + if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") { + markScrollGesture() return } if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { if (composer.blocked() || isChildSession()) return - const input = inputRef - if (!input) return - input.focus() - setCursorPosition(input, prompt.cursor() ?? promptLength(prompt.current())) + inputRef?.focus() } } createEffect(() => { - if (!layout.ready()) return - if (sync().status !== "complete") return if (!sync().project) return const list = changesOptions() - const mode = reviewMode() - if (list.includes(mode)) return + if (list.includes(store.changes)) return const next = list[0] if (!next) return - view().review.setMode(next) + setStore("changes", next) }) createEffect( @@ -1111,6 +978,7 @@ export default function Page() { const [tree, setTree] = createStore({ reviewScroll: undefined as HTMLDivElement | undefined, pendingDiff: undefined as string | undefined, + activeDiff: undefined as string | undefined, }) createEffect( @@ -1120,6 +988,7 @@ export default function Page() { setTree({ reviewScroll: undefined, pendingDiff: undefined, + activeDiff: undefined, }) }, { defer: true }, @@ -1137,21 +1006,13 @@ export default function Page() { } useComposerCommands() + useSettingsCommand() useSessionCommands({ navigateMessageByOffset, setActiveMessage, focusInput, review: reviewTab, - fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id, }) - command.register("session-palette", () => [ - { - id: "command.palette", - title: language.t("command.palette"), - hidden: true, - onSelect: () => command.trigger("file.open", "palette"), - }, - ]) const openReviewFile = createOpenReviewFile({ showAllFiles, @@ -1161,44 +1022,26 @@ export default function Page() { loadFile: file.load, }) - const changesLabel = (option: ChangeMode) => { - if (option === "git") return language.t("ui.sessionReview.title.git") - if (option === "branch") return language.t("ui.sessionReview.title.branch") - return language.t("ui.sessionReview.title.lastTurn") - } - const changesTitle = () => { if (!canReview()) { return null } - return ( -