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/compliance-close.yml b/.github/workflows/compliance-close.yml index a83824e5cf..a6e4b9215f 100644 --- a/.github/workflows/compliance-close.yml +++ b/.github/workflows/compliance-close.yml @@ -35,37 +35,13 @@ jobs: const now = Date.now(); const twoHours = 2 * 60 * 60 * 1000; const orgMemberAssociations = new Set(['OWNER', 'MEMBER']); - const agentLogin = 'opencode-agent[bot]'; - const { data: file } = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: '.github/TEAM_MEMBERS', - ref: 'dev', - }); - const teamMembers = new Set( - Buffer.from(file.content, 'base64') - .toString() - .split('\n') - .map((line) => line.trim().toLowerCase()) - .filter(Boolean) - ); - - function isExempt(item) { - const login = item.user?.login?.toLowerCase(); - return ( - login === agentLogin || - orgMemberAssociations.has(item.author_association) || - (login && teamMembers.has(login)) - ); - } for (const item of items) { const isPR = !!item.pull_request; const kind = isPR ? 'PR' : 'issue'; - const login = item.user?.login; - if (isExempt(item)) { - core.info(`Skipping ${kind} #${item.number}; author ${login || 'unknown'} is exempt`); + if (orgMemberAssociations.has(item.author_association)) { + core.info(`Skipping ${kind} #${item.number}; author association is ${item.author_association}`); try { await github.rest.issues.removeLabel({ owner: context.repo.owner, diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml index 3972247daf..b4500e0cc1 100644 --- a/.github/workflows/duplicate-issues.yml +++ b/.github/workflows/duplicate-issues.yml @@ -17,31 +17,12 @@ jobs: with: fetch-depth: 1 - - name: Check exempt issue author - id: author - run: | - LOGIN="${{ github.event.issue.user.login }}" - ASSOCIATION="${{ github.event.issue.author_association }}" - - if [ "$LOGIN" = "opencode-agent[bot]" ] || - [ "$ASSOCIATION" = "OWNER" ] || - [ "$ASSOCIATION" = "MEMBER" ] || - grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then - echo "skip=true" >> "$GITHUB_OUTPUT" - echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)" - else - echo "skip=false" >> "$GITHUB_OUTPUT" - fi - - uses: ./.github/actions/setup-bun - if: steps.author.outputs.skip != 'true' - name: Install opencode - if: steps.author.outputs.skip != 'true' run: curl -fsSL https://opencode.ai/install | bash - name: Check duplicates and compliance - if: steps.author.outputs.skip != 'true' env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -151,31 +132,12 @@ jobs: with: fetch-depth: 1 - - name: Check exempt issue author - id: author - run: | - LOGIN="${{ github.event.issue.user.login }}" - ASSOCIATION="${{ github.event.issue.author_association }}" - - if [ "$LOGIN" = "opencode-agent[bot]" ] || - [ "$ASSOCIATION" = "OWNER" ] || - [ "$ASSOCIATION" = "MEMBER" ] || - grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then - echo "skip=true" >> "$GITHUB_OUTPUT" - echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)" - else - echo "skip=false" >> "$GITHUB_OUTPUT" - fi - - uses: ./.github/actions/setup-bun - if: steps.author.outputs.skip != 'true' - name: Install opencode - if: steps.author.outputs.skip != 'true' run: curl -fsSL https://opencode.ai/install | bash - name: Recheck compliance - if: steps.author.outputs.skip != 'true' env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1c41a66faa..037020c03a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -219,6 +219,7 @@ jobs: build-electron: needs: + - build-cli - version if: github.repository == 'anomalyco/opencode' continue-on-error: false @@ -315,7 +316,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 +343,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 }} diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index 0350e43877..27852a12ce 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -16,32 +16,13 @@ jobs: with: fetch-depth: 1 - - name: Check exempt issue author - id: author - run: | - LOGIN="${{ github.event.issue.user.login }}" - ASSOCIATION="${{ github.event.issue.author_association }}" - - if [ "$LOGIN" = "opencode-agent[bot]" ] || - [ "$ASSOCIATION" = "OWNER" ] || - [ "$ASSOCIATION" = "MEMBER" ] || - grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then - echo "skip=true" >> "$GITHUB_OUTPUT" - echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)" - else - echo "skip=false" >> "$GITHUB_OUTPUT" - fi - - name: Setup Bun - if: steps.author.outputs.skip != 'true' uses: ./.github/actions/setup-bun - name: Install opencode - if: steps.author.outputs.skip != 'true' run: curl -fsSL https://opencode.ai/install | bash - name: Triage issue - if: steps.author.outputs.skip != 'true' env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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/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/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..ab6d6929ae 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.11", "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,7 +92,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.18.11", + "version": "1.17.11", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -142,23 +138,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.11", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -194,7 +176,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.18.11", + "version": "1.17.11", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -221,7 +203,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.18.11", + "version": "1.17.11", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -243,7 +225,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.18.11", + "version": "1.17.11", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -267,7 +249,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.18.11", + "version": "1.17.11", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -287,7 +269,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.18.11", + "version": "1.17.11", "bin": { "opencode": "./bin/opencode", }, @@ -295,7 +277,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,15 +285,15 @@ "@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:", @@ -339,7 +321,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.12.1", + "gitlab-ai-provider": "6.9.3", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -352,7 +334,7 @@ "npm-package-arg": "13.0.2", "semver": "^7.6.3", "turndown": "7.2.0", - "venice-ai-sdk-provider": "2.1.1", + "venice-ai-sdk-provider": "2.0.2", "which": "6.0.1", "xdg-basedir": "5.1.0", "zod": "catalog:", @@ -381,16 +363,16 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.18.11", + "version": "1.17.11", "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 +417,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.18.11", + "version": "1.17.11", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -449,7 +431,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.18.11", + "version": "1.17.11", "dependencies": { "effect": "catalog:", }, @@ -461,7 +443,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.11", + "version": "1.17.11", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -493,7 +475,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.18.11", + "version": "1.17.11", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -509,7 +491,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.18.11", + "version": "1.17.11", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -540,7 +522,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.18.11", + "version": "1.17.11", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -559,7 +541,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.18.11", + "version": "1.17.11", "bin": { "opencode": "./bin/opencode", }, @@ -570,22 +552,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 +578,6 @@ "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", - "@opencode-ai/codemode": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -632,7 +613,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.12.1", + "gitlab-ai-provider": "6.9.3", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -656,7 +637,7 @@ "tree-sitter-powershell": "0.25.10", "turndown": "7.2.0", "ulid": "catalog:", - "venice-ai-sdk-provider": "2.1.1", + "venice-ai-sdk-provider": "2.0.2", "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "ws": "8.21.0", @@ -690,7 +671,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.18.11", + "version": "1.17.11", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -707,9 +688,9 @@ "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", @@ -766,7 +747,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.18.11", + "version": "1.17.11", "dependencies": { "cross-spawn": "catalog:", }, @@ -781,7 +762,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.18.11", + "version": "1.17.11", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -796,10 +777,9 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.11", + "version": "1.17.11", "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 +791,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 +821,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.18.11", + "version": "1.17.11", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -853,10 +834,9 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.11", + "version": "1.17.11", "dependencies": { "@ibm/plex": "6.4.1", - "@kobalte/core": "catalog:", "@opencode-ai/stats-core": "workspace:*", "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -887,7 +867,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.11", + "version": "1.17.11", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -906,7 +886,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.11", + "version": "1.17.11", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -938,7 +918,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,7 +927,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.18.11", + "version": "1.17.11", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -975,7 +954,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.11", + "version": "1.17.11", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1001,7 +980,6 @@ "remend": "catalog:", "shiki": "catalog:", "solid-list": "catalog:", - "solid-sonner": "catalog:", "strip-ansi": "7.1.2", }, "devDependencies": { @@ -1027,7 +1005,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.18.11", + "version": "1.17.11", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1070,19 +1048,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 +1070,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 +1080,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 +1093,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 +1113,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 +1122,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 +1155,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 +1165,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 +1179,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 +1195,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 +1451,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 +1923,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 +2013,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 +2649,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 +2753,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 +2773,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=="], @@ -3054,7 +3009,7 @@ "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], - "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "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=="], @@ -3258,7 +3213,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=="], @@ -3850,13 +3805,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.9.3", "", { "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-lWo6b6es5+k9iXaDIvE9ECzyK4zfEza4+dQ5FN8SJpEuVRi3ZBCpHIOTa32QoYEDCBaiPh+tcyca86PfNodmlg=="], "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 +4273,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=="], @@ -5114,26 +5069,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 +5303,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=="], @@ -5470,7 +5429,7 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.1.1", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.51", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.30" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-w3OHkuzzKZ3r2TOxER6myBYzZJNoDqol+DUHu3NnfBN/GETnUVxecZJab0CHQQ8GZc0jjzpFymepjcLDPS4SQg=="], + "venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.0.2", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.47", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.27" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-aoa05nI3BTK5aGbjBflq+Gfln2AHAkwNbWuGGvCzUIsOfp5Y3iPD4O4PUGDAEiWVJWbjpPn0KfDa0H/HebwsaA=="], "verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], @@ -5614,6 +5573,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 +5623,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 +5635,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 +5663,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 +5677,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 +5691,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,12 +5899,8 @@ "@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=="], @@ -6034,26 +5979,20 @@ "@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/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=="], - "@opencode-ai/core/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "@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=="], "@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], - "@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 +6001,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=="], @@ -6180,16 +6117,8 @@ "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 +6147,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=="], @@ -6310,8 +6237,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=="], @@ -6376,8 +6301,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 +6333,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=="], @@ -6420,8 +6341,6 @@ "opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], - "opencode/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], @@ -6488,8 +6407,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 +6425,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=="], @@ -6528,24 +6443,20 @@ "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=="], "unzipper/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], - "venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.32" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SoPSkrL5cbNQnAljRsJ7pOzJ2FmWgnhC0lfFOda873ycCdFJL1A+h3Ib7mX2spcv3XnNaO13y/45/0RyqNWlIQ=="], + "venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="], "venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - "venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.32", "", { "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-Kwj499fTcN9bP/AfGoPU7JWIXeP6VZqKI6omsH062c9E2G4gdjeJczkz4z/tYSkzYjLE2AI3DtZbMfs6D7vn2Q=="], + "venice-ai-sdk-provider/@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=="], "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 +6537,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=="], @@ -6920,10 +6829,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=="], @@ -6970,8 +6875,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=="], @@ -6994,18 +6897,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=="], @@ -7150,16 +7041,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=="], @@ -7192,10 +7073,6 @@ "unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - "venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="], - - "venice-ai-sdk-provider/@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="], - "venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -7408,8 +7285,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=="], @@ -7426,12 +7301,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 +7369,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=="], 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..5809bf7ff3 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-JXQ9PAqqRlJtHa8T3ZxqdRRyxC+0ip+2wSnehvKXUbI=", + "aarch64-linux": "sha256-nI+RaxDXmAcjhSjCtIvyi292xBEg0E5NXaoyGrJE69s=", + "aarch64-darwin": "sha256-UleKpm7Khf3kibm4BqZJ9bmu0N2kOjNd77g1Bd2tmfw=", + "x86_64-darwin": "sha256-V4AQH868dOfVhEj1mKTnZlhpZwFqBYsHS28Tx9VXHkE=" } } diff --git a/package.json b/package.json index 2a8eef6356..b9622583a7 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,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 +39,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 +68,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 +88,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 +104,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 +141,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..401fb74496 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, }) } @@ -49,7 +45,7 @@ export async function installStressSessionTabs(page: Page, input?: { draftID?: s }), ) localStorage.setItem( - "opencode.window.browser.dat:tabs", + "opencode.global.dat:tabs", JSON.stringify([ ...sessionIDs.map((sessionId) => ({ type: "session", @@ -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..031440f166 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" @@ -15,7 +14,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn 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", + "opencode.global.dat:tabs", JSON.stringify([ { type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA }, { type: "session", server: serverB, sessionId: sessionB }, @@ -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) @@ -53,7 +52,7 @@ test("legacy session routes preserve an existing tab's server", async ({ page }) 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", + "opencode.global.dat:tabs", JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]), ) }, @@ -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..d12684c143 100644 --- a/packages/app/e2e/regression/prompt-thinking-level.spec.ts +++ b/packages/app/e2e/regression/prompt-thinking-level.spec.ts @@ -54,16 +54,19 @@ 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() - const high = page.getByRole("menuitemradio", { name: "high" }) + await control.locator('[data-action="prompt-model-variant"]').click() + const high = page.getByRole("option", { name: "high" }) await expect(high).toBeVisible() await page.mouse.move(0, 0) await expect(control).toBeVisible() 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..e66cf81504 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" @@ -39,35 +38,9 @@ test("shows a pending question dock", async ({ page }) => { await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible() await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0) - 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()) - }) - - await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() - await expect(question).toBeVisible() - await expect(question.getByText("Which implementation should be used?")).toBeVisible() - await expect(question.getByText("Select one answer")).toBeHidden() - await expect(question.getByRole("radio", { name: /Minimal/ })).toBeHidden() - await expect(question.getByRole("radio", { name: /Extended/ })).toBeHidden() - await expect(question.getByRole("button", { name: "Dismiss" })).toBeVisible() - await expect(question.getByRole("button", { name: "Submit" })).toBeVisible() - await expect(page.locator('[data-component="question-minimized-dock"]')).toHaveCount(0) - expect(rejectRequests).toEqual([]) - - await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() - await expect(question).toBeVisible() - await expect(question.getByText("Which implementation should be used?")).toBeVisible() - await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible() - expect(rejectRequests).toEqual([]) - 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 +73,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 +85,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..fd0b00f71c 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() @@ -149,7 +145,7 @@ async function configurePage(page: Page) { }), ) localStorage.setItem( - "opencode.window.browser.dat:tabs", + "opencode.global.dat:tabs", JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))), ) }, 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..5bca533e6a 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -127,7 +127,7 @@ test.describe("smoke: session timeline", () => { await page.addInitScript( ({ dirBase64, sourceID, targetID }) => { localStorage.setItem( - "opencode.window.browser.dat:tabs", + "opencode.global.dat:tabs", JSON.stringify( [sourceID, targetID].map((sessionId) => ({ type: "session", @@ -253,7 +253,7 @@ test.describe("smoke: session timeline", () => { await page.addInitScript( ({ dirBase64, sourceID, targetID }) => { localStorage.setItem( - "opencode.window.browser.dat:tabs", + "opencode.global.dat:tabs", JSON.stringify( [sourceID, targetID].map((sessionId) => ({ type: "session", @@ -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..52420afedc 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.11", "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..5b9223b5ee 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,23 +28,22 @@ 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 { CommandProvider } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file" -import { ServerSDKProvider } from "@/context/server-sdk" +import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk" import { ServerSyncProvider, useServerSync } from "@/context/server-sync" import { GlobalProvider, useGlobal } from "@/context/global" import { HighlightsProvider } from "@/context/highlights" import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LayoutProvider } from "@/context/layout" import { ModelsProvider } from "@/context/models" -import { NotificationProvider } from "@/context/notification" +import { NotificationProvider, useNotification } from "@/context/notification" import { PermissionProvider } from "@/context/permission" -import { usePlatform } from "@/context/platform" import { PromptProvider } from "@/context/prompt" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { SettingsProvider, useSettings } from "@/context/settings" +import { TerminalProvider } from "@/context/terminal" import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" import { SDKProvider, useSDK } from "@/context/sdk" import { WslServersProvider } from "@/wsl/context" @@ -63,12 +52,17 @@ 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 { + legacySessionHref, + legacySessionServer, + requireServerKey, + selectSessionLineage, + sessionHref, +} from "./utils/session-route" +import { isSessionNotFoundError } from "./utils/server-errors" -import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" -import { NewHome } from "@/pages/home" -import { LegacyHome } from "@/pages/home/legacy-home" +import Session from "@/pages/session" +import { NewHome, LegacyHome } from "@/pages/home" const NewSession = lazy(() => import("@/pages/new-session")) @@ -102,13 +96,13 @@ const SessionRoute = () => { }) return ( - - - + + + ) } -function TargetServerRoute(props: ParentProps) { +const TargetSessionRoute = () => { const params = useParams<{ serverKey: string; id: string }>() const global = useGlobal() const conn = createMemo(() => { @@ -117,54 +111,82 @@ 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() { +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 cached = createMemo(() => sync().session.lineage.peek(params.id)) + const [resolved] = createResource( + () => { + if (cached()) return + return { id: params.id, server: serverKey(), sync: sync() } + }, + ({ id, server, sync }) => + sync.session.lineage.resolve(id).catch((error) => { + if (isSessionNotFoundError(error, id)) tabs.removeSessionTab({ server, sessionId: id }) + throw error + }), + ) + const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved())) + const directory = createMemo(() => current()?.session.directory) + const targetDirectory = () => directory()! + + createEffect(() => { + const session = current() + if (!session) return + tabs.addSessionTab({ + server: serverKey(), + sessionId: session.root.id, + }) + }) + return ( - - - - - + params.id}> + }> + + } + > + + + + + + + + + ) } -function LegacyTargetSessionRedirect() { - const params = useParams<{ id: string }>() - const navigate = useNavigate() - const sync = useServerSync() - const current = createSessionLineage( - () => params.id, - () => sync().session.lineage, +function TargetSessionPage() { + const sdk = useSDK() + const serverSDK = useServerSDK() + return ( + + + + + ) - - 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 +197,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 +215,7 @@ function DraftRoute() { keyed fallback={} > - {(draft) => ( - } - > - - - )} + {(draft) => } ) @@ -217,7 +231,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) { - + @@ -225,7 +239,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) { - + @@ -237,37 +251,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 } } @@ -310,69 +300,78 @@ function SharedProviders(props: ParentProps) { <> - {props.children} ) } -function DesktopCommands() { - const command = useCommand() - const language = useLanguage() - const platform = usePlatform() - - command.register("desktop", () => { - const commands: CommandOption[] = [] - if (platform.platform === "desktop" && platform.exportDebugLogs) { - commands.push({ - id: "logs.export", - title: "Export logs", - category: language.t("command.category.settings"), - onSelect: () => { - void platform.exportDebugLogs?.() - }, - }) - } - return commands - }) - - return null -} - // 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 TargetServerScopedProviders(props: ServerScopedShellProps) { + return ( + + + {props.children} + + ) +} + +function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) { + const notification = useNotification() + createEffect(() => { + const sessionID = props.sessionID?.() + if (!notification.ready() || !sessionID) return + if (notification.session.unseenCount(sessionID) === 0) return + notification.session.markViewed(sessionID) + }) + return null +} + +function SessionProviders(props: ParentProps) { + 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 +389,8 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { { - void window.api?.setTitlebar?.({ mode, scheme }) + onThemeApplied={(_, mode) => { + void window.api?.setTitlebar?.({ mode }) }} > @@ -419,7 +418,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 +447,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 +541,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 +562,23 @@ export function AppInterface(props: { > - + ( - - - - - {routerProps.children} - - - - + + + + {routerProps.children} + + + )} > - + @@ -604,24 +588,13 @@ export function AppInterface(props: { ) } -function Routes(props: { serverScoped?: JSX.Element }) { +function Routes() { const settings = useSettings() return ( <> - ( - {routeProps.children} - )} - > - - { - <> - - - - } - + + {} } /> @@ -629,15 +602,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..a0821ef1a3 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -3,26 +3,13 @@ import { List } from "@opencode-ai/ui/list" import { Switch } from "@opencode-ai/ui/switch" import { Tooltip } from "@opencode-ai/ui/tooltip" import { Button } from "@opencode-ai/ui/button" -import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" -import { Dialog as DialogV2, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2" -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 { Switch as SwitchV2 } from "@opencode-ai/ui/v2/switch-v2" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { useFilteredList } from "@opencode-ai/ui/hooks" -import { For, Show, type Component } from "solid-js" +import type { Component } from "solid-js" 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" -import "./settings-v2/settings-v2.css" - -type ModelItem = ReturnType["model"]["list"]>[number] export const DialogManageModels: Component = () => { const local = useLocal() @@ -31,7 +18,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) @@ -115,151 +102,3 @@ export const DialogManageModels: Component = () => {
) } - -export const DialogManageModelsV2: Component = () => { - const local = useLocal() - const language = useLanguage() - const dialog = useDialog() - const directory = () => decode64(local.slug()) - - const handleConnectProvider = () => { - void dialog.show(() => ) - } - const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) - const providerVisible = (providerID: string) => - providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id })) - const setProviderVisibility = (providerID: string, checked: boolean) => { - providerList(providerID).forEach((x) => { - local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked) - }) - } - const setModelVisibility = (item: ModelItem, checked: boolean) => { - local.model.setVisibility({ modelID: item.id, providerID: item.provider.id }, checked) - } - const list = useFilteredList({ - items: () => local.model.list(), - key: (x) => `${x.provider.id}:${x.id}`, - filterKeys: ["provider.name", "name", "id"], - sortBy: (a, b) => a.name.localeCompare(b.name), - groupBy: (x) => x.provider.id, - sortGroupsBy: (a, b) => { - const aRank = popularProviders.indexOf(a.category) - const bRank = popularProviders.indexOf(b.category) - const aPopular = aRank >= 0 - const bPopular = bRank >= 0 - if (aPopular && !bPopular) return -1 - if (!aPopular && bPopular) return 1 - return aRank - bRank - }, - }) - - return ( - - - - - {language.t("command.provider.connect")} - - - -
-
- list.onInput(event.currentTarget.value)} - placeholder={language.t("dialog.model.search.placeholder")} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - autofocus - aria-label={language.t("dialog.model.search.placeholder")} - /> - - } - onClick={() => list.clear()} - aria-label={language.t("common.clear")} - /> - -
-
-
-
- - {language.t("common.loading")} - {language.t("common.loading.ellipsis")} -
- } - > - 0} - fallback={ -
- {language.t("dialog.model.empty")} - - "{list.filter()}" - -
- } - > - - {(group) => ( -
-
-
- -

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

-
-
- setProviderVisibility(group.category, checked)} - hideLabel - > - {group.items[0].provider.name} - -
-
- - - {(item) => ( - -
- setModelVisibility(item, checked)} - hideLabel - > - {item.name} - -
-
- )} -
-
-
- )} -
-
- -
-
- - - ) -} 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..b4e8b3b719 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -1,48 +1,23 @@ import { Popover as Kobalte } from "@kobalte/core/popover" -import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js" +import { Component, ComponentProps, createMemo, 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" import { popularProviders } from "@/hooks/use-providers" import { Button } from "@opencode-ai/ui/button" import { IconButton } from "@opencode-ai/ui/icon-button" -import { ScrollView } from "@opencode-ai/ui/scroll-view" import { Tag } from "@opencode-ai/ui/tag" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" 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) type ModelState = ReturnType["model"] -type ModelItem = ReturnType[number] - -const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}` -const manageKey = "action:manage" - -const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { category: string; items: ModelItem[] }) => { - const aIndex = popularProviders.indexOf(a.category) - const bIndex = popularProviders.indexOf(b.category) - const aPopular = aIndex >= 0 - const bPopular = bIndex >= 0 - - if (aPopular && !bPopular) return -1 - if (!aPopular && bPopular) return 1 - if (aPopular && bPopular) return aIndex - bIndex - return a.items[0].provider.name.localeCompare(b.items[0].provider.name) -} const ModelList: Component<{ provider?: string @@ -113,13 +88,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 +123,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 +140,9 @@ export function ModelSelectorPopover(props: { placement="top-start" gutter={4} > - + + {props.children} + void -}) { - const dialog = useDialog() - const controller = createModelSelectorController({ - model: props.model, - provider: () => props.provider, - onSelect: () => props.onClose?.(), - }) - - 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)), - ) - - 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())) - const keys = () => [...models().map(modelKey), manageKey] - const initialActive = () => { - const selected = props.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 setOpen = (open: boolean) => { - if (open) { - dismiss.allowTriggerRestore() - setStore({ open: true, active: initialActive() }) - setTimeout(() => - requestAnimationFrame(() => { - searchRef?.focus() - activeItem()?.scrollIntoView({ block: "nearest" }) - }), - ) - return - } - setStore({ open: false, search: "", active: "" }) - } - const selectModel = (item: ModelItem) => { - dismiss.preventTriggerRestore() - setOpen(false) - dismiss.afterClose(() => props.select(item)) - } - const manage = () => { - dismiss.preventTriggerRestore() - setOpen(false) - dismiss.afterClose(props.onManage) - } - const selectActive = () => { - const item = models().find((item) => modelKey(item) === store.active) - if (item) { - selectModel(item) - return - } - if (store.active === manageKey) manage() - } - const moveActive = (delta: number) => { - const options = keys() - if (options.length === 0) return - const index = options.indexOf(store.active) - const start = index === -1 ? 0 : index - setStore("active", options[(start + delta + options.length) % options.length]) - queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" })) - } - const setSearch = (value: string) => { - const first = props.models(value)[0] - 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 ( - - - - (contentRef = element)} - 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} - > -
-
- - (searchRef = el)} - value={store.search} - placeholder={language.t("dialog.model.search.placeholder")} - class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - onInput={(event) => setSearch(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === "Tab") return - event.stopPropagation() - if (event.key === "Escape") { - event.preventDefault() - dismiss.preventTriggerRestore() - setOpen(false) - dismiss.afterClose(props.onClose) - return - } - if (event.altKey || event.metaKey) return - if (event.key === "ArrowDown") { - event.preventDefault() - moveActive(1) - return - } - if (event.key === "ArrowUp") { - event.preventDefault() - moveActive(-1) - return - } - if (event.key === "Enter" && !event.isComposing) { - event.preventDefault() - selectActive() - } - }} - /> - - - -
-
-
- -
- 0} - fallback={ -
- {language.t("dialog.model.empty")} -
- } - > - - {(group) => ( - - - {group.items[0].provider.name} - - - - {(item) => ( - - } - > - { - setStore("active", modelKey(item)) - setTimeout(() => searchRef?.focus()) - }} - onSelect={() => selectModel(item)} - > - {item.name} - - {language.t("model.tag.free")} - - - {language.t("model.tag.latest")} - - - - )} - - - - )} - -
-
-
-
-
- { - setStore("active", manageKey) - setTimeout(() => searchRef?.focus()) - }} - onSelect={manage} - > - - {language.t("dialog.model.manage")} - -
- - - - ) -} - export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => { const dialog = useDialog() const language = useLanguage() @@ -528,8 +207,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..1a25d10f78 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, @@ -34,19 +35,14 @@ import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" 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" 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 { ModelSelectorPopover } from "@/components/dialog-select-model" 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 +53,120 @@ 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", @@ -132,8 +214,6 @@ export const PromptInput: Component = (props) => { let fileInputRef: HTMLInputElement | undefined let scrollRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement - let restoreEndOnFocus = true - let savedCursor: number | null = null const mirror = { input: false } const inset = 56 @@ -435,7 +515,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 +549,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,23 +586,13 @@ 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() }) } - const handleFocus = () => { - if (!restoreEndOnFocus) return - restoreEndOnFocus = false - requestAnimationFrame(() => { - if (document.activeElement !== editorRef) return - setCursorPosition(editorRef, prompt.cursor() ?? promptLength(prompt.current())) - queueScroll() - }) - } - const renderEditorWithCursor = (parts: Prompt) => { const cursor = currentCursor() renderEditor(parts) @@ -543,9 +613,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) } @@ -562,89 +629,24 @@ export const PromptInput: Component = (props) => { }) } - const referenceDescription = (reference: ReferenceInfo) => - reference.source.type === "git" ? reference.source.repository : reference.source.path - - const referenceList = createMemo(() => - sync() - .data.reference.filter((reference) => !reference.hidden) - .map( - (reference): AtOption => ({ - type: "reference", - name: reference.name, - path: reference.path, - display: reference.name, - description: reference.description ?? referenceDescription(reference), - }), - ), - ) - const agentList = createMemo(() => props.controls.agents.available .filter((agent) => !agent.hidden && agent.mode !== "primary") .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), ) - const mcpResourceList = createMemo(() => - Object.values(sync().data.mcp_resource).map( - (resource): AtOption => ({ - type: "resource", - name: resource.name, - uri: resource.uri, - client: resource.server, - display: resource.name, - description: resource.description, - mime: resource.mimeType, - }), - ), - ) - const handleAtSelect = (option: AtOption | undefined) => { if (!option) return if (option.type === "agent") { addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 }) - return + } else { + addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) } - if (option.type === "reference") { - addPart({ - type: "file", - path: option.path, - content: "@" + option.name, - start: 0, - end: 0, - mime: "application/x-directory", - filename: option.name, - }) - return - } - if (option.type === "resource") { - addPart({ - type: "file", - path: option.uri, - content: "@" + option.name, - start: 0, - end: 0, - mime: option.mime ?? "text/plain", - filename: option.name, - url: option.uri, - source: { - type: "resource", - text: { value: "@" + option.name, start: 0, end: 0 }, - clientName: option.client, - uri: option.uri, - }, - }) - return - } - addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) } const atKey = (x: AtOption | undefined) => { if (!x) return "" - if (x.type === "agent") return `agent:${x.name}` - if (x.type === "reference") return `reference:${x.name}` - if (x.type === "resource") return `resource:${x.client}:${x.uri}` - return `file:${x.path}` + return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}` } const { @@ -655,36 +657,30 @@ export const PromptInput: Component = (props) => { onKeyDown: atOnKeyDown, } = useFilteredList({ items: async (query) => { - const references = referenceList() const agents = agentList() - const mcpResources = mcpResourceList() const open = recent() const seen = new Set(open) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) - if (!query.trim()) return [...references, ...agents, ...mcpResources, ...pinned] + if (!query.trim()) return [...agents, ...pinned] const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) .map((path) => ({ type: "file", path, display: path })) - return [...references, ...agents, ...mcpResources, ...pinned, ...fileOptions] + return [...agents, ...pinned, ...fileOptions] }, key: atKey, filterKeys: ["display"], skipFilter: (item) => item.type === "file" && !item.recent, groupBy: (item) => { - if (item.type === "reference") return "reference" if (item.type === "agent") return "agent" - if (item.type === "resource") return "resource" if (item.recent) return "recent" return "file" }, sortGroupsBy: (a, b) => { const rank = (category: string) => { - if (category === "reference") return 0 - if (category === "agent") return 1 - if (category === "resource") return 2 - if (category === "recent") return 3 - return 4 + if (category === "agent") return 0 + if (category === "recent") return 1 + return 2 } return rank(a.category) - rank(b.category) }, @@ -709,7 +705,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 +713,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") @@ -763,17 +746,7 @@ export const PromptInput: Component = (props) => { const pill = document.createElement("span") pill.textContent = part.content pill.setAttribute("data-type", part.type) - if (part.type === "file") { - pill.setAttribute("data-path", part.path) - if (part.mime) pill.setAttribute("data-mime", part.mime) - if (part.filename) pill.setAttribute("data-filename", part.filename) - if (part.url) pill.setAttribute("data-url", part.url) - if (part.source?.type === "resource") { - pill.setAttribute("data-source-type", part.source.type) - pill.setAttribute("data-source-client-name", part.source.clientName) - pill.setAttribute("data-source-uri", part.source.uri) - } - } + if (part.type === "file") pill.setAttribute("data-path", part.path) if (part.type === "agent") pill.setAttribute("data-name", part.name) pill.setAttribute("contenteditable", "false") pill.style.userSelect = "text" @@ -888,29 +861,12 @@ export const PromptInput: Component = (props) => { const pushFile = (file: HTMLElement) => { const content = file.textContent ?? "" - const source = - file.dataset.sourceType === "resource" && file.dataset.sourceClientName && file.dataset.sourceUri - ? { - type: "resource" as const, - text: { - value: content, - start: position, - end: position + content.length, - }, - clientName: file.dataset.sourceClientName, - uri: file.dataset.sourceUri, - } - : undefined parts.push({ type: "file", path: file.dataset.path!, content, start: position, end: position + content.length, - ...(file.dataset.mime ? { mime: file.dataset.mime } : {}), - ...(file.dataset.filename ? { filename: file.dataset.filename } : {}), - ...(file.dataset.url ? { url: file.dataset.url } : {}), - ...(source ? { source } : {}), }) position += content.length } @@ -1002,10 +958,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 +1085,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 +1172,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 +1211,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 +1346,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 +1356,42 @@ export const PromptInput: Component = (props) => { (p) => p, ) - const bindEditorRef = (el: HTMLDivElement) => { - editorRef = el - restoreEndOnFocus = true - props.ref?.(el) + 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"), + style: control(), + onClose: restoreFocus, + onUnpaidClick: () => { + void import("@/components/dialog-select-model-unpaid").then((x) => { + dialog.show(() => ) + }) + }, + })) + + const newSession = () => props.variant === "new-session" + 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 +1407,468 @@ 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")} -
- -
-
- -
- - (x === "default" ? language.t("common.default") : x)} + onSelect={(value) => { + props.controls.model.selection.variant.set(value === "default" ? undefined : value) + restoreFocus() + }} + class="capitalize max-w-[160px] text-text-base" + valueClass="truncate text-13-regular text-text-base" + triggerStyle={control()} + triggerProps={{ "data-action": "prompt-model-variant" }} + variant="ghost" + /> + +
+
-
- -
- - + +
+ ) +} + +function ComposerModelControl(props: { state: ComposerModelControlState }) { + return ( + + + {props.state.title} + + + } + > + + + } + > + + {props.state.title} + + + } + > + + + {(providerID) => ( + + )} + + {props.state.modelName} + + + + + + + + ) +} diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index 6f3ef57c66..82b2d906a8 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -3,13 +3,28 @@ import { makeEventListener } from "@solid-primitives/event-listener" import { showToast } from "@/utils/toast" import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt" import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" import { uuid } from "@/utils/uuid" import { getCursorPosition } from "./editor-dom" -import { createBlobReference, type DraftStore } from "@/utils/draft-store" import { attachmentMime } from "./files" import { normalizePaste, pasteMode } from "./paste" +function dataUrl(file: File, mime: string) { + return new Promise((resolve) => { + const reader = new FileReader() + reader.addEventListener("error", () => resolve("")) + reader.addEventListener("load", () => { + const value = typeof reader.result === "string" ? reader.result : "" + const idx = value.indexOf(",") + if (idx === -1) { + resolve(value) + return + } + resolve(`data:${mime};base64,${value.slice(idx + 1)}`) + }) + reader.readAsDataURL(file) + }) +} + type PromptTarget = Pick["capture"]>, "current" | "cursor" | "set"> type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined } @@ -21,10 +36,9 @@ type PromptAttachmentsCoreInput = { warn?: () => void readClipboardImage?: () => Promise getPathForFile?: (file: File) => string - draftStore?: DraftStore } -export type PromptAttachmentsInput = { +type PromptAttachmentsInput = { prompt: ReturnType editor: () => HTMLDivElement | undefined isDialogActive: () => boolean @@ -51,13 +65,16 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) { return false } + const url = await dataUrl(file, mime) + if (!url) return false + const attachment: ImageAttachmentPart = { type: "image", id: uuid(), filename: file.name, sourcePath: input.getPathForFile?.(file) || undefined, mime, - blob: input.draftStore ? await input.draftStore.putBlob(file) : await createBlobReference(file), + dataUrl: url, } target.prompt.set([...target.prompt.current(), attachment], target.cursor) return true @@ -149,10 +166,8 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) { export function createPromptAttachments(input: PromptAttachmentsInput) { const language = useLanguage() - const platform = usePlatform() const attachments = createPromptAttachmentsCore({ ...input, - draftStore: platform.draftStore, capture: input.prompt.capture, warn: () => { showToast({ diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts index ab84cb6eae..099200f23f 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.test.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -100,41 +100,6 @@ describe("buildRequestParts", () => { ) }) - test("preserves reference aliases as directory file parts", () => { - const result = buildRequestParts({ - prompt: [ - { - type: "file", - path: "/repo/../docs", - content: "@docs", - start: 0, - end: 5, - mime: "application/x-directory", - filename: "docs", - }, - ], - context: [], - images: [], - text: "@docs", - messageID: "msg_reference", - sessionID: "ses_reference", - sessionDirectory: "/repo/app", - }) - - const filePart = result.requestParts.find((part) => part.type === "file") - expect(filePart).toBeDefined() - if (filePart?.type === "file") { - expect(filePart.mime).toBe("application/x-directory") - expect(filePart.filename).toBe("docs") - expect(filePart.url).toBe("file:///repo/../docs") - expect(filePart.source?.type).toBe("file") - if (filePart.source?.type === "file") { - expect(filePart.source.path).toBe("/repo/../docs") - expect(filePart.source.text.value).toBe("@docs") - } - } - }) - test("deduplicates context files when prompt already includes same path", () => { const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }] diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index a1e3448b31..043425356a 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -22,7 +22,7 @@ type ContextFile = { type BuildRequestPartsInput = { prompt: Prompt context: ContextFile[] - images: (Omit & { dataUrl: string })[] + images: ImageAttachmentPart[] text: string messageID: string sessionID: string @@ -89,43 +89,31 @@ const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: } export function buildRequestParts(input: BuildRequestPartsInput) { - const requestParts: PromptRequestPart[] = input.text.trim() - ? [ - { - id: Identifier.ascending("part"), - type: "text", - text: input.text, - }, - ] - : [] + const requestParts: PromptRequestPart[] = [ + { + id: Identifier.ascending("part"), + type: "text", + text: input.text, + }, + ] const files = input.prompt.filter(isFileAttachment).map((attachment) => { const path = absolute(input.sessionDirectory, attachment.path) - const source = attachment.source - ? { - ...attachment.source, - text: { - value: attachment.content, - start: attachment.start, - end: attachment.end, - }, - } - : { - type: "file" as const, - text: { - value: attachment.content, - start: attachment.start, - end: attachment.end, - }, - path, - } return { id: Identifier.ascending("part"), type: "file", - mime: attachment.mime ?? "text/plain", - url: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`, - filename: attachment.filename ?? getFilename(attachment.path), - source, + mime: "text/plain", + url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`, + filename: getFilename(attachment.path), + source: { + type: "file", + text: { + value: attachment.content, + start: attachment.start, + end: attachment.end, + }, + path, + }, } satisfies PromptRequestPart }) diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx index 1f03f199ab..3a0e525a4a 100644 --- a/packages/app/src/components/prompt-input/context-items.tsx +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -1,9 +1,7 @@ import { Component, For, Show } from "solid-js" -import { Dynamic } from "solid-js/web" import { FileIcon } from "@opencode-ai/ui/file-icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path" import type { ContextItem } from "@/context/prompt" @@ -14,7 +12,6 @@ type ContextItemsProps = { active: (item: PromptContextItem) => boolean openComment: (item: PromptContextItem) => void remove: (item: PromptContextItem) => void - newLayoutDesigns: boolean t: (key: string) => string } @@ -30,24 +27,17 @@ export const PromptContextItems: Component = (props) => { const selected = props.active(item) return ( - - + {directory} {filename} } placement="top" - openDelay={800} + openDelay={2000} >
= (props) => { {(comment) =>
{comment()}
}
-
+ ) }} diff --git a/packages/app/src/components/prompt-input/contracts.ts b/packages/app/src/components/prompt-input/contracts.ts deleted file mode 100644 index a38f45d925..0000000000 --- a/packages/app/src/components/prompt-input/contracts.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { useLocal } from "@/context/local" -import type { Prompt, usePrompt } from "@/context/prompt" -import type { PromptInputHistory } from "./history-store" -import type { FollowupDraft } from "./submit" - -export type PromptInputState = ReturnType - -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 - } - } -} - -export interface PromptInputProps { - class?: string - 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 -} diff --git a/packages/app/src/components/prompt-input/history-store.ts b/packages/app/src/components/prompt-input/history-store.ts deleted file mode 100644 index 3858b8b9bc..0000000000 --- a/packages/app/src/components/prompt-input/history-store.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { createStore, type SetStoreFunction, type Store } from "solid-js/store" -import type { Prompt } from "@/context/prompt" -import { Persist, persisted } from "@/utils/persist" -import { - clonePromptHistoryComments, - clonePromptParts, - prependHistoryEntry, - type PromptHistoryComment, - type PromptHistoryStoredEntry, -} from "./history" - -export type PromptInputHistory = { - entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[] - add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void -} - -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) - }, - } -} - -export function createPromptInputHistory(): PromptInputHistory { - const [normal, setNormal] = createStore({ entries: [] }) - const [shell, setShell] = createStore({ entries: [] }) - return createPromptInputHistoryStore(normal, setNormal, shell, setShell) -} - -export function createPersistedPromptInputHistory() { - const [normal, setNormal, normalInit] = persisted( - Persist.prompt(Persist.global("prompt-history", ["prompt-history.v1"])), - createStore({ entries: [] }), - ) - const [shell, setShell, shellInit] = persisted( - Persist.prompt(Persist.global("prompt-history-shell", ["prompt-history-shell.v1"])), - createStore({ entries: [] }), - ) - const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell) - return { - ...history, - add(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) { - const ready = mode === "shell" ? shellInit : normalInit - if (!(ready instanceof Promise)) return history.add(prompt, mode, comments) - const saved = clonePromptParts(prompt) - const metadata = clonePromptHistoryComments(comments) - void ready.then(() => history.add(saved, mode, metadata)) - }, - } -} diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts index 94f24ce935..5e9c2c66ea 100644 --- a/packages/app/src/components/prompt-input/history.test.ts +++ b/packages/app/src/components/prompt-input/history.test.ts @@ -112,7 +112,7 @@ describe("prompt-input history", () => { end: 12, selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 }, }, - { type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } }, + { type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" }, ] const copy = clonePromptParts(original) expect(copy).not.toBe(original) diff --git a/packages/app/src/components/prompt-input/image-attachments.css b/packages/app/src/components/prompt-input/image-attachments.css deleted file mode 100644 index 2aaf494ebf..0000000000 --- a/packages/app/src/components/prompt-input/image-attachments.css +++ /dev/null @@ -1,43 +0,0 @@ -@keyframes prompt-attachments-fade-left { - from { - visibility: hidden; - } - to { - visibility: visible; - } -} - -@keyframes prompt-attachments-fade-right { - from { - visibility: visible; - } - to { - visibility: hidden; - } -} - -[data-slot="prompt-attachments"] { - timeline-scope: --prompt-attachments-scroll; - - [data-slot^="prompt-attachments-fade-"] { - visibility: hidden; - } -} - -@supports (animation-timeline: --prompt-attachments-scroll) and (timeline-scope: --prompt-attachments-scroll) { - [data-slot="prompt-attachments-scroll"] { - scroll-timeline: --prompt-attachments-scroll x; - } - - [data-slot="prompt-attachments-fade-left"] { - animation: prompt-attachments-fade-left linear both; - animation-timeline: --prompt-attachments-scroll; - animation-range: 0 0.1px; - } - - [data-slot="prompt-attachments-fade-right"] { - animation: prompt-attachments-fade-right linear both; - animation-timeline: --prompt-attachments-scroll; - animation-range: calc(100% - 1.1px) calc(100% - 1px); - } -} diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx index ecc38db888..dd8138e5a4 100644 --- a/packages/app/src/components/prompt-input/image-attachments.tsx +++ b/packages/app/src/components/prompt-input/image-attachments.tsx @@ -1,167 +1,60 @@ import { Component, For, Show } from "solid-js" import { Icon } from "@opencode-ai/ui/icon" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { AttachmentCardV2 } from "@opencode-ai/session-ui/v2/attachment-card-v2" -import { CommentCardV2 } from "@opencode-ai/session-ui/v2/comment-card-v2" -import { typeLabel } from "@opencode-ai/session-ui/message-file" -import type { ContextItem, ImageAttachmentPart } from "@/context/prompt" -import "./image-attachments.css" - -type PromptCommentItem = ContextItem & { key: string } +import type { ImageAttachmentPart } from "@/context/prompt" type PromptImageAttachmentsProps = { attachments: ImageAttachmentPart[] onOpen: (attachment: ImageAttachmentPart) => void onRemove: (id: string) => void removeLabel: string - newLayoutDesigns: boolean - comments?: PromptCommentItem[] - commentActive?: (item: PromptCommentItem) => boolean - onOpenComment?: (item: PromptCommentItem) => void - onRemoveComment?: (item: PromptCommentItem) => void } const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base" const imageClass = "size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors" -const imageClassV2 = "w-[58px] h-[46px] rounded-[6px] object-cover" -// inset box-shadows do not paint over content, so the hairline is a separate overlay -const imageHairlineClassV2 = - "absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" const removeClass = "absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover" -const removeClassV2 = - "absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md" export const PromptImageAttachments: Component = (props) => { return ( - 0 || (props.newLayoutDesigns && (props.comments?.length ?? 0) > 0)}> -
-
- - - {(item) => ( -
- - props.onOpenComment?.(item)} - /> - - -
- )} -
-
- - {(attachment) => { - const image = attachment.mime.startsWith("image/") - const media = () => ( + 0}> +
+ + {(attachment) => ( + +
- -
- } - > - - {typeLabel(attachment.filename, attachment.mime)} - - +
+ +
} > {attachment.filename} props.onOpen(attachment)} /> - ) - const name = () => ( -
- {attachment.filename} -
- ) - const remove = () => ( - ) - // v2 keeps the remove button outside the tooltip trigger so hovering it dismisses the tooltip - return ( - -
- {media()} - {name()} - {remove()} -
-
- } - > -
- - {media()} - -
- - - {remove()} -
-
- ) - }} - -
- -
-
- +
+ {attachment.filename} +
+
+ + )} +
) diff --git a/packages/app/src/components/prompt-input/placeholder.ts b/packages/app/src/components/prompt-input/placeholder.ts index cbb4c45b7e..6669f13614 100644 --- a/packages/app/src/components/prompt-input/placeholder.ts +++ b/packages/app/src/components/prompt-input/placeholder.ts @@ -13,8 +13,3 @@ export function promptPlaceholder(input: PromptPlaceholderInput) { if (!input.suggest) return input.t("prompt.placeholder.simple") return input.t("prompt.placeholder.normal", { example: input.example }) } - -export function promptDesignPlaceholder(mode: PromptPlaceholderInput["mode"], placeholder: string) { - if (mode === "shell") return placeholder - return "Ask anything, / for commands, @ for context..." -} diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index b9a2dc7afa..aa56b07961 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -7,16 +7,6 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path" export type AtOption = | { type: "agent"; name: string; display: string } - | { - type: "resource" - name: string - uri: string - client: string - display: string - description?: string - mime?: string - } - | { type: "reference"; name: string; path: string; display: string; description: string } | { type: "file"; path: string; display: string; recent?: boolean } export interface SlashCommand { @@ -41,10 +31,6 @@ type PromptPopoverProps = { slashActive?: string setSlashActive: (id: string) => void onSlashSelect: (item: SlashCommand) => void - slashMenu: boolean - slashMenuQuery: string - onSlashMenuInput: (value: string) => void - onSlashMenuKeyDown: (event: KeyboardEvent) => void commandKeybind: (id: string) => string | undefined commandKeybindParts: (id: string) => string[] newLayoutDesigns: boolean @@ -117,94 +103,6 @@ export const PromptPopover: Component = (props) => { ) } - if (item.type === "resource") { - return ( - - ) - } - - if (item.type === "reference") { - return ( - - ) - } - const isDirectory = item.path.endsWith("/") const directory = isDirectory ? item.path : getDirectory(item.path) const filename = isDirectory ? "" : getFilename(item.path) @@ -258,20 +156,6 @@ export const PromptPopover: Component = (props) => { - -
- requestAnimationFrame(() => el.focus())} - value={props.slashMenuQuery} - onInput={(event) => props.onSlashMenuInput(event.currentTarget.value)} - onKeyDown={props.onSlashMenuKeyDown} - onMouseDown={(event) => event.stopPropagation()} - aria-label={props.t("prompt.menu.commands")} - placeholder="/" - class="w-full bg-transparent outline-none text-[13px] leading-5 text-v2-text-text-base placeholder:text-v2-text-text-faint" - /> -
-
0} fallback={ diff --git a/packages/app/src/components/prompt-input/submission-state.ts b/packages/app/src/components/prompt-input/submission-state.ts index f9ca735609..ebc4c50715 100644 --- a/packages/app/src/components/prompt-input/submission-state.ts +++ b/packages/app/src/components/prompt-input/submission-state.ts @@ -7,7 +7,6 @@ export function createPromptSubmissionState(input: { prompt: Prompt context: (ContextItem & { key: string })[] }) { - const initial = input.target let target = input.target let cleared: Prompt | undefined @@ -16,7 +15,6 @@ export function createPromptSubmissionState(input: { context: input.context, target: () => target, clear() { - if (initial !== target) initial.reset() target.reset() cleared = target.current() }, diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index b3201b3ef6..9a65fa101d 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -1,18 +1,11 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" -import { createStore } from "solid-js/store" -import type { Prompt, PromptStore } from "@/context/prompt" -import type { ModelSelection } from "@/context/local" +import type { Prompt } from "@/context/prompt" let createPromptSubmit: typeof import("./submit").createPromptSubmit const createdClients: string[] = [] const createdSessions: string[] = [] -const sessionCreateInputs: Array<{ - agent?: string - model?: { id: string; providerID: string; variant?: string } - location?: { directory: string } -}> = [] -const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = [] +const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = [] const optimistic: Array<{ directory?: string sessionID?: string @@ -25,38 +18,21 @@ const optimistic: Array<{ const optimisticSeeded: boolean[] = [] const storedSessions: Record> = {} const promoted: Array<{ directory: string; sessionID: string }> = [] -const sentShell: Array<{ sessionID: string; id?: string; command: string }> = [] +const sentShell: string[] = [] const syncedDirectories: string[] = [] const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = [] -const sentPrompts: string[] = [] -const promptInputs: unknown[] = [] -const sentCommands: unknown[] = [] -const commands: Array<{ name: string }> = [] -let serverSessionSyncs = 0 let params: { id?: string } = {} let search: { draftId?: string } = {} let selected = "/repo/worktree-a" let variant: string | undefined -let permissionServer = "server-a" -let createSessionGate: Promise | undefined -let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] -const [promptStore, setPromptStore] = createStore({ - prompt: promptValue, - cursor: 0, - context: { items: [] }, -}) +const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const prompt = { - store: [() => promptStore, setPromptStore] as [() => PromptStore, typeof setPromptStore], ready: Object.assign(() => true, { promise: Promise.resolve(true) }), current: () => promptValue, cursor: () => 0, dirty: () => true, - model: { - current: () => undefined, - set: () => undefined, - }, reset: () => undefined, set: () => undefined, context: { @@ -73,39 +49,22 @@ const prompt = { const clientFor = (directory: string) => { createdClients.push(directory) return { - api: { - session: { - create: async (input: (typeof sessionCreateInputs)[number]) => { - await createSessionGate - const location = input.location?.directory ?? directory - createdSessions.push(location) - sessionCreateInputs.push(input) - return { - id: `session-${createdSessions.length}`, - projectID: "project", - agent: input.agent, - model: input.model, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1, updated: 1 }, - title: `New session ${createdSessions.length}`, - location: { directory: location }, - } - }, - prompt: async (input: unknown) => { - sentPrompts.push(directory) - promptInputs.push(input) - return { data: undefined } - }, - command: async (input: unknown) => { - sentCommands.push(input) - }, - shell: async (input: { sessionID: string; id?: string; command: string }) => { - sentShell.push(input) - }, - }, - }, session: { + create: async () => { + createdSessions.push(directory) + return { + data: { + id: `session-${createdSessions.length}`, + title: `New session ${createdSessions.length}`, + }, + } + }, + shell: async () => { + sentShell.push(directory) + return { data: undefined } + }, + prompt: async () => ({ data: undefined }), + promptAsync: async () => ({ data: undefined }), command: async () => ({ data: undefined }), abort: async () => ({ data: undefined }), }, @@ -158,14 +117,13 @@ beforeAll(async () => { }), })) - mock.module("@/context/permission", () => { - const state = (server: string) => ({ + mock.module("@/context/permission", () => ({ + usePermission: () => ({ enableAutoAccept(sessionID: string, directory: string) { - enabledAutoAccept.push({ server, sessionID, directory }) + enabledAutoAccept.push({ sessionID, directory }) }, - }) - return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) } - }) + }), + })) mock.module("@/context/server", () => ({ useServer: () => ({ key: "server-key" }), @@ -198,7 +156,6 @@ beforeAll(async () => { scope: "local", directory: "/repo/main", client: rootClient, - api: rootClient.api, url: "http://localhost:4096", createClient(opts: any) { return clientFor(opts.directory) @@ -210,7 +167,7 @@ beforeAll(async () => { mock.module("@/context/sync", () => ({ useSync: () => () => ({ - data: { command: commands }, + data: { command: [] }, session: { optimistic: { add: (value: { @@ -237,9 +194,6 @@ beforeAll(async () => { session: { remember: () => undefined, set: () => undefined, - sync: async () => { - serverSessionSyncs++ - }, }, child: (directory: string) => { syncedDirectories.push(directory) @@ -281,26 +235,17 @@ beforeAll(async () => { beforeEach(() => { createdClients.length = 0 createdSessions.length = 0 - sessionCreateInputs.length = 0 enabledAutoAccept.length = 0 optimistic.length = 0 optimisticSeeded.length = 0 promoted.length = 0 promotedDrafts.length = 0 - sentPrompts.length = 0 - promptInputs.length = 0 - sentCommands.length = 0 - commands.length = 0 - promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }] params = {} search = {} sentShell.length = 0 syncedDirectories.length = 0 selected = "/repo/worktree-a" variant = undefined - permissionServer = "server-a" - createSessionGate = undefined - serverSessionSyncs = 0 for (const key of Object.keys(storedSessions)) delete storedSessions[key] }) @@ -334,24 +279,8 @@ describe("prompt submit worktree selection", () => { expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) - expect(sessionCreateInputs).toEqual([ - { - agent: "agent", - model: { id: "model", providerID: "provider", variant: undefined }, - location: { directory: "/repo/worktree-a" }, - }, - { - agent: "agent", - model: { id: "model", providerID: "provider", variant: undefined }, - location: { directory: "/repo/worktree-b" }, - }, - ]) - expect(sentShell).toEqual([ - expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }), - expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }), - ]) + expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"]) - expect(serverSessionSyncs).toBe(0) expect(promoted).toEqual([ { directory: "/repo/worktree-a", sessionID: "session-1" }, { directory: "/repo/worktree-b", sessionID: "session-2" }, @@ -384,40 +313,7 @@ describe("prompt submit worktree selection", () => { await submit.handleSubmit(event) - expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }]) - }) - - test("keeps auto-accept bound to the submission server", async () => { - let release = () => {} - createSessionGate = new Promise((resolve) => { - release = resolve - }) - const submit = createPromptSubmit({ - prompt, - info: () => undefined, - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => true, - mode: () => "shell", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - newSessionWorktree: () => selected, - onNewSessionWorktreeReset: () => undefined, - onSubmit: () => undefined, - }) - - const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) - permissionServer = "server-b" - release() - await result - - expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }]) + expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }]) }) test("promotes drafts using the selected project's server", async () => { @@ -472,7 +368,6 @@ describe("prompt submit worktree selection", () => { const event = { preventDefault: () => undefined } as unknown as Event await submit.handleSubmit(event) - await Bun.sleep(0) expect(optimistic).toHaveLength(1) expect(optimistic[0]).toMatchObject({ @@ -481,89 +376,6 @@ describe("prompt submit worktree selection", () => { model: { providerID: "provider", modelID: "model", variant: "high" }, }, }) - expect(sentPrompts).toEqual(["/repo/main"]) - expect(promptInputs[0]).toMatchObject({ - sessionID: "session-1", - text: "ls", - files: [], - agents: [], - }) - expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_") - expect((promptInputs[0] as { legacyParts?: { id: string; type: string; text?: string }[] }).legacyParts).toEqual([ - { id: expect.stringMatching(/^prt_/), type: "text", text: "ls" }, - ]) - }) - - test("submits slash commands through the current session API", async () => { - params = { id: "session-1" } - variant = "high" - commands.push({ name: "review" }) - promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }] - - const submit = createPromptSubmit({ - prompt, - info: () => ({ id: "session-1" }), - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => false, - mode: () => "normal", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - }) - - await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) - - expect(sentCommands).toEqual([ - { - sessionID: "session-1", - id: expect.stringMatching(/^msg_/), - command: "review", - arguments: "staged changes", - agent: "agent", - model: { id: "model", providerID: "provider", variant: "high" }, - files: [], - }, - ]) - expect(serverSessionSyncs).toBe(0) - }) - - test("uses an injected model selection", async () => { - params = { id: "session-1" } - const model = { - current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }), - variant: { current: () => "draft-variant" }, - } as unknown as ModelSelection - const submit = createPromptSubmit({ - prompt, - info: () => ({ id: "session-1" }), - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => false, - mode: () => "normal", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - model, - }) - - await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) - - expect(optimistic[0]).toMatchObject({ - message: { - model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" }, - }, - }) }) test("seeds new sessions before optimistic prompts are added", async () => { @@ -591,8 +403,7 @@ describe("prompt submit worktree selection", () => { await submit.handleSubmit(event) - expect(storedSessions["/repo/worktree-a"]).toHaveLength(1) - expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" }) + expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }]) expect(optimisticSeeded).toEqual([true]) }) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index c82c3a4399..05d978a947 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -3,12 +3,12 @@ import { showToast } from "@/utils/toast" import { base64Encode } from "@opencode-ai/core/util/encode" import { Binary } from "@opencode-ai/core/util/binary" import { useNavigate, useParams, useSearchParams } from "@solidjs/router" -import { batch, startTransition, type Accessor } from "solid-js" +import { batch, type Accessor } from "solid-js" import { useTabs } from "@/context/tabs" import { useServerSync, type ServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" -import { useLocal, type ModelSelection } from "@/context/local" +import { useLocal } from "@/context/local" import { usePermission } from "@/context/permission" import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt" import { useSDK, type DirectorySDK } from "@/context/sdk" @@ -20,9 +20,6 @@ import { setCursorPosition } from "./editor-dom" import { formatServerError } from "@/utils/server-errors" import { ScopedKey } from "@/utils/server-scope" import { createPromptSubmissionState } from "./submission-state" -import { normalizeSessionInfo } from "@/utils/session" -import { Event } from "@opencode-ai/schema/event" -import { blobDataUrl } from "@/utils/draft-store" type PendingPrompt = { abort: AbortController @@ -42,7 +39,7 @@ export type FollowupDraft = { } type FollowupSendInput = { - api: DirectorySDK["api"]["session"] + client: DirectorySDK["client"] serverSync: ServerSync sync: DirectorySync draft: FollowupDraft @@ -84,24 +81,20 @@ export async function sendFollowupDraft(input: FollowupSendInput) { return false } - const messageID = Identifier.ascending("message") - await input.api.command({ + await input.client.session.command({ sessionID: input.draft.sessionID, - id: messageID, command: cmd, arguments: tail.join(" "), agent: input.draft.agent, - model: { - id: input.draft.model.modelID, - providerID: input.draft.model.providerID, - variant: input.draft.variant, - }, - files: await Promise.all( - images.map(async (attachment) => ({ - uri: await blobDataUrl(attachment.blob, attachment.mime), - name: attachment.filename, - })), - ), + model: `${input.draft.model.providerID}/${input.draft.model.modelID}`, + variant: input.draft.variant, + parts: images.map((attachment) => ({ + id: Identifier.ascending("part"), + type: "file" as const, + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + })), }) return true } catch (err) { @@ -111,16 +104,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) { } const messageID = input.messageID ?? Identifier.ascending("message") - const encodedImages = await Promise.all( - images.map(async (attachment) => ({ - ...attachment, - dataUrl: await blobDataUrl(attachment.blob, attachment.mime), - })), - ) const { requestParts, optimisticParts } = buildRequestParts({ prompt: input.draft.prompt, context: input.draft.context, - images: encodedImages, + images, text, sessionID: input.draft.sessionID, messageID, @@ -165,37 +152,13 @@ export async function sendFollowupDraft(input: FollowupSendInput) { return false } - await input.api.prompt({ + await input.client.session.promptAsync({ sessionID: input.draft.sessionID, - id: messageID, agent: input.draft.agent, model: input.draft.model, + messageID, + parts: requestParts, variant: input.draft.variant, - legacyParts: requestParts, - text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), - files: requestParts.flatMap((part) => { - if (part.type !== "file") return [] - const text = part.source?.text - return [ - { - uri: part.url, - name: part.filename, - mention: text ? { start: text.start, end: text.end, text: text.value } : undefined, - }, - ] - }), - agents: requestParts.flatMap((part) => - part.type === "agent" - ? [ - { - name: part.name, - mention: part.source - ? { start: part.source.start, end: part.source.end, text: part.source.value } - : undefined, - }, - ] - : [], - ), }) return true } catch (err) { @@ -228,7 +191,6 @@ type PromptSubmitInput = { onQueue?: (draft: FollowupDraft) => void onAbort?: () => void onSubmit?: () => void - model?: ModelSelection } export function createPromptSubmit(input: PromptSubmitInput) { @@ -247,7 +209,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID) const errorMessage = (err: unknown) => { - if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message if (err && typeof err === "object" && "data" in err) { const data = (err as { data?: { message?: string } }).data if (data?.message) return data.message @@ -273,7 +234,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { return Promise.resolve() } return sdk() - .api.session.interrupt({ sessionID }) + .client.session.abort({ + sessionID, + }) .catch(() => {}) } @@ -335,10 +298,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { return } - const modelSelection = input.model ?? local.model - const currentModel = modelSelection.current() + const currentModel = local.model.current() const currentAgent = local.agent.current() - const variant = modelSelection.variant.current() + const variant = local.model.variant.current() if (!currentModel || !currentAgent) { showToast({ title: language.t("prompt.toast.modelAgentRequired.title"), @@ -351,7 +313,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { input.resetHistoryNavigation() const projectDirectory = sdk().directory - const permissionState = permission.currentServerState() const isNewSession = !params.id const shouldAutoAccept = isNewSession && input.autoAccept() const worktreeSelection = input.newSessionWorktree?.() || "main" @@ -400,13 +361,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { let session = input.info() if (!session && isNewSession) { - const created = await sdk() - .api.session.create({ - agent: currentAgent.name, - model: { id: currentModel.id, providerID: currentModel.provider.id, variant }, - location: { directory: sessionDirectory }, - }) - .then(normalizeSessionInfo) + const created = await client.session + .create() + .then((x) => x.data ?? undefined) .catch((err) => { showToast({ title: language.t("prompt.toast.sessionCreateFailed.title"), @@ -417,20 +374,13 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (created) { seed(sessionDirectory, created) session = created - await startTransition(() => { - if (!session) return - if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory) - local.session.promote(sessionDirectory, session.id, { - agent: currentAgent.name, - model: { providerID: currentModel.provider.id, modelID: currentModel.id }, - variant: variant ?? null, - }) - layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) - const draftID = search.draftId - if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id }) - else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) - submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id })) - }) + if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) + local.session.promote(sessionDirectory, session.id) + layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) + const draftID = search.draftId + if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id }) + else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id })) } } if (!session) { @@ -490,14 +440,12 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (mode === "shell") { clearInput() - const eventID = Event.ID.create() - sdk() - .api.session.shell({ + client.session + .shell({ sessionID: session.id, - id: eventID, - command: text, agent, model, + command: text, }) .catch((err) => { showToast({ @@ -515,25 +463,23 @@ export function createPromptSubmit(input: PromptSubmitInput) { const customCommand = sync().data.command.find((c) => c.name === commandName) if (customCommand) { clearInput() - const messageID = Identifier.ascending("message") - serverSync().session.set("session_status", session.id, { type: "busy" }) - sdk() - .api.session.command({ + client.session + .command({ sessionID: session.id, - id: messageID, command: commandName, arguments: args.join(" "), agent, - model: { id: model.modelID, providerID: model.providerID, variant }, - files: await Promise.all( - images.map(async (attachment) => ({ - uri: await blobDataUrl(attachment.blob, attachment.mime), - name: attachment.filename, - })), - ), + model: `${model.providerID}/${model.modelID}`, + variant, + parts: images.map((attachment) => ({ + id: Identifier.ascending("part"), + type: "file" as const, + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + })), }) .catch((err) => { - serverSync().session.set("session_status", session.id, { type: "idle" }) showToast({ title: language.t("prompt.toast.commandSendFailed.title"), description: formatServerError(err, language.t, language.t("common.requestFailed")), @@ -617,7 +563,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { } void sendFollowupDraft({ - api: sdk().api.session, + client, sync: sync(), serverSync: serverSync(), draft, diff --git a/packages/app/src/components/prompt-input/transient-state.ts b/packages/app/src/components/prompt-input/transient-state.ts index 29f2f182b3..c8abf9a766 100644 --- a/packages/app/src/components/prompt-input/transient-state.ts +++ b/packages/app/src/components/prompt-input/transient-state.ts @@ -4,40 +4,37 @@ import type { PromptHistoryEntry } from "./history" export type PromptInputTransientState = { popover: "at" | "slash" | null - slashMenu: boolean - slashMenuQuery: string historyIndex: number savedPrompt: PromptHistoryEntry | null placeholder: number draggingType: "image" | "@mention" | null mode: "normal" | "shell" applyingHistory: boolean + variantOpen: boolean } function resetPromptInputTransientState(setStore: SetStoreFunction) { setStore({ popover: null, - slashMenu: false, - slashMenuQuery: "", historyIndex: -1, savedPrompt: null, draggingType: null, mode: "normal", applyingHistory: false, + variantOpen: false, }) } export function createPromptInputTransientState(identity: Accessor, placeholder: number) { const [store, setStore] = createStore({ popover: null, - slashMenu: false, - slashMenuQuery: "", historyIndex: -1, savedPrompt: null, placeholder, draggingType: null, mode: "normal", applyingHistory: false, + variantOpen: false, }) createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true })) diff --git a/packages/app/src/components/prompt-project-selector.tsx b/packages/app/src/components/prompt-project-selector.tsx index b84afdb56b..4573d0d552 100644 --- a/packages/app/src/components/prompt-project-selector.tsx +++ b/packages/app/src/components/prompt-project-selector.tsx @@ -1,13 +1,4 @@ -import { - createEffect, - createSignal, - For, - onCleanup, - Show, - splitProps, - type Accessor, - type ComponentProps, -} from "solid-js" +import { For, Show, splitProps, type Accessor, type ComponentProps } from "solid-js" import { createStore } from "solid-js/store" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Icon } from "@opencode-ai/ui/icon" @@ -17,8 +8,6 @@ import { getProjectAvatarVariant } from "@/context/layout" import { useLanguage } from "@/context/language" import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" import { pathKey } from "@/utils/path-key" -import { handleDocumentSearchKeydown } from "@/utils/search-keydown" -import { createMenuDismissController } from "@/utils/menu-dismiss-controller" export type PromptProject = { name?: string @@ -56,7 +45,7 @@ export function createPromptProjectController(input: { const [store, setStore] = createStore({ open: false, search: "", active: "" }) let searchRef: HTMLInputElement | undefined - const current = () => { + const selected = () => { const key = pathKey(input.controls().directory) return input .controls() @@ -66,7 +55,6 @@ export function createPromptProjectController(input: { (pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)), ) } - const selected = () => current() ?? input.controls().available[0] const projects = () => { const search = store.search.trim().toLowerCase() if (!search) return input.controls().available @@ -102,8 +90,8 @@ export function createPromptProjectController(input: { } const select = (project: PromptProject) => { if ( - pathKey(project.worktree) !== pathKey(current()?.worktree ?? "") || - project.server?.key !== current()?.server?.key + pathKey(project.worktree) !== pathKey(selected()?.worktree ?? "") || + project.server?.key !== selected()?.server?.key ) { input.controls().select(project.worktree, project.server?.key) } @@ -113,20 +101,9 @@ export function createPromptProjectController(input: { setStore({ open: false, search: "", active: "" }) input.controls().add(language.t("command.project.open"), server) } - const setSearch = (value: string) => { - const search = value.trim().toLowerCase() - const first = input - .controls() - .available.find((project) => !search || displayName(project).toLowerCase().includes(search)) - setStore({ - search: value, - active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key), - }) - } return { selected, - empty: () => input.controls().available.length === 0, projects, servers, projectKey, @@ -150,7 +127,16 @@ export function createPromptProjectController(input: { } setStore({ open: false, search: "", active: "" }) }, - setSearch, + setSearch(value: string) { + const search = value.trim().toLowerCase() + const first = input + .controls() + .available.find((project) => !search || displayName(project).toLowerCase().includes(search)) + setStore({ + search: value, + active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key), + }) + }, clearSearch() { setStore({ search: "", active: initialActive() }) setTimeout(() => searchRef?.focus()) @@ -184,9 +170,6 @@ export function createPromptProjectController(input: { focusSearch() { setTimeout(() => requestAnimationFrame(() => searchRef?.focus())) }, - handleSearchKeydown(event: KeyboardEvent) { - return handleDocumentSearchKeydown(searchRef, event, store.search, setSearch) - }, } } @@ -196,41 +179,32 @@ export function PromptProjectSelector(props: { controller: PromptProjectController placement?: "bottom" | "bottom-start" }) { - const [triggerReady, setTriggerReady] = createSignal(false) let contentRef: HTMLDivElement | undefined - const dismiss = createMenuDismissController(() => contentRef) - let triggerFrame: number | undefined - - // Floating UI requires a connected anchor; route transitions can construct this trigger before adoption. - const setTriggerRef = (element: HTMLButtonElement) => { - const ready = () => { - if (!element.isConnected) { - triggerFrame = requestAnimationFrame(ready) - return - } - triggerFrame = undefined - setTriggerReady(true) - } - ready() - } - - onCleanup(() => { - if (triggerFrame !== undefined) cancelAnimationFrame(triggerFrame) - }) + let restoreTrigger = true const activeItem = () => props.controller.active() ? contentRef?.querySelector(`[data-option-key="${CSS.escape(props.controller.active())}"]`) : undefined + const afterClose = (callback: () => void) => { + const complete = () => { + if (contentRef?.isConnected) { + requestAnimationFrame(complete) + return + } + requestAnimationFrame(() => requestAnimationFrame(callback)) + } + requestAnimationFrame(complete) + } const selectProject = (project: PromptProject) => { - dismiss.preventTriggerRestore() + restoreTrigger = false props.controller.setOpen(false) - dismiss.afterClose(() => props.controller.select(project)) + afterClose(() => props.controller.select(project)) } const selectAction = (server?: string) => { - dismiss.preventTriggerRestore() + restoreTrigger = false props.controller.setOpen(false) - dismiss.afterClose(() => props.controller.add(server)) + afterClose(() => props.controller.add(server)) } const selectActive = () => { const project = props.controller.activeProject() @@ -258,7 +232,7 @@ export function PromptProjectSelector(props: { ) .filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap")) .findLast((element) => element.offsetParent !== null) - dismiss.preventTriggerRestore() + restoreTrigger = false target?.focus() queueMicrotask(() => { if (props.controller.open()) props.controller.setOpen(false) @@ -269,34 +243,26 @@ export function PromptProjectSelector(props: { return project ? props.controller.projectKey(project) : undefined } - createEffect(() => { - if (!props.controller.open()) return - const handler = (event: KeyboardEvent) => props.controller.handleSearchKeydown(event) - document.addEventListener("keydown", handler, true) - onCleanup(() => document.removeEventListener("keydown", handler, true)) - }) - return ( { - if (open) dismiss.allowTriggerRestore() - props.controller.setOpen(open) - }} + onOpenChange={(open) => props.controller.setOpen(open)} > - + event.preventDefault()} - onPointerDownOutside={dismiss.preventTriggerRestore} - onFocusOutside={dismiss.preventTriggerRestore} - onCloseAutoFocus={dismiss.onCloseAutoFocus} + onPointerDownOutside={() => (restoreTrigger = false)} + onFocusOutside={() => (restoreTrigger = false)} + onCloseAutoFocus={(event) => { + if (!restoreTrigger) event.preventDefault() + }} >
@@ -356,45 +322,41 @@ export function PromptProjectSelector(props: {
-
- 1} - fallback={ - - - {(project) => ( - - )} - - - } - > - - props.controller.projects().some((project) => project.server?.key === server!.key), + 1} + fallback={ + + + {(project) => ( + )} - > - {(server) => ( -
-
- {server!.name} -
- - project.server?.key === server!.key)} - > - {(project) => ( - - )} - - -
+
+
+ } + > + + props.controller.projects().some((project) => project.server?.key === server!.key), )} - -
-
+ > + {(server) => ( +
+
+ {server!.name} +
+ + project.server?.key === server!.key)}> + {(project) => ( + + )} + + +
+ )} + +
diff --git a/packages/app/src/components/prompt-workspace-selector.tsx b/packages/app/src/components/prompt-workspace-selector.tsx index a7a3537434..b9188ac97c 100644 --- a/packages/app/src/components/prompt-workspace-selector.tsx +++ b/packages/app/src/components/prompt-workspace-selector.tsx @@ -1,6 +1,5 @@ import { For, Show } from "solid-js" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { Icon } from "@opencode-ai/ui/icon" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { getFilename } from "@opencode-ai/core/util/path" @@ -93,36 +92,11 @@ export function PromptWorkspaceSelector(props: { - + +
+ + {props.branch || "main"} +
) } - -export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) { - const language = useLanguage() - const label = () => { - if (props.noGit) return language.t("session.new.git.none") - return props.branch - } - - return ( - - {(value) => ( - <> - - -
- - {value()} -
-
- - )} -
- ) -} diff --git a/packages/app/src/components/server/server-row-menu.tsx b/packages/app/src/components/server/server-row-menu.tsx index 0a2920dec7..9d5f5e5a32 100644 --- a/packages/app/src/components/server/server-row-menu.tsx +++ b/packages/app/src/components/server/server-row-menu.tsx @@ -15,47 +15,9 @@ export const ServerRowMenu: Component<{ }> = (props) => { const language = useLanguage() const key = ServerConnection.key(props.server) - return ( - props.controller.setDefault(key)} - onRemoveDefault={() => props.controller.setDefault(null)} - onRemove={() => props.controller.handleRemove(key)} - open={props.open} - onOpenChange={props.onOpenChange} - /> - ) -} + const builtin = ServerConnection.builtin(props.server) + const isDefault = () => props.controller.defaultKey() === key -export function serverMenuLabels(language: ReturnType) { - return { - more: language.t("common.moreOptions"), - server: language.t("settings.section.server"), - edit: language.t("dialog.server.menu.edit"), - default: language.t("dialog.server.menu.default"), - defaultRemove: language.t("dialog.server.menu.defaultRemove"), - delete: language.t("dialog.server.menu.delete"), - } -} - -export const ServerRowMenuView: Component<{ - server: ServerConnection.Any - labels: ReturnType - canDefault: boolean - isDefault: boolean - onEdit: (server: ServerConnection.Http) => void - onSetDefault: () => void - onRemoveDefault: () => void - onRemove: () => void - open?: boolean - onOpenChange?: (open: boolean) => void -}> = (props) => { - const builtin = () => ServerConnection.builtin(props.server) - const httpServer = () => (props.server.type === "http" ? props.server : undefined) return ( } - aria-label={props.labels.more} + aria-label={language.t("common.moreOptions")} /> - {props.labels.server} + {language.t("settings.section.server")} { - const server = httpServer() - if (server) props.onEdit(server) - }} + disabled={builtin || props.server.type !== "http"} + onSelect={() => props.onEdit(props.server as ServerConnection.Http)} > - {props.labels.edit} + {language.t("dialog.server.menu.edit")} - - {props.labels.default} + + props.controller.setDefault(key)}> + {language.t("dialog.server.menu.default")} + - - {props.labels.defaultRemove} + + props.controller.setDefault(null)}> + {language.t("dialog.server.menu.defaultRemove")} + - - {props.labels.delete} + props.controller.handleRemove(key)}> + {language.t("dialog.server.menu.delete")} diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index c1e0ccde05..0d6b761c94 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -1,10 +1,9 @@ -import { Match, Show, Switch, createMemo, type ComponentProps, type JSX } from "solid-js" +import { Match, Show, Switch, createMemo } from "solid-js" +import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip" import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2" import { Button } from "@opencode-ai/ui/button" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { createMediaQuery } from "@solid-primitives/media" import { useFile } from "@/context/file" import { useLayout } from "@/context/layout" @@ -12,24 +11,14 @@ import { useSync } from "@/context/sync" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { useSDK } from "@/context/sdk" -import { getSessionContext } from "@/components/session/session-context-metrics" +import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" -import { useSettings } from "@/context/settings" interface SessionContextUsageProps { variant?: "button" | "indicator" buttonAppearance?: "default" | "v2" - placement?: ComponentProps["placement"] -} - -function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) { - return ( -
- {props.name} - {props.value} -
- ) + placement?: TooltipProps["placement"] } function openSessionContext(args: { @@ -37,7 +26,7 @@ function openSessionContext(args: { layout: ReturnType tabs: ReturnType["tabs"]> }) { - args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button") + if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open() if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all") void args.tabs.open("context") args.tabs.setActive("context") @@ -49,10 +38,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const layout = useLayout() const language = useLanguage() const sdk = useSDK() - const settings = useSettings() const providers = useProviders(() => sdk().directory) const { params, tabs, view } = useSessionLayout() - const isDesktop = createMediaQuery("(min-width: 768px)") const variant = createMemo(() => props.variant ?? "button") const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default") @@ -60,7 +47,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) { tabs, pathFromTab: file.pathFromTab, normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), - fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id, }) const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : [])) const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) @@ -74,28 +60,20 @@ export function SessionContextUsage(props: SessionContextUsageProps) { ) const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) + const tokens = createMemo(() => info()?.tokens) const cost = createMemo(() => { return usd().format(info()?.cost ?? 0) }) - const contextVisible = createMemo(() => view().reviewPanel.opened() && tabState.activeTab() === "context") - const hasOtherTabs = createMemo(() => - tabs() - .all() - .some((tab) => tab !== "context" && tab !== "review"), - ) const openContext = () => { if (!params.id) return - const sessionView = view() - if (contextVisible()) { + if (tabState.activeTab() === "context") { tabs().close("context") - if (sessionView.reviewPanel.source() === "context-button" && !hasOtherTabs()) sessionView.reviewPanel.close() return } - openSessionContext({ - view: sessionView, + view: view(), layout, tabs: tabs(), }) @@ -103,20 +81,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const circle = () => (
- +
) const circleV2 = () => ( @@ -126,19 +91,35 @@ export function SessionContextUsage(props: SessionContextUsageProps) { ) const tooltipValue = () => ( -
- - - +
+ + {(value) => ( +
+ + {getSessionTokenTotal(value())?.toLocaleString(language.intl())} + + {language.t("context.usage.tokens")} +
+ )} +
+ + {(ctx) => ( +
+ {ctx().usage ?? 0}% + {language.t("context.usage.usage")} +
+ )} +
+
+ {cost()} + {language.t("context.usage.cost")} +
) return ( - + {circle()} @@ -163,7 +144,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { - + ) } diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts index c4fdb2ff38..8e424f0f36 100644 --- a/packages/app/src/components/session/index.ts +++ b/packages/app/src/components/session/index.ts @@ -1,6 +1,6 @@ export { SessionHeader } from "./session-header" export { SessionContextTab } from "./session-context-tab" export { SortableTab, FileVisual } from "./session-sortable-tab" -export { SortableTabV2 } from "./session-sortable-tab-v2" export { SortableTerminalTab } from "./session-sortable-terminal-tab" export { NewSessionView } from "./session-new-view" +export { NewSessionDesignView } from "./session-new-design-view" diff --git a/packages/app/src/components/session/open-in-app-v2.tsx b/packages/app/src/components/session/open-in-app-v2.tsx deleted file mode 100644 index e26ff2e0ea..0000000000 --- a/packages/app/src/components/session/open-in-app-v2.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { For, Show } from "solid-js" -import { AppIcon } from "@opencode-ai/ui/app-icon" -import { Icon } from "@opencode-ai/ui/icon" -import { Spinner } from "@opencode-ai/ui/spinner" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" -import { SplitButtonV2, SplitButtonV2Action, SplitButtonV2MenuTrigger } from "@opencode-ai/ui/v2/split-button-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { useLanguage } from "@/context/language" -import { type OpenApp, useOpenInApp } from "@/components/session/open-in-app" - -export function OpenInAppV2(props: { directory: () => string }) { - const language = useLanguage() - const state = useOpenInApp(props) - - return ( - - event.stopPropagation()}> - - event.stopPropagation()} - onClick={(event) => { - event.stopPropagation() - if (state.opening()) return - state.openDir(state.current().id) - }} - disabled={state.opening()} - aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })} - > - }> - - - - - state.setMenu("open", open)} - > - event.stopPropagation()} - > - - - - - - {language.t("session.header.openIn")} - { - state.selectApp(value as OpenApp) - }} - > - - {(option) => ( - { - state.selectApp(option.id) - state.setMenu("open", false) - state.openDir(option.id) - }} - > - - {option.label} - - )} - - - - - { - state.setMenu("open", false) - state.copyPath() - }} - > - - {language.t("session.header.open.copyPath")} - - - - - - - ) -} diff --git a/packages/app/src/components/session/open-in-app.tsx b/packages/app/src/components/session/open-in-app.tsx deleted file mode 100644 index cd363d00c4..0000000000 --- a/packages/app/src/components/session/open-in-app.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import { createEffect, createMemo } from "solid-js" -import { createStore } from "solid-js/store" -import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" -import { useServer } from "@/context/server" -import { Persist, persisted } from "@/utils/persist" -import { showToast } from "@/utils/toast" - -export const OPEN_APPS = [ - "vscode", - "cursor", - "zed", - "textmate", - "antigravity", - "finder", - "terminal", - "iterm2", - "ghostty", - "warp", - "xcode", - "android-studio", - "powershell", - "sublime-text", -] as const - -export type OpenApp = (typeof OPEN_APPS)[number] -export type OpenAppOS = "macos" | "windows" | "linux" | "unknown" - -export const MAC_OPEN_APPS = [ - { - id: "vscode", - label: "session.header.open.app.vscode", - icon: "vscode", - openWith: "Visual Studio Code", - }, - { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" }, - { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" }, - { id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" }, - { - id: "antigravity", - label: "session.header.open.app.antigravity", - icon: "antigravity", - openWith: "Antigravity", - }, - { id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" }, - { id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" }, - { id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" }, - { id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" }, - { id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" }, - { - id: "android-studio", - label: "session.header.open.app.androidStudio", - icon: "android-studio", - openWith: "Android Studio", - }, - { - id: "sublime-text", - label: "session.header.open.app.sublimeText", - icon: "sublime-text", - openWith: "Sublime Text", - }, -] as const - -export const WINDOWS_OPEN_APPS = [ - { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, - { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, - { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, - { - id: "powershell", - label: "session.header.open.app.powershell", - icon: "powershell", - openWith: "powershell", - }, - { - id: "sublime-text", - label: "session.header.open.app.sublimeText", - icon: "sublime-text", - openWith: "Sublime Text", - }, -] as const - -export const LINUX_OPEN_APPS = [ - { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, - { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, - { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, - { - id: "sublime-text", - label: "session.header.open.app.sublimeText", - icon: "sublime-text", - openWith: "Sublime Text", - }, -] as const - -export function detectOpenAppOS(platform: ReturnType): OpenAppOS { - if (platform.platform === "desktop" && platform.os) return platform.os - if (typeof navigator !== "object") return "unknown" - const value = navigator.platform || navigator.userAgent - if (/Mac/i.test(value)) return "macos" - if (/Win/i.test(value)) return "windows" - if (/Linux/i.test(value)) return "linux" - return "unknown" -} - -export function openAppFileManager(os: OpenAppOS) { - if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const } - if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const } - return { label: "session.header.open.fileManager", icon: "finder" as const } -} - -export function openAppsForOS(os: OpenAppOS) { - if (os === "macos") return MAC_OPEN_APPS - if (os === "windows") return WINDOWS_OPEN_APPS - return LINUX_OPEN_APPS -} - -const showRequestError = (language: ReturnType, err: unknown) => { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) -} - -export function useOpenInApp(input: { directory: () => string }) { - const platform = usePlatform() - const server = useServer() - const language = useLanguage() - - const os = createMemo(() => detectOpenAppOS(platform)) - const apps = createMemo(() => openAppsForOS(os())) - const fileManager = createMemo(() => openAppFileManager(os())) - - const [exists, setExists] = createStore>>({ - finder: true, - }) - - createEffect(() => { - if (platform.platform !== "desktop") return - if (!platform.checkAppExists) return - - const list = apps() - - setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial>) - - void Promise.all( - list.map((app) => - Promise.resolve(platform.checkAppExists?.(app.openWith)) - .then((value) => Boolean(value)) - .catch(() => false) - .then((ok) => [app.id, ok] as const), - ), - ).then((entries) => { - setExists(Object.fromEntries(entries) as Partial>) - }) - }) - - const options = createMemo(() => { - return [ - { id: "finder", label: language.t(fileManager().label), icon: fileManager().icon }, - ...apps() - .filter((app) => exists[app.id]) - .map((app) => ({ ...app, label: language.t(app.label) })), - ] as const - }) - - const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp | "finder" })) - const [menu, setMenu] = createStore({ open: false }) - const [openRequest, setOpenRequest] = createStore({ - app: undefined as OpenApp | undefined, - }) - - const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) - const current = createMemo( - () => - options().find((o) => o.id === prefs.app) ?? - options()[0] ?? - ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const), - ) - const opening = createMemo(() => openRequest.app !== undefined) - - const selectApp = (app: OpenApp | "finder") => { - if (!options().some((item) => item.id === app)) return - setPrefs("app", app) - } - - const openDir = (app: OpenApp | "finder") => { - if (opening() || !canOpen() || !platform.openPath) return - const directory = input.directory() - if (!directory) return - - const item = options().find((o) => o.id === app) - const openWith = item && "openWith" in item ? item.openWith : undefined - setOpenRequest("app", app) - platform - .openPath(directory, openWith) - .catch((err: unknown) => showRequestError(language, err)) - .finally(() => { - setOpenRequest("app", undefined) - }) - } - - const copyPath = () => { - const directory = input.directory() - if (!directory) return - navigator.clipboard - .writeText(directory) - .then(() => { - showToast({ - variant: "success", - icon: "circle-check", - title: language.t("session.share.copy.copied"), - description: directory, - }) - }) - .catch((err: unknown) => showRequestError(language, err)) - } - - return { - canOpen, - opening, - current, - options, - menu, - setMenu, - openDir, - selectApp, - copyPath, - } -} diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts index 37cc0d5b5d..5b11bf1433 100644 --- a/packages/app/src/components/session/session-context-metrics.test.ts +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { Message } from "@opencode-ai/sdk/v2/client" -import { getSessionContext } from "./session-context-metrics" +import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics" const assistant = ( id: string, @@ -38,10 +38,10 @@ const user = (id: string) => { } describe("getSessionContext", () => { - test("computes token totals and usage from latest assistant with tokens", () => { + test("computes usage from latest assistant with tokens", () => { const messages = [ user("u1"), - assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 0.5), + assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5), assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25), ] const providers = [ @@ -60,8 +60,6 @@ describe("getSessionContext", () => { const ctx = getSessionContext(messages, providers) expect(ctx?.message.id).toBe("a2") - expect(ctx?.total).toBe(500) - expect(ctx?.input).toBe(300) expect(ctx?.usage).toBe(50) expect(ctx?.providerLabel).toBe("OpenAI") expect(ctx?.modelLabel).toBe("GPT-4.1") @@ -96,4 +94,15 @@ describe("getSessionContext", () => { expect(ctx).toBeUndefined() }) + + test("computes stored session token totals", () => { + expect( + getSessionTokenTotal({ + input: 10, + output: 20, + reasoning: 30, + cache: { read: 40, write: 50 }, + }), + ).toBe(150) + }) }) diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts index 30dc9e958b..84cc984abf 100644 --- a/packages/app/src/components/session/session-context-metrics.ts +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client" +import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client" type Provider = { id: string @@ -21,7 +21,6 @@ type Context = { modelLabel: string limit: number | undefined input: number - total: number usage: number | null } @@ -55,7 +54,6 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context | modelLabel: model?.name ?? message.modelID, limit, input: message.tokens.input, - total, usage: limit ? Math.round((total / limit) * 100) : null, } } @@ -63,3 +61,8 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context | export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) { return build(messages, providers) } + +export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) { + if (!tokens) return undefined + return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write +} diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 25ad5ab230..8757c69367 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { useSDK } from "@/context/sdk" import { useSessionLayout } from "@/pages/session/session-layout" -import { getSessionContext } from "./session-context-metrics" +import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { createSessionContextFormatter } from "./session-context-format" @@ -135,6 +135,7 @@ export function SessionContextTab() { ) const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) + const tokens = createMemo(() => info()?.tokens) const formatter = createMemo(() => createSessionContextFormatter(language.intl())) const cost = createMemo(() => { @@ -203,15 +204,14 @@ export function SessionContextTab() { { label: "context.stats.provider", value: providerLabel }, { label: "context.stats.model", value: modelLabel }, { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) }, - { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) }, + { label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) }, { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, - { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) }, - { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) }, - { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) }, + { label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) }, + { label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) }, + { label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) }, { label: "context.stats.cacheTokens", - value: () => - `${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`, + value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`, }, { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) }, { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) }, diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index dc6cdaa6f3..ef93bbe081 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -24,7 +24,6 @@ import { focusTerminalById } from "@/pages/session/helpers" import { useSessionLayout } from "@/pages/session/session-layout" import { messageAgentColor } from "@/utils/agent" import { decode64 } from "@/utils/base64" -import { fileManagerApp } from "@/utils/file-manager" import { Persist, persisted } from "@/utils/persist" import { StatusPopover, StatusPopoverV2 } from "../status-popover" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" @@ -32,7 +31,6 @@ import { Icon as IconV2 } 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 { reviewTooltipKeybind } from "../command-tooltip-keybind" -import { useTitlebarRightMount } from "../titlebar" const OPEN_APPS = [ "vscode", @@ -176,7 +174,11 @@ export function SessionHeader() { return LINUX_APPS }) - const fileManager = createMemo(() => fileManagerApp(os())) + const fileManager = createMemo(() => { + if (os() === "macos") return { label: "session.header.open.finder", icon: "finder" as const } + if (os() === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const } + return { label: "session.header.open.fileManager", icon: "finder" as const } + }) createEffect(() => { if (platform.platform !== "desktop") return @@ -282,16 +284,17 @@ export function SessionHeader() { } const [centerMount, setCenterMount] = createSignal(null) - const rightMount = useTitlebarRightMount() + const [rightMount, setRightMount] = createSignal(null) onMount(() => { setCenterMount(document.getElementById("opencode-titlebar-center")) + setRightMount(document.getElementById("opencode-titlebar-right")) }) return ( <> - + {(mount) => ( - +
- + {(keybind) => ( - {keybind} + {keybind()} )} @@ -319,9 +322,9 @@ export function SessionHeader() { )} - + {(mount) => ( - + diff --git a/packages/app/src/components/session/session-new-design-view.tsx b/packages/app/src/components/session/session-new-design-view.tsx new file mode 100644 index 0000000000..6993b8f5a8 --- /dev/null +++ b/packages/app/src/components/session/session-new-design-view.tsx @@ -0,0 +1,16 @@ +import type { JSX } from "solid-js" +import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2" +import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" + +export function NewSessionDesignView(props: { children: JSX.Element }) { + return ( +
+
+
+ +
{props.children}
+
+
+
+ ) +} diff --git a/packages/app/src/components/session/session-sortable-tab-v2.tsx b/packages/app/src/components/session/session-sortable-tab-v2.tsx deleted file mode 100644 index e109e5d2ec..0000000000 --- a/packages/app/src/components/session/session-sortable-tab-v2.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { createMemo, Show } from "solid-js" -import type { JSX } from "solid-js" -import { useSortable } from "@dnd-kit/solid/sortable" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { Tabs } from "@opencode-ai/ui/tabs" -import { useFile } from "@/context/file" -import { useLanguage } from "@/context/language" -import { useCommand } from "@/context/command" -import { FileVisual } from "./session-sortable-tab" - -export function SortableTabV2(props: { - tab: string - index: () => number - temporary?: boolean - onTabClose: (tab: string) => void - onTabDoubleClick?: (tab: string) => void -}): JSX.Element { - const file = useFile() - const language = useLanguage() - const command = useCommand() - const closeTabKeybind = createMemo(() => command.keybindParts("tab.close")) - const sortable = useSortable({ - get id() { - return props.tab - }, - get index() { - return props.index() - }, - }) - const path = createMemo(() => file.pathFromTab(props.tab)) - const content = createMemo(() => { - const value = path() - if (!value) return - return - }) - return ( -
-
- - {language.t("common.closeTab")} - 0}> - - - - } - placement="bottom" - gutter={10} - > - props.onTabClose(props.tab)} - aria-label={language.t("common.closeTab")} - /> - - } - hideCloseButton - onMiddleClick={() => props.onTabClose(props.tab)} - onDblClick={() => props.onTabDoubleClick?.(props.tab)} - > - {(value) => value()} - -
-
- ) -} diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index d78f392941..f04228ca66 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -10,7 +10,7 @@ import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { useCommand } from "@/context/command" -export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element { +export function FileVisual(props: { path: string; active?: boolean }): JSX.Element { return (
- - {getFilename(props.path)} - + {getFilename(props.path)}
) } -export function SortableTab(props: { - tab: string - temporary?: boolean - onTabClose: (tab: string) => void - onTabDoubleClick?: (tab: string) => void -}): JSX.Element { +export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element { const file = useFile() const language = useLanguage() const command = useCommand() @@ -43,7 +36,7 @@ export function SortableTab(props: { const content = createMemo(() => { const value = path() if (!value) return - return + return }) return (
@@ -68,7 +61,6 @@ export function SortableTab(props: { } hideCloseButton onMiddleClick={() => props.onTabClose(props.tab)} - onDblClick={() => props.onTabDoubleClick?.(props.tab)} > {(value) => value()} diff --git a/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx b/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx deleted file mode 100644 index a27b29668e..0000000000 --- a/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import type { JSX } from "solid-js" -import { Show, createEffect, onCleanup } from "solid-js" -import { createStore } from "solid-js/store" -import { useSortable } from "@dnd-kit/solid/sortable" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { Tabs } from "@opencode-ai/ui/tabs" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Icon } from "@opencode-ai/ui/icon" -import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" -import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title" -import { useTerminal, type LocalPTY } from "@/context/terminal" -import { useLanguage } from "@/context/language" -import { focusTerminalById } from "@/pages/session/helpers" - -export function SortableTerminalTabV2(props: { - terminal: LocalPTY - index: () => number - newLayout: boolean - onClose?: () => void -}): JSX.Element { - const terminal = useTerminal() - const language = useLanguage() - const sortable = useSortable({ - get id() { - return props.terminal.id - }, - get index() { - return props.index() - }, - }) - const [store, setStore] = createStore({ - editing: false, - title: props.terminal.title, - menuOpen: false, - menuPosition: { x: 0, y: 0 }, - blurEnabled: false, - }) - let input: HTMLInputElement | undefined - let blurFrame: number | undefined - let editRequested = false - - const isDefaultTitle = () => { - const number = props.terminal.titleNumber - if (!Number.isFinite(number) || number <= 0) return false - return isDefaultTerminalTitle(props.terminal.title, number) - } - - const label = () => { - language.locale() - if (props.terminal.title && !isDefaultTitle()) return props.terminal.title - - const number = props.terminal.titleNumber - if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number }) - if (props.terminal.title) return props.terminal.title - return language.t("terminal.title") - } - - const close = () => { - const count = terminal.all().length - void terminal.close(props.terminal.id) - if (count === 1) { - props.onClose?.() - } - } - - const focus = () => { - if (store.editing) return - terminal.requestFocus(props.terminal.id) - terminal.open(props.terminal.id) - if (document.activeElement instanceof HTMLElement) document.activeElement.blur() - focusTerminalById(props.terminal.id) - const input = document.getElementById(`terminal-wrapper-${props.terminal.id}`)?.querySelector("textarea") - if (input === document.activeElement) terminal.consumeFocus(props.terminal.id) - } - - const edit = (e?: Event) => { - if (e) { - e.stopPropagation() - e.preventDefault() - } - - setStore("blurEnabled", false) - setStore("title", props.terminal.title) - setStore("editing", true) - } - - const save = () => { - if (!store.blurEnabled) return - - const value = store.title.trim() - if (value && value !== props.terminal.title) { - terminal.update({ id: props.terminal.id, title: value }) - } - setStore("editing", false) - } - - const keydown = (e: KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault() - save() - return - } - if (e.key === "Escape") { - e.preventDefault() - setStore("editing", false) - } - } - - const menu = (e: MouseEvent) => { - e.preventDefault() - setStore("menuPosition", { x: e.clientX, y: e.clientY }) - setStore("menuOpen", true) - } - - createEffect(() => { - if (!store.editing) return - if (!input) return - input.focus() - input.select() - if (blurFrame !== undefined) cancelAnimationFrame(blurFrame) - blurFrame = requestAnimationFrame(() => { - blurFrame = undefined - setStore("blurEnabled", true) - }) - }) - - onCleanup(() => { - if (blurFrame === undefined) return - cancelAnimationFrame(blurFrame) - }) - - return ( -
- - e.preventDefault()} - onContextMenu={menu} - class="!shadow-none" - classes={{ - button: "border-0 outline-none focus:outline-none focus-visible:outline-none !shadow-none !ring-0", - }} - closeButton={ - { - e.stopPropagation() - close() - }} - aria-label={language.t("terminal.close")} - /> - } - > - - {label()} - - - -
- setStore("title", e.currentTarget.value)} - onBlur={save} - onKeyDown={keydown} - onMouseDown={(e) => e.stopPropagation()} - class="bg-transparent border-none outline-none text-sm min-w-0 flex-1" - /> -
-
- setStore("menuOpen", open)}> - - { - if (!editRequested) return - e.preventDefault() - editRequested = false - requestAnimationFrame(() => edit()) - }} - > - (editRequested = true)}> - - {language.t("common.rename")} - - - - {language.t("common.close")} - - - - -
- } - > - - - { - // Switch on mousedown to shave the press-release delay off tab switches. - if (e.button !== 0) return - if (store.editing) return - focus() - }} - onClick={(e) => { - // Mouse navigation already happened on mousedown; detail 0 means keyboard activation. - if (e.detail > 0) return - focus() - }} - closeButton={ - { - e.stopPropagation() - close() - }} - aria-label={language.t("terminal.close")} - /> - } - hideCloseButton - onMiddleClick={close} - > - - {label()} - - - -
- setStore("title", e.currentTarget.value)} - onBlur={save} - onKeyDown={keydown} - onMouseDown={(e) => e.stopPropagation()} - class="bg-transparent border-none outline-none min-w-0 flex-1 p-0 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:440] [font-variation-settings:'slnt'_0] [font-variant-numeric:tabular-nums]" - /> -
-
-
- - { - if (!editRequested) return - e.preventDefault() - editRequested = false - requestAnimationFrame(() => edit()) - }} - > - (editRequested = true)}>{language.t("common.rename")} - {language.t("common.close")} - - -
- -
- ) -} diff --git a/packages/app/src/components/settings-dialog.tsx b/packages/app/src/components/settings-dialog.tsx index 46dd89ade8..d2cc85c04e 100644 --- a/packages/app/src/components/settings-dialog.tsx +++ b/packages/app/src/components/settings-dialog.tsx @@ -4,7 +4,7 @@ import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useDialog } from "@opencode-ai/ui/context/dialog" -export function useSettingsDialog(defaultValue?: string) { +export function useSettingsDialog() { const dialog = useDialog() const params = useParams<{ id?: string }>() let run = 0 @@ -19,7 +19,7 @@ export function useSettingsDialog(defaultValue?: string) { const sessionID = params.id void import("@/components/settings-v2").then((module) => { if (dead || run !== current) return - void dialog.show(() => ) + void dialog.show(() => ) }) } } diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 8adaa1f0fd..cbbf33d2b1 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -5,7 +5,6 @@ import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { TextField } from "@opencode-ai/ui/text-field" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { Tag } from "@opencode-ai/ui/v2/badge-v2" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useParams } from "@solidjs/router" @@ -29,7 +28,7 @@ import { } from "@/context/settings" import { decode64 } from "@/utils/base64" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" -import { ExternalLink } from "./external-link" +import { Link } from "./link" import { SettingsList } from "./settings-list" let demoSoundState = { @@ -127,14 +126,11 @@ export const SettingsGeneral: Component = () => { const serverSdk = useServerSDK() const [shells] = createResource( - async () => { - const sdk = serverSdk() - if ((await sdk.protocol) === "v1") { - return (await sdk.client.pty.shells()).data ?? [] - } - // return (await sdk.api.pty.shells()).data - return [] as ShellOption[] - }, + () => + serverSdk() + .client.pty.shells() + .then((res) => res.data ?? []) + .catch(() => [] as ShellOption[]), { initialValue: [] as ShellOption[] }, ) @@ -252,50 +248,6 @@ export const SettingsGeneral: Component = () => { triggerVariant: "settings" as const, }) - const InterfaceSection = () => ( -
- - - {language.t("settings.general.row.newInterface.title")} - {language.t("settings.general.row.newInterface.badge")} - - } - description={language.t("settings.general.row.newInterface.description")} - > -
- { - settings.general.setNewLayoutDesigns(checked) - if (!checked) return - void import("@/components/settings-v2").then((module) => { - void dialog.show(() => ) - }) - }} - /> -
-
-
-
- ) - - const InterfaceNoticeSection = () => ( -
- - - - - -
- ) - const GeneralSection = () => (
@@ -382,6 +334,24 @@ export const SettingsGeneral: Component = () => { />
+ + +
+ { + settings.general.setNewLayoutDesigns(checked) + if (!checked) return + void import("@/components/settings-v2").then((module) => { + dialog.show(() => ) + }) + }} + /> +
+
) @@ -470,6 +440,11 @@ export const SettingsGeneral: Component = () => { 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() + }} variant="secondary" size="small" triggerVariant="settings" @@ -482,7 +457,7 @@ export const SettingsGeneral: Component = () => { description={ <> {language.t("settings.general.row.theme.description")}{" "} - {language.t("common.learnMore")} + {language.t("common.learnMore")} } > @@ -496,6 +471,11 @@ export const SettingsGeneral: Component = () => { if (!option) return theme.setTheme(option.id) }} + onHighlight={(option) => { + if (!option) return + theme.previewTheme(option.id) + return () => theme.cancelPreview() + }} variant="secondary" size="small" triggerVariant="settings" @@ -748,14 +728,6 @@ export const SettingsGeneral: Component = () => {
- - - - - - - - diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index ce74be4586..98f6c9ffa0 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -5,21 +5,28 @@ import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { TextField } from "@opencode-ai/ui/text-field" -import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" -import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" -import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { showToast } from "@/utils/toast" import fuzzysort from "fuzzysort" -import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/context/command" +import { formatKeybind, parseKeybind, useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { SettingsList } from "./settings-list" -import { SettingsListV2 } from "./settings-v2/parts/list" +const ButtonV2 = lazy(() => import("@opencode-ai/ui/v2/button-v2").then((module) => ({ default: module.ButtonV2 }))) const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon }))) +const IconButtonV2 = lazy(() => + import("@opencode-ai/ui/v2/icon-button-v2").then((module) => ({ default: module.IconButtonV2 })), +) +const TextInputV2 = lazy(() => + import("@opencode-ai/ui/v2/text-input-v2").then((module) => ({ default: module.TextInputV2 })), +) +const SettingsListV2 = lazy(() => + import("./settings-v2/parts/list").then((module) => ({ default: module.SettingsListV2 })), +) const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) const PALETTE_ID = "command.palette" +const DEFAULT_PALETTE_KEYBIND = "mod+shift+p" type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt" @@ -30,8 +37,6 @@ type KeybindMeta = { type KeybindMap = Record type CommandContext = ReturnType -type LanguageContext = ReturnType -type SettingsContext = ReturnType const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"] @@ -124,7 +129,7 @@ function keybinds(value: unknown): KeybindMap { return value as KeybindMap } -function listFor(command: Pick, map: KeybindMap, palette: string) { +function listFor(command: CommandContext, map: KeybindMap, palette: string) { const out = new Map() out.set(PALETTE_ID, { title: palette, group: "General" }) @@ -264,274 +269,7 @@ function useKeyCapture(input: { }) } -export function createKeybindSettingsController( - input: { - command: Pick - settings: { - current: { keybinds: unknown } - keybinds: Pick - } - target?: Document - notify?: (toast: { title: string; description: string }) => void - }, - language: Pick = useLanguage(), -) { - const [store, setStore] = createStore({ active: null as string | null }) - const overrides = createMemo(() => keybinds(input.settings.current.keybinds)) - const list = createMemo(() => { - language.locale() - return listFor(input.command, overrides(), language.t("command.palette")) - }) - const grouped = createMemo(() => groupedFor(list())) - const title = (id: string) => list().get(id)?.title ?? "" - const effective = (id: string) => { - if (id === PALETTE_ID) return input.settings.keybinds.get(id) ?? DEFAULT_PALETTE_KEYBIND - - const custom = input.settings.keybinds.get(id) - if (typeof custom === "string") return custom - - const live = input.command.options.find((item) => item.id === id) - if (live?.keybind) return live.keybind - return input.command.catalog.find((item) => item.id === id)?.keybind - } - const used = createMemo(() => { - const value = new Map() - - for (const id of list().keys()) { - for (const signature of signatures(effective(id))) { - const items = value.get(signature) - if (items) { - items.push({ id, title: title(id) }) - continue - } - value.set(signature, [{ id, title: title(id) }]) - } - } - - return value - }) - const stop = () => { - if (!store.active) return - setStore("active", null) - input.command.keybinds(true) - } - const toggle = (id: string) => { - if (store.active === id) { - stop() - return - } - if (store.active) stop() - setStore("active", id) - input.command.keybinds(false) - } - const notify = input.notify ?? ((toast: { title: string; description: string }) => showToast(toast)) - - const handle = (event: KeyboardEvent) => { - const id = store.active - if (!id) return - - event.preventDefault() - event.stopPropagation() - event.stopImmediatePropagation() - - if (event.key === "Escape") { - stop() - return - } - - const clear = - (event.key === "Backspace" || event.key === "Delete") && - !event.ctrlKey && - !event.metaKey && - !event.altKey && - !event.shiftKey - if (clear) { - input.settings.keybinds.set(id, "none") - stop() - return - } - - const next = recordKeybind(event) - if (!next) return - - const conflicts = new Map() - for (const signature of signatures(next)) { - for (const item of used().get(signature) ?? []) { - if (item.id === id) continue - conflicts.set(item.id, item.title) - } - } - - if (conflicts.size > 0) { - notify({ - title: language.t("settings.shortcuts.conflict.title"), - description: language.t("settings.shortcuts.conflict.description", { - keybind: formatKeybind(next, language.t), - titles: [...conflicts.values()].join(", "), - }), - }) - return - } - - input.settings.keybinds.set(id, next) - stop() - } - - const target = input.target ?? (typeof document === "object" ? document : undefined) - if (target) makeEventListener(target, "keydown", handle, { capture: true }) - - onCleanup(() => { - if (store.active) input.command.keybinds(true) - }) - - return { - catalog: { - groups: GROUPS, - filtered: (query: string) => - filteredFor(query, list(), grouped(), (id) => formatKeybind(effective(id) ?? "", language.t)), - title, - keybind: (id: string) => formatKeybind(effective(id) ?? "", language.t), - }, - capture: { - active: () => store.active, - toggle, - }, - settings: { - hasOverrides: () => Object.values(overrides()).some((value) => typeof value === "string"), - reset: () => { - stop() - input.settings.keybinds.resetAll() - notify({ - title: language.t("settings.shortcuts.reset.toast.title"), - description: language.t("settings.shortcuts.reset.toast.description"), - }) - }, - }, - } -} - -function SettingsKeybindsV2() { - const command = useCommand() - const settings = useSettings() - const controller = createKeybindSettingsController({ - command, - settings, - }) - - return ( - - ) -} - -function SettingsKeybindsV2View(props: { - groups: KeybindGroup[] - filtered: (query: string) => Map - title: (id: string) => string - keybind: (id: string) => string - active: () => string | null - onCapture: (id: string) => void - hasOverrides: () => boolean - onReset: () => void -}) { - const language = useLanguage() - const [store, setStore] = createStore({ filter: "" }) - const filtered = createMemo(() => props.filtered(store.filter)) - const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0)) - - return ( - <> -
-
-

{language.t("settings.shortcuts.title")}

- - {language.t("settings.shortcuts.reset.button")} - -
- -
-
-
- - {(group) => ( - 0}> -
-

{language.t(groupKey[group])}

- - - {(id) => ( -
- {props.title(id)} - -
- )} -
-
-
-
- )} -
- -
- {language.t("settings.shortcuts.search.empty")} - "{store.filter}" -
-
-
-
- - ) -} - export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => { - if (props.v2) return - const command = useCommand() const language = useLanguage() const settings = useSettings() @@ -745,37 +483,78 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => { ) return ( -
-
-
-
-

{language.t("settings.shortcuts.title")}

- -
+ +
+
+
+

{language.t("settings.shortcuts.title")}

+ +
-
- - + + setStore("filter", v)} + placeholder={language.t("settings.shortcuts.search.placeholder")} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + class="flex-1" + /> + + setStore("filter", "")} /> + +
+
+
+ {groups} +
+ } + > + <> +
+
+

{language.t("settings.shortcuts.title")}

+ + {language.t("settings.shortcuts.reset.button")} + +
+
-
- {groups} -
+
{groups}
+ + ) } diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 080e6c517b..24e7a60104 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -6,9 +6,10 @@ import { showToast } from "@/utils/toast" import { popularProviders, useProviders } from "@/hooks/use-providers" 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 { SettingsList } from "./settings-list" import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker" @@ -27,27 +28,20 @@ const PROVIDER_NOTES = [ { match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" }, ] as const -export const SettingsProviders: Component<{ onBack?: () => void }> = (props) => { +export const SettingsProviders: Component = () => { return ( - + ) } -const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => { +const SettingsProvidersContent: Component = () => { const dialog = useDialog() const language = useLanguage() const serverSDK = useServerSDK() - const protocol = useServerProtocol() const serverSync = useServerSync() - const providers = useProviders(() => undefined) - const providerConnect = useProviderConnectController({ onBack: props.onBack }) - - const connect = (provider?: string) => { - providerConnect.select(provider) - void dialog.show(() => ) - } + const providers = useProviders() const connected = createMemo(() => { return providers @@ -84,8 +78,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => 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 @@ -98,7 +91,6 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => } 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) @@ -214,46 +206,53 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => {(key) => {language.t(key())}}
-
)}
- -
-
-
- - {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..5fb2fa892c 100644 --- a/packages/app/src/components/titlebar-tab-nav.css +++ b/packages/app/src/components/titlebar-tab-nav.css @@ -9,86 +9,49 @@ 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 { +[data-titlebar-tab-slot]:not(:first-child)::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 { +[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-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 { - display: none; +[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"]: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"]: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..9d9df7d2c9 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -1,43 +1,42 @@ 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 { ServerConnection, serverName } from "@/context/server" -import { displayName, projectForSession } from "@/pages/layout/helpers" +import { useLanguage } from "@/context/language" +import { ServerConnection } from "@/context/server" +import { 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" import "./titlebar-tab-nav.css" -// MouseEvent.button uses 1 for the middle/wheel button. -const MIDDLE_MOUSE_BUTTON = 1 - export function TabNavItem(props: { ref?: Ref href: string 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() @@ -54,28 +53,6 @@ export function TabNavItem(props: { if (!session) return return projectForSession(session, serverCtx()?.projects.list() ?? []) }) - const title = createMemo(() => props.session()?.title ?? props.fallbackTitle) - - const projectName = createMemo(() => { - const session = props.session() - if (!session) return - return displayName(project() ?? { worktree: session.directory }) - }) - const previewPath = createMemo(() => { - const session = props.session() - if (!session) return - const home = serverCtx()?.sync.data.path.home - return home ? session.directory.replace(home, "~") : session.directory - }) - // Only label the server when multiple servers are connected. - const serverLabel = createMemo(() => { - if (global.servers.list().length <= 1) return - const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server) - return conn ? serverName(conn) : undefined - }) - - const [popoverOpen, setPopoverOpen] = createSignal(false) - const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session() const measureTitleOverflow = () => { if (!titleEl || editing()) { @@ -94,7 +71,7 @@ export function TabNavItem(props: { } createEffect(() => { - title() + props.session()?.title props.forceTruncate editing() scheduleTitleOverflow() @@ -113,34 +90,55 @@ 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(() => { if (editing()) return if (!titleEl) return - const value = title() - if (value === undefined) return - titleEl.textContent = value + const title = props.session()?.title + if (title === undefined) return + titleEl.textContent = title }) 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 @@ -170,7 +168,7 @@ export function TabNavItem(props: { onCleanup(cleanup) }) - const tab = ( + return (
{ tabRoot = el @@ -180,104 +178,86 @@ 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() - event.stopPropagation() - }} - onAuxClick={(event) => { - if (event.button !== MIDDLE_MOUSE_BUTTON) return + if (event.button !== 1) return 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]" - > - - { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => { + 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]" + > + + + + { + titleEl = el + titleEl.textContent = session().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 = session().title + void closeRename(false) + }} + onBlur={() => void closeRename(true)} + onPointerDown={(event) => { + if (!editing()) return + event.stopPropagation() + }} + onClick={(event) => { + if (!editing()) return + event.preventDefault() + }} /> - )} - - - { - 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() - }} - /> - + + ) + }} + -
+
) - - return ( - { - if (value && previewBlocked()) return - setPopoverOpen(value) - }} - data={{ - projectName: projectName(), - title: props.session()?.title, - path: previewPath(), - serverName: serverLabel(), - }} - /> - ) } export function DraftTabItem(props: { @@ -335,16 +298,11 @@ 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 - event.preventDefault() - event.stopPropagation() - }} - onAuxClick={(event) => { - if (event.button !== MIDDLE_MOUSE_BUTTON) return + if (event.button !== 1) return closeTab(event) }} > @@ -357,16 +315,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 +332,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 deleted file mode 100644 index e3b96dcaa4..0000000000 --- a/packages/app/src/components/titlebar-tab-popover.css +++ /dev/null @@ -1,127 +0,0 @@ -[data-component="session-tab-popover-trigger"] { - display: flex; - width: 100%; - min-width: 0; -} - -[data-component="session-tab-popover"] { - z-index: 50; - box-sizing: border-box; - width: 256px; - display: flex; - flex-direction: column; - gap: 6px; - padding: 12px; - - background: var(--v2-background-bg-base); - border-radius: 6px; - box-shadow: var(--v2-elevation-floating); - - color: var(--v2-text-text-base); - font-variant-numeric: tabular-nums; - user-select: none; - pointer-events: none; - - transform-origin: var(--kb-hovercard-content-transform-origin); - /* Entrance: instant (trying no animation — may revert to 120ms ease-out). */ - animation: none; - - &:focus-visible, - &:focus-within { - outline: none; - } - - /* Exit: instant (trying no animation — may revert to 80ms ease-out). */ - &[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; - } - - [data-slot="header"] { - display: flex; - flex-direction: column; - gap: 6px; - width: 100%; - } - - [data-slot="project"] { - font-weight: 440; - font-size: 13px; - line-height: 1.5; - letter-spacing: -0.04px; - color: var(--v2-text-text-muted); - } - - [data-slot="title"] { - font-weight: 530; - font-size: 13px; - line-height: 1.5; - letter-spacing: -0.04px; - color: var(--v2-text-text-base); - overflow-wrap: anywhere; - } - - [data-slot="row"] { - display: flex; - width: 100%; - min-width: 0; - } - - [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; - font-weight: 440; - font-size: 13px; - line-height: 1.5; - letter-spacing: -0.04px; - color: var(--v2-text-text-muted); - } - - [data-slot="server"] { - font-weight: 440; - font-size: 13px; - line-height: 1; - letter-spacing: -0.04px; - color: var(--v2-text-text-muted); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } -} - -@keyframes sessionTabPopoverIn { - from { - opacity: 0; - transform: scale(0.96); - } - to { - opacity: 1; - transform: scale(1); - } -} - -@keyframes sessionTabPopoverOut { - from { - opacity: 1; - transform: scale(1); - } - to { - opacity: 0; - transform: scale(0.96); - } -} - -@media (prefers-reduced-motion: reduce) { - [data-component="session-tab-popover"], - [data-component="session-tab-popover"][data-closed] { - animation: none; - } -} diff --git a/packages/app/src/components/titlebar-tab-popover.tsx b/packages/app/src/components/titlebar-tab-popover.tsx deleted file mode 100644 index 0689302ca6..0000000000 --- a/packages/app/src/components/titlebar-tab-popover.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { HoverCard as Kobalte } from "@kobalte/core/hover-card" -import { createSignal, Show, type JSXElement } from "solid-js" -import "./titlebar-tab-popover.css" - -// Initial hover delay before the preview appears, per design. -const OPEN_DELAY = 2_000 -// 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 -let lastClosedAt = 0 - -export interface TabPreviewData { - projectName?: string - title?: string - path?: string - serverName?: string -} - -export function TabPreviewPopover(props: { - trigger: JSXElement - open: boolean - onOpenChange: (open: boolean) => void - 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 handleOpenChange = (open: boolean) => { - if (open) setInstant(warm()) - else lastClosedAt = Date.now() - props.onOpenChange(open) - } - - return ( - - - {props.trigger} - - - { - // Portalled content lives outside the themed subtree, so mirror the - // active theme like the v2 tooltip does. - const theme = triggerEl?.closest("[data-theme]")?.getAttribute("data-theme") - if (theme) el.setAttribute("data-theme", theme) - }} - data-component="session-tab-popover" - data-instant={instant() || undefined} - > -
- - {props.data.projectName} - - - {props.data.title} - -
- - -
- {props.data.path} -
-
- - -
{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..ef404157e4 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,22 @@ 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 sortable = useSortable({ get id() { return props.id @@ -42,50 +40,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() @@ -94,31 +50,8 @@ function SessionTabEntry(props: { ({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined), ) 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 +72,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 +81,38 @@ 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} + 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()} /> - +
) } @@ -191,7 +140,6 @@ function DraftTabSlot(props: { ref={sortable.ref} data-titlebar-tab-slot data-tab-key={props.id} - data-active={props.active()} class="relative flex w-56 min-w-7 max-w-56 flex-shrink" > Tab | undefined + activeServerKey: ServerConnection.Key forceTruncate: boolean onNavigate: (tab: Tab, el?: HTMLDivElement) => void onClose: (tab: Tab) => void @@ -220,39 +169,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 +201,7 @@ export function TitlebarTabStrip(props: { createEffect(() => { props.tabs.length - visibleTabIds() + tabIds() refreshOverflow() }) @@ -316,29 +237,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 +261,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 +282,7 @@ export function TitlebarTabStrip(props: { props.currentTab() === tab} title={language.t("command.session.new")} onNavigate={(element) => { @@ -402,7 +316,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..b826001387 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 +259,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 +317,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 +362,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 +373,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 +421,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 +455,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl { @@ -391,31 +464,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 +506,14 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
+ {/*
*/}
-
@@ -564,10 +643,12 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl "pr-2": !windows(), }} data-tauri-drag-region + onMouseDown={drag} >
-
+ {!tauriApi() &&
} +
@@ -630,27 +711,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..2e85f4850a 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -2,152 +2,48 @@ 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() - - await bootstrapDirectory({ - directory: "/project", - scope: ServerScope.local, - mcp: true, - global: { - config: {} satisfies Config, - path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, - project: [{ id: "project", worktree: "/project" } as Project], - provider, + const [store, setStore] = createStore({ + status: "loading", + agent: [], + command: [], + 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" }, - sdk: { - app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) }, - config: { - get: async () => { - legacyConfigReads.push("directory") - return { data: {} } - }, - }, - session: { status: async () => ({ data: {} }) }, - vcs: { get: async () => ({ data: undefined }) }, - command: { - list: async () => { - mcpReads.push("command") - return { data: [] } - }, - }, - permission: { list: async () => ({ data: [] }) }, - question: { list: async () => ({ data: [] }) }, - v2: { reference: { list: async () => ({ data: { data: [] } }) } }, - mcp: { - status: async () => { - mcpReads.push("status") - 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"), + session_diff: {}, + todo: {}, + permission: {}, + question: {}, + mcp_ready: true, + mcp: {}, + lsp_ready: true, + lsp: [], + vcs: undefined, + limit: 5, + message: {}, + part: {}, + part_text_accum_delta: {}, }) - expect(store.status).toBe("partial") - - 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, @@ -159,20 +55,32 @@ describe("bootstrapDirectory", () => { provider, }, sdk: { - config: { - get: async () => { - throw new Error("legacy directory config should not be called") + app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) }, + config: { get: async () => ({ data: {} }) }, + session: { status: async () => ({ data: {} }) }, + vcs: { get: async () => ({ data: undefined }) }, + command: { + list: async () => { + mcpReads.push("command") + return { data: [] } }, }, + permission: { list: async () => ({ data: [] }) }, + question: { list: async () => ({ data: [] }) }, + mcp: { + status: async () => { + mcpReads.push("status") + 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("v2"), }) expect(store.status).toBe("partial") @@ -180,151 +88,21 @@ describe("bootstrapDirectory", () => { 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..5da05d19cf 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -6,23 +6,8 @@ import type { Project, ProviderAuthResponse, QuestionRequest, - 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 +15,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 { loadMcpQuery } 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 +81,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 +104,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 +112,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,127 +161,38 @@ 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!)) - }, - }) - -export const loadReferencesQuery = ( - scope: ServerScope, - directory: string, - api: ReferenceListApi, - legacy?: OpencodeClient, - protocol?: Promise, -) => - 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(() => []), - placeholderData: [], + queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)), }) export async function bootstrapDirectory(input: { @@ -332,17 +200,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 +213,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 +232,61 @@ 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 ?? [])))), + () => + 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 +310,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 +338,16 @@ 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.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..31c7519d84 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -34,9 +34,7 @@ const queryOptionsApi = { }), agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }), mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }), - mcpResources: (directory: string) => ({ queryKey: [directory, "mcpResources"], queryFn: async () => ({}) }), lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }), - references: (directory: string) => ({ queryKey: [directory, "references"], queryFn: async () => [] }), sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }), } as unknown as QueryOptionsApi @@ -199,18 +197,14 @@ describe("createChildStoreManager", () => { try { if (!manager) throw new Error("manager required") const [store, setStore] = manager.child("/project", { bootstrap: false }) - expect(querySingles.length - offset).toBe(6) + expect(querySingles.length - offset).toBe(4) const query = querySingles[offset + 1] - const resourceQuery = querySingles[offset + 2] if (!query) throw new Error("query required") - if (!resourceQuery) throw new Error("resource query required") expect(query().enabled).toBe(false) - expect(resourceQuery().enabled).toBe(false) setStore("status", "complete") manager.child("/project", { bootstrap: false, mcp: true }) expect(query().enabled).toBe(true) - expect(resourceQuery().enabled).toBe(true) expect(store.mcp).toEqual({ demo: { status: "disabled" } }) expect(mcpLoads).toEqual(["/project"]) @@ -221,56 +215,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..65011e8b09 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,18 @@ 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 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: {} } @@ -223,9 +210,6 @@ export function createChildStoreManager(input: { status: "loading" as const, agent: [], command: [], - get reference() { - return referenceQuery.isLoading ? [] : (referenceQuery.data ?? []) - }, session: [], sessionTotal: 0, session_status: {}, @@ -243,11 +227,8 @@ export function createChildStoreManager(input: { get mcp() { return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {}) }, - get mcp_resource() { - return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {}) - }, get lsp_ready() { - return instanceQueriesEnabled() && !lspQuery.isLoading + return !lspQuery.isLoading }, get lsp() { return lspQuery.isLoading ? [] : (lspQuery.data ?? []) @@ -255,14 +236,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 +282,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 +293,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 +306,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 +352,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..bca3f85720 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" @@ -112,7 +112,6 @@ export function applyDirectoryEvent(input: { push: (directory: string) => void directory: string loadLsp: () => void - loadReferences?: () => void vcsCache?: VcsCache setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void retainedLimit?: number @@ -146,14 +145,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 +171,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 +181,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": { @@ -470,9 +404,5 @@ export function applyDirectoryEvent(input: { input.loadLsp() break } - case "reference.updated": { - input.loadReferences?.() - break - } } } 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..9fb86d6d35 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -1,21 +1,21 @@ import type { Agent, + Command, Config, LspStatus, + McpStatus, Message, Part, Path, PermissionRequest, QuestionRequest, - 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,8 +33,7 @@ export type ProjectMeta = { export type State = { status: "loading" | "partial" | "complete" agent: Agent[] - command: CommandInfo[] - reference: ReferenceInfo[] + command: Command[] project: string projectMeta: ProjectMeta | undefined icon: string | undefined @@ -49,7 +48,7 @@ export type State = { } session_working(id: string): boolean session_diff: { - [sessionID: string]: FileDiffInfo[] + [sessionID: string]: SnapshotFileDiff[] } todo: { [sessionID: string]: Todo[] @@ -62,10 +61,7 @@ export type State = { } mcp_ready: boolean mcp: { - [name: string]: McpServer["status"] - } - mcp_resource: { - [key: string]: McpResource + [name: string]: McpStatus } lsp_ready: boolean lsp: LspStatus[] @@ -74,9 +70,6 @@ export type State = { message: { [sessionID: string]: Message[] } - session_message: { - [sessionID: string]: SessionMessageInfo[] - } part: { [messageID: string]: Part[] } @@ -129,6 +122,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..eb97b400c8 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 } @@ -31,7 +28,6 @@ const DEFAULT_SIDEBAR_WIDTH = 344 const DEFAULT_FILE_TREE_WIDTH = 200 const DEFAULT_SESSION_WIDTH = 600 const DEFAULT_TERMINAL_HEIGHT = 280 -const DEFAULT_REVIEW_PANEL_OPENED = false export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] export function getAvatarColors(key?: string) { @@ -48,28 +44,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,8 +77,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 = | { type: "home" } @@ -95,6 +84,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 +124,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" } @@ -213,8 +210,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( if (!isRecord(review)) return review if (typeof review.panelOpened === "boolean") return review - const opened = - isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : DEFAULT_REVIEW_PANEL_OPENED + const opened = isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : true return { ...review, panelOpened: opened, @@ -283,7 +279,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, review: { diffStyle: "split" as ReviewDiffStyle, - panelOpened: DEFAULT_REVIEW_PANEL_OPENED, + panelOpened: true, }, fileTree: { opened: false, @@ -306,10 +302,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, }), ) - const [ephemeral, setEphemeral] = createStore({ - reviewPanelSource: "other" as ReviewPanelSource, - sessionTabPreview: {} as Record, - }) const MAX_SESSION_KEYS = 50 const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000 @@ -367,12 +359,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 +487,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 +556,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 +607,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 @@ -695,7 +662,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( diffStyle: createMemo(() => store.review?.diffStyle ?? "split"), setDiffStyle(diffStyle: ReviewDiffStyle) { if (!store.review) { - setStore("review", { diffStyle, panelOpened: DEFAULT_REVIEW_PANEL_OPENED }) + setStore("review", { diffStyle, panelOpened: true }) return } setStore("review", "diffStyle", diffStyle) @@ -809,17 +776,8 @@ 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")) + const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true) function setTerminalOpened(next: boolean) { const current = store.terminal @@ -833,26 +791,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("terminal", "opened", next) } - function setReviewPanelOpened(next: boolean, source: ReviewPanelSource) { - const nextSource = next ? source : "other" + function setReviewPanelOpened(next: boolean) { const current = store.review if (!current) { - batch(() => { - setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next }) - setEphemeral("reviewPanelSource", nextSource) - }) + setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next }) return } - const value = current.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED - if (value === next) { - if (ephemeral.reviewPanelSource !== nextSource) setEphemeral("reviewPanelSource", nextSource) - return - } - batch(() => { - setStore("review", "panelOpened", next) - setEphemeral("reviewPanelSource", nextSource) - }) + const value = current.panelOpened ?? true + if (value === next) return + setStore("review", "panelOpened", next) } return { @@ -888,44 +836,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, reviewPanel: { opened: reviewPanelOpened, - source: reviewPanelSource, - open(source: ReviewPanelSource = "other") { - setReviewPanelOpened(true, source) + open() { + setReviewPanelOpened(true) }, close() { - setReviewPanelOpened(false, "other") + setReviewPanelOpened(false) }, toggle() { - setReviewPanelOpened(!reviewPanelOpened(), "other") + setReviewPanelOpened(!reviewPanelOpened()) }, }, 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 +918,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 +934,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..2a71bcea01 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, @@ -84,7 +80,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const [store, setStore] = createStore<{ current?: string draft?: State - promoting?: State last?: { type: "agent" | "model" | "variant" agent?: string @@ -111,7 +106,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(() => { @@ -126,7 +123,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const scope = createMemo(() => { const session = id() - if (!session) return store.draft ?? store.promoting + if (!session) return store.draft return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session)) }) @@ -139,13 +136,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (!next) return if (saved.session[session] !== undefined) { handoff.delete(key) - setStore("promoting", undefined) return } setSaved("session", session, clone(next)) handoff.delete(key) - setStore("promoting", undefined) }) const configuredModel = () => { @@ -182,9 +177,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,21 +372,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ model, agent, session: { - ready: savedReady, reset() { - setStore({ draft: undefined, promoting: undefined }) + setStore("draft", 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) if (dir === sdk().directory) { setSaved("session", session, next) + setStore("draft", undefined) + return } - setStore("promoting", next) + handoff.set(handoffKey(serverSDK().scope, dir, session), next) setStore("draft", undefined) }, restore(msg: { sessionID: string; agent: string; model: ModelKey }) { @@ -413,5 +406,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..de12e8c3f6 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,12 +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 - /** Application-global desktop updater */ updater?: UpdaterPlatform @@ -92,12 +85,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 +109,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..014352506a 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,51 +1,175 @@ -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" -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 +} + +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 && 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 +178,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 +188,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 +292,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 +313,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 +343,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 +361,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..f2ee8869ac 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -1,11 +1,4 @@ -import type { - Config, - OpencodeClient, - Path, - Project, - ProviderAuthResponse, - SessionStatus, -} from "@opencode-ai/sdk/v2/client" +import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse } from "@opencode-ai/sdk/v2/client" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" @@ -18,22 +11,19 @@ import { bootstrapGlobal, clearProviderRev, loadAgentsQuery, - loadCommands, loadGlobalConfigQuery, loadPathQuery, loadProjectsQuery, loadProvidersQuery, - loadReferencesQuery, } 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 +35,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,77 +50,10 @@ 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]))) - }, - }) - -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"] - >({ - 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])), - ) - }, - placeholderData: {}, + queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}), }) export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => @@ -150,52 +62,20 @@ 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)), + mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)), lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)), sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }), } @@ -224,44 +104,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 +136,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 +164,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 +193,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { bootstrapInstance, }) + const session = createServerSession(serverSDK.client) + const children = createChildStoreManager({ owner, scope: serverSDK.scope, @@ -363,15 +205,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 +260,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 +334,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { provider: globalStore.provider, }, sdk, - api: serverSDK.api, store: child[0], setStore: child[1], vcsCache: cache, @@ -499,7 +341,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { translate: language.t, queryClient, session, - protocol: serverSDK.protocol, }) }) @@ -511,41 +352,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,68 +370,32 @@ 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,40 +462,28 @@ 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 }) }, refresh: async () => { await queryClient.refetchQueries(queryOptionsApi.mcp(key)) - await queryClient.refetchQueries(queryOptionsApi.mcpResources(key)) }, }) }, 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..02a5ac7fd6 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 } @@ -59,14 +51,18 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const fallback = server.key const [store, setStore, _, ready] = persisted( { - ...Persist.window("tabs"), - migrate: (value: unknown) => migrateTabs(value, fallback), + ...Persist.global("tabs"), + 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 [recent, setRecent, , recentReady] = persisted(Persist.global("tabs.recent"), 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/desktop-menu.test.ts b/packages/app/src/desktop-menu.test.ts deleted file mode 100644 index c7e36a7cb7..0000000000 --- a/packages/app/src/desktop-menu.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { DESKTOP_MENU } from "./desktop-menu" - -describe("desktop menu", () => { - test("exports logs through the desktop command registry", () => { - const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter( - (item) => item.type === "item" && item.label === "Export Logs...", - ) - - expect(items).toHaveLength(2) - expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true) - }) -}) 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..3872d01ae7 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", @@ -403,20 +388,6 @@ export const dict = { "wsl.onboarding.chooseDistroFirst": "Choose a distro first.", "wsl.onboarding.loadFailed": "Failed to load WSL state.", "wsl.onboarding.loading": "Loading...", - "wsl.onboarding.installedDistros": "Installed distros", - "wsl.onboarding.checkAgain": "Check again", - "wsl.onboarding.distroStatus.ready": "Ready", - "wsl.onboarding.distroStatus.checking": "Checking...", - "wsl.onboarding.distroStatus.opencodeMissing": "OpenCode not installed", - "wsl.onboarding.distroStatus.missingTools": "Missing bash, curl", - "wsl.onboarding.distroStatus.unsupported": "Unsupported · Use WSL 2", - "wsl.onboarding.needAnotherDistro": "Need another distro?", - "wsl.onboarding.needAnotherDistroHint": "Install a Linux distribution from the WSL catalog", - "wsl.onboarding.wslNotInstalled.title": "WSL not installed", - "wsl.onboarding.wslNotInstalled.description": - "WSL (Windows Subsystem for Linux) is required before OpenCode can add a WSL server", - "wsl.onboarding.wslUnavailable.title": "WSL unavailable", - "wsl.onboarding.wslUnavailable.description": "OpenCode could not verify WSL on this machine.", "wsl.onboarding.installWsl": "Install WSL", "wsl.onboarding.windowsRestartRequired": "Restart Windows to finish installing WSL, then reopen OpenCode.", "wsl.onboarding.next": "Next", @@ -426,7 +397,6 @@ export const dict = { "wsl.onboarding.install": "Install", "wsl.onboarding.installing": "Installing...", "wsl.onboarding.installDistro": "Install distro", - "wsl.onboarding.searchDistros": "Search distros", "wsl.onboarding.wsl2Required": "WSL 2 is required.", "wsl.onboarding.toolsRequired": "This distro needs bash and curl.", "wsl.onboarding.openTerminal": "Open terminal", @@ -619,7 +589,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,18 +600,12 @@ 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", "session.tab.context": "Context", - "session.tab.unknown": "Unknown Session", "session.panel.reviewAndFiles": "Review and files", - "session.error.notFound": "This session cannot be found", - "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...", @@ -674,10 +637,6 @@ export const dict = { "session.todo.expand": "Expand", "session.todo.progress": "{{done}} of {{total}} todos completed", "session.question.progress": "{{current}} of {{total}} questions", - "session.question.minimize": "Minimize question", - "session.question.restore": "Restore question", - "session.question.pending.one": "{{count}} pending question", - "session.question.pending.other": "{{count}} pending questions", "session.followupDock.summary.one": "{{count}} queued message", "session.followupDock.summary.other": "{{count}} queued messages", "session.followupDock.sendNow": "Send now", @@ -701,7 +660,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 +672,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 +815,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 +849,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 +859,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..6f8ec89d4c 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -194,19 +194,8 @@ timeline-scope: --home-projects-scroll; } - [data-slot="model-selector-scroll"] { - timeline-scope: --model-selector-scroll; - } - - [data-slot="manage-models-scroll"] { - timeline-scope: --manage-models-scroll; - } - [data-slot="home-projects-scroll"]::before, - [data-slot="home-projects-scroll"]::after, - [data-slot="model-selector-scroll"]::before, - [data-slot="model-selector-scroll"]::after, - [data-slot="manage-models-scroll"]::before { + [data-slot="home-projects-scroll"]::after { content: ""; position: absolute; left: 0; @@ -222,64 +211,11 @@ background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent); } - [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% - ); - } - - [data-slot="manage-models-scroll"]::before { - top: 0; - background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent); - } - [data-slot="home-projects-scroll"]::after { bottom: 0; background: linear-gradient(to top, var(--v2-background-bg-base), transparent); } - [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% - ); - } - @supports (animation-timeline: --home-projects-scroll) and (timeline-scope: --home-projects-scroll) { [data-slot="home-projects-scroll"] .scroll-view__viewport { scroll-timeline: --home-projects-scroll y; @@ -297,34 +233,4 @@ animation-range: calc(100% - 1.1px) calc(100% - 1px); } } - - @supports (animation-timeline: --model-selector-scroll) and (timeline-scope: --model-selector-scroll) { - [data-slot="model-selector-scroll"] .scroll-view__viewport { - scroll-timeline: --model-selector-scroll y; - } - - [data-slot="model-selector-scroll"]::before { - animation: home-projects-fade-top linear both; - animation-timeline: --model-selector-scroll; - animation-range: 0 0.1px; - } - - [data-slot="model-selector-scroll"]::after { - animation: home-projects-fade-bottom linear both; - animation-timeline: --model-selector-scroll; - animation-range: calc(100% - 1.1px) calc(100% - 1px); - } - } - - @supports (animation-timeline: --manage-models-scroll) and (timeline-scope: --manage-models-scroll) { - [data-slot="manage-models-scroll"] .settings-v2-panel { - scroll-timeline: --manage-models-scroll y; - } - - [data-slot="manage-models-scroll"]::before { - animation: home-projects-fade-top linear both; - animation-timeline: --manage-models-scroll; - animation-range: 0 0.1px; - } - } } 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..bb1111c9c2 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -276,10 +276,7 @@ export const ErrorPage: Component = (props) => { } return ( -
+
@@ -354,7 +351,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..31c59579b6 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( @@ -944,6 +943,18 @@ export default function LegacyLayout(props: ParentProps) { keybind: "mod+comma", onSelect: () => openSettings(), }, + ...(platform.platform === "desktop" && platform.exportDebugLogs + ? [ + { + id: "logs.export", + title: "Export logs", + category: language.t("command.category.settings"), + onSelect: () => { + void platform.exportDebugLogs?.() + }, + }, + ] + : []), { id: "session.previous", title: language.t("command.session.previous"), @@ -1096,9 +1107,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 +1196,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 +1242,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 +1305,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 +1454,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 +1488,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 +1535,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 +1593,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 +2250,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 +2260,7 @@ export default function LegacyLayout(props: ParentProps) { return (
{autoselecting() ?? ""} - setState("debugTools", (value) => !value) } - : undefined - } - /> + @@ -2411,9 +2405,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 +2441,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..1c9391e0fd 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,22 +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, on, onMount, - type ParentProps, untrack, } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" @@ -24,45 +19,31 @@ import { createMediaQuery } from "@solid-primitives/media" import { createResizeObserver } from "@solid-primitives/resize-observer" import { debounce } from "@solid-primitives/scheduled" import { useLocal } from "@/context/local" -import { FileProvider, selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" +import { 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" import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge" 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 { useLocation, useNavigate, 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 { useComments } from "@/context/comments" 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 { PromptProvider, usePrompt } from "@/context/prompt" +import { usePrompt } from "@/context/prompt" import { usePlatform } from "@/context/platform" -import { SDKProvider, useSDK } from "@/context/sdk" +import { useSDK } from "@/context/sdk" import { useServerSDK } from "@/context/server-sdk" -import { ServerConnection, serverName, useServer } from "@/context/server" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" -import { useTabs } from "@/context/tabs" -import { TerminalProvider, useTerminal } from "@/context/terminal" +import { 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 +51,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 +73,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 { formatServerError } from "@/utils/server-errors" import { legacySessionHref, requireServerKey, 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,13 +88,9 @@ type VcsMode = "git" | "branch" const sessionViewState = () => ({ messageId: undefined as string | undefined, mobileTab: "session" as "session" | "changes", + changes: "git" as ChangeMode, }) -function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) { - if (!sessionID) return false - return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID) -} - async function runPromptRollbackMutation(input: { capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void } optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void @@ -144,212 +114,6 @@ async function runPromptRollbackMutation(input: { }) } -export function SessionPage() { - return ( - - - - ) -} - -// 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() { - 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( - props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>, -) { - const settings = useSettings() - return ( - - settings.general.newLayoutDesigns() ? ( - - - - - - ) : ( - - ) - } - > - {props.children} - - ) -} - -function SessionErrorFallback(props: { error: unknown; sessionID?: string; serverKey?: ServerConnection.Key }) { - const language = useLanguage() - const server = useServer() - const tabs = useTabs() - const displayServer = createMemo(() => { - const key = props.serverKey ?? server.key - const conn = server.list.find((item) => ServerConnection.key(item) === key) - return conn ? serverName(conn) : key - }) - const closeTab = () => { - if (!props.sessionID) return - tabs.removeSessionTab({ server: props.serverKey ?? server.key, sessionId: props.sessionID }) - } - if (isCurrentSessionNotFoundError(props.error, props.sessionID)) { - return ( -
-
-
-
{language.t("session.error.notFound")}
-
- {language.t("session.error.notFound.description")} -
-
- - {(sessionID) => ( -
-
{displayServer()}
- - {sessionID()} - -
- )} -
- - {language.t("session.error.notFound.closeTab")} - -
-
- ) - } - return -} - -function ResolvedTargetSessionRoute() { - const params = useParams<{ serverKey: string; id: string }>() - const tabs = useTabs() - const sync = useServerSync() - const serverKey = createMemo(() => requireServerKey(params.serverKey)) - const current = createSessionLineage( - () => params.id, - () => sync().session.lineage, - ) - const directory = createMemo(() => current()?.session.directory) - const targetDirectory = () => directory()! - - createEffect(() => { - const session = current() - if (!session) return - tabs.addSessionTab({ - server: serverKey(), - sessionId: session.root.id, - }) - }) - - 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. - - - - - - - - ) -} - -// 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() - return ( - - - - ) -} - -function TargetServerScopedProviders( - props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>, -) { - return ( - <> - - {props.children} - - ) -} - -function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) { - const notification = useNotification() - createEffect(() => { - const sessionID = props.sessionID?.() - if (!notification.ready() || !sessionID) return - if (notification.session.unseenCount(sessionID) === 0) return - notification.session.markViewed(sessionID) - }) - return null -} - -function SessionProviders(props: ParentProps) { - return ( - - - - {props.children} - - - - ) -} - -function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) { - return ( -
- {props.children} -
- ) -} - -function SessionPanelFrame(props: ParentProps<{ newLayout: boolean; raised?: boolean }>) { - return ( -
- {props.children} -
- ) -} - export default function Page() { const serverSync = useServerSync() const layout = useLayout() @@ -365,14 +129,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 +167,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 +208,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 +216,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 +247,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 +289,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 +372,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 +392,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 +403,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 +602,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 +716,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 +760,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 +770,7 @@ export default function Page() { setTree({ reviewScroll: undefined, pendingDiff: undefined, + activeDiff: undefined, }) }, { defer: true }, @@ -1137,21 +788,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 +804,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 ( -