Browse Source

Merge branch 'dev' into fix/daytona-plugin-link-15976

shivam kr chaudhary 1 month ago
parent
commit
86a2379af2
100 changed files with 3556 additions and 1232 deletions
  1. 4 37
      .github/workflows/docs-locale-sync.yml
  2. 126 1
      .github/workflows/publish.yml
  3. 38 0
      .opencode/glossary/tr.md
  4. 11 0
      AGENTS.md
  5. 1 0
      README.ar.md
  6. 284 47
      bun.lock
  7. 3 3
      flake.lock
  8. 1 4
      infra/console.ts
  9. 4 4
      nix/hashes.json
  10. 1 0
      nix/node_modules.nix
  11. 4 2
      package.json
  12. 515 0
      packages/app/create-effect-simplification-spec.md
  13. 11 6
      packages/app/e2e/projects/projects-switch.spec.ts
  14. 11 0
      packages/app/e2e/session/session-composer-dock.spec.ts
  15. 1 1
      packages/app/package.json
  16. 1 0
      packages/app/script/e2e-local.ts
  17. 7 4
      packages/app/src/app.tsx
  18. 1 2
      packages/app/src/components/dialog-connect-provider.tsx
  19. 1 2
      packages/app/src/components/dialog-select-model-unpaid.tsx
  20. 1 7
      packages/app/src/components/dialog-select-provider.tsx
  21. 0 6
      packages/app/src/components/file-tree.tsx
  22. 82 38
      packages/app/src/components/prompt-input.tsx
  23. 38 0
      packages/app/src/components/prompt-input/submit.test.ts
  24. 5 0
      packages/app/src/components/prompt-input/submit.ts
  25. 15 15
      packages/app/src/components/session/session-header.tsx
  26. 1 2
      packages/app/src/components/settings-models.tsx
  27. 3 9
      packages/app/src/components/settings-providers.tsx
  28. 12 15
      packages/app/src/components/status-popover.tsx
  29. 3 4
      packages/app/src/components/terminal.tsx
  30. 2 0
      packages/app/src/components/titlebar.tsx
  31. 58 38
      packages/app/src/context/global-sync.tsx
  32. 21 9
      packages/app/src/context/global-sync/child-store.ts
  33. 77 5
      packages/app/src/context/layout.tsx
  34. 2 2
      packages/app/src/context/permission-auto-respond.test.ts
  35. 1 1
      packages/app/src/context/permission-auto-respond.ts
  36. 20 39
      packages/app/src/context/sync.tsx
  37. 206 134
      packages/app/src/pages/layout.tsx
  38. 25 5
      packages/app/src/pages/layout/helpers.ts
  39. 324 197
      packages/app/src/pages/session.tsx
  40. 81 16
      packages/app/src/pages/session/composer/session-composer-region.tsx
  41. 9 2
      packages/app/src/pages/session/composer/session-composer-state.ts
  42. 173 65
      packages/app/src/pages/session/composer/session-todo-dock.tsx
  43. 28 35
      packages/app/src/pages/session/file-tabs.tsx
  44. 201 45
      packages/app/src/pages/session/message-timeline.tsx
  45. 8 28
      packages/app/src/pages/session/review-tab.tsx
  46. 3 3
      packages/app/src/pages/session/terminal-panel.tsx
  47. 26 16
      packages/app/src/pages/session/use-session-hash-scroll.ts
  48. 19 18
      packages/app/src/utils/notification-click.test.ts
  49. 8 8
      packages/app/src/utils/notification-click.ts
  50. 1 1
      packages/console/app/package.json
  51. 14 5
      packages/console/app/src/app.tsx
  52. 2 2
      packages/console/app/src/component/header.tsx
  53. 33 0
      packages/console/app/src/i18n/ar.ts
  54. 33 0
      packages/console/app/src/i18n/br.ts
  55. 33 0
      packages/console/app/src/i18n/da.ts
  56. 33 0
      packages/console/app/src/i18n/de.ts
  57. 33 0
      packages/console/app/src/i18n/en.ts
  58. 33 0
      packages/console/app/src/i18n/es.ts
  59. 31 0
      packages/console/app/src/i18n/fr.ts
  60. 33 0
      packages/console/app/src/i18n/it.ts
  61. 34 0
      packages/console/app/src/i18n/ja.ts
  62. 33 0
      packages/console/app/src/i18n/ko.ts
  63. 33 0
      packages/console/app/src/i18n/no.ts
  64. 33 0
      packages/console/app/src/i18n/pl.ts
  65. 33 0
      packages/console/app/src/i18n/ru.ts
  66. 33 0
      packages/console/app/src/i18n/th.ts
  67. 39 6
      packages/console/app/src/i18n/tr.ts
  68. 31 0
      packages/console/app/src/i18n/zh.ts
  69. 31 0
      packages/console/app/src/i18n/zht.ts
  70. 3 0
      packages/console/app/src/lib/form-error.ts
  71. 2 2
      packages/console/app/src/routes/[...404].tsx
  72. 7 4
      packages/console/app/src/routes/api/enterprise.ts
  73. 3 1
      packages/console/app/src/routes/auth/[...callback].ts
  74. 4 1
      packages/console/app/src/routes/bench/submission.ts
  75. 9 8
      packages/console/app/src/routes/brand/index.tsx
  76. 1 1
      packages/console/app/src/routes/download/[channel]/[platform].ts
  77. 1 1
      packages/console/app/src/routes/stripe/webhook.ts
  78. 2 2
      packages/console/app/src/routes/temp.tsx
  79. 2 1
      packages/console/app/src/routes/workspace/[id]/billing/lite-section.tsx
  80. 2 1
      packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx
  81. 58 40
      packages/console/app/src/routes/zen/util/handler.ts
  82. 1 1
      packages/console/app/src/routes/zen/util/provider/anthropic.ts
  83. 13 42
      packages/console/app/src/routes/zen/util/rateLimiter.ts
  84. 6 9
      packages/console/app/src/routes/zen/util/trialLimiter.ts
  85. 1 74
      packages/console/app/test/rateLimiter.test.ts
  86. 4 7
      packages/console/core/package.json
  87. 312 0
      packages/console/core/script/black-stats.ts
  88. 0 22
      packages/console/core/script/promote-black.ts
  89. 5 5
      packages/console/core/script/promote-limits.ts
  90. 0 28
      packages/console/core/script/update-black.ts
  91. 6 6
      packages/console/core/script/update-limits.ts
  92. 2 24
      packages/console/core/src/black.ts
  93. 2 13
      packages/console/core/src/lite.ts
  94. 3 33
      packages/console/core/src/model.ts
  95. 46 0
      packages/console/core/src/subscription.ts
  96. 1 5
      packages/console/core/sst-env.d.ts
  97. 1 1
      packages/console/function/package.json
  98. 1 5
      packages/console/function/sst-env.d.ts
  99. 1 1
      packages/console/mail/package.json
  100. 1 5
      packages/console/resource/sst-env.d.ts

+ 4 - 37
.github/workflows/docs-locale-sync.yml

@@ -59,43 +59,10 @@ jobs:
             {
             {
               "permission": {
               "permission": {
                 "*": "deny",
                 "*": "deny",
-                "read": {
-                  "*": "deny",
-                  "packages/web/src/content/docs": "allow",
-                  "packages/web/src/content/docs/*": "allow",
-                  "packages/web/src/content/docs/*.mdx": "allow",
-                  "packages/web/src/content/docs/*/*.mdx": "allow",
-                  ".opencode": "allow",
-                  ".opencode/agent": "allow",
-                  ".opencode/glossary": "allow",
-                  ".opencode/agent/translator.md": "allow",
-                  ".opencode/glossary/*.md": "allow"
-                },
-                "edit": {
-                  "*": "deny",
-                  "packages/web/src/content/docs/*/*.mdx": "allow"
-                },
-                "glob": {
-                  "*": "deny",
-                  "packages/web/src/content/docs*": "allow",
-                  ".opencode/glossary*": "allow"
-                },
-                "task": {
-                  "*": "deny",
-                  "translator": "allow"
-                }
-              },
-              "agent": {
-                "translator": {
-                  "permission": {
-                    "*": "deny",
-                    "read": {
-                      "*": "deny",
-                      ".opencode/agent/translator.md": "allow",
-                      ".opencode/glossary/*.md": "allow"
-                    }
-                  }
-                }
+                "read": "allow",
+                "edit": "allow",
+                "glob": "allow",
+                "task": "allow"
               }
               }
             }
             }
         run: |
         run: |

+ 126 - 1
.github/workflows/publish.yml

@@ -99,7 +99,6 @@ jobs:
         with:
         with:
           name: opencode-cli
           name: opencode-cli
           path: packages/opencode/dist
           path: packages/opencode/dist
-
     outputs:
     outputs:
       version: ${{ needs.version.outputs.version }}
       version: ${{ needs.version.outputs.version }}
 
 
@@ -240,11 +239,130 @@ jobs:
           APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
           APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
           APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
           APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
 
 
+  build-electron:
+    needs:
+      - build-cli
+      - version
+    continue-on-error: false
+    strategy:
+      fail-fast: false
+      matrix:
+        settings:
+          - host: macos-latest
+            target: x86_64-apple-darwin
+            platform_flag: --mac --x64
+          - host: macos-latest
+            target: aarch64-apple-darwin
+            platform_flag: --mac --arm64
+          - host: "blacksmith-4vcpu-windows-2025"
+            target: x86_64-pc-windows-msvc
+            platform_flag: --win
+          - host: "blacksmith-4vcpu-ubuntu-2404"
+            target: x86_64-unknown-linux-gnu
+            platform_flag: --linux
+          - host: "blacksmith-4vcpu-ubuntu-2404"
+            target: aarch64-unknown-linux-gnu
+            platform_flag: --linux
+    runs-on: ${{ matrix.settings.host }}
+    steps:
+      - uses: actions/checkout@v3
+
+      - uses: apple-actions/import-codesign-certs@v2
+        if: runner.os == 'macOS'
+        with:
+          keychain: build
+          p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
+          p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+
+      - name: Setup Apple API Key
+        if: runner.os == 'macOS'
+        run: echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
+
+      - uses: ./.github/actions/setup-bun
+
+      - uses: actions/setup-node@v4
+        with:
+          node-version: "24"
+
+      - name: Cache apt packages
+        if: contains(matrix.settings.host, 'ubuntu')
+        uses: actions/cache@v4
+        with:
+          path: ~/apt-cache
+          key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-${{ hashFiles('.github/workflows/publish.yml') }}
+          restore-keys: |
+            ${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-
+
+      - name: Install dependencies (ubuntu only)
+        if: contains(matrix.settings.host, 'ubuntu')
+        run: |
+          mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
+          sudo apt-get update
+          sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" rpm
+          sudo chmod -R a+rw ~/apt-cache
+
+      - name: Setup git committer
+        id: committer
+        uses: ./.github/actions/setup-git-committer
+        with:
+          opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
+          opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
+
+      - name: Prepare
+        run: bun ./scripts/prepare.ts
+        working-directory: packages/desktop-electron
+        env:
+          OPENCODE_VERSION: ${{ needs.version.outputs.version }}
+          OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
+          RUST_TARGET: ${{ matrix.settings.target }}
+          GH_TOKEN: ${{ github.token }}
+          GITHUB_RUN_ID: ${{ github.run_id }}
+
+      - name: Build
+        run: bun run build
+        working-directory: packages/desktop-electron
+        env:
+          OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
+
+      - name: Package and publish
+        if: needs.version.outputs.release
+        run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish always --config electron-builder.config.ts
+        working-directory: packages/desktop-electron
+        timeout-minutes: 60
+        env:
+          OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
+          GH_TOKEN: ${{ steps.committer.outputs.token }}
+          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 }}
+
+      - name: Package (no publish)
+        if: ${{ !needs.version.outputs.release }}
+        run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish never --config electron-builder.config.ts
+        working-directory: packages/desktop-electron
+        timeout-minutes: 60
+        env:
+          OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
+
+      - uses: actions/upload-artifact@v4
+        with:
+          name: opencode-electron-${{ matrix.settings.target }}
+          path: packages/desktop-electron/dist/*
+
+      - uses: actions/upload-artifact@v4
+        if: needs.version.outputs.release
+        with:
+          name: latest-yml-${{ matrix.settings.target }}
+          path: packages/desktop-electron/dist/latest*.yml
+
   publish:
   publish:
     needs:
     needs:
       - version
       - version
       - build-cli
       - build-cli
       - build-tauri
       - build-tauri
+      - build-electron
     runs-on: blacksmith-4vcpu-ubuntu-2404
     runs-on: blacksmith-4vcpu-ubuntu-2404
     steps:
     steps:
       - uses: actions/checkout@v3
       - uses: actions/checkout@v3
@@ -281,6 +399,12 @@ jobs:
           name: opencode-cli
           name: opencode-cli
           path: packages/opencode/dist
           path: packages/opencode/dist
 
 
+      - uses: actions/download-artifact@v4
+        if: needs.version.outputs.release
+        with:
+          pattern: latest-yml-*
+          path: /tmp/latest-yml
+
       - name: Cache apt packages (AUR)
       - name: Cache apt packages (AUR)
         uses: actions/cache@v4
         uses: actions/cache@v4
         with:
         with:
@@ -308,3 +432,4 @@ jobs:
           GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
           GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
           GH_REPO: ${{ needs.version.outputs.repo }}
           GH_REPO: ${{ needs.version.outputs.repo }}
           NPM_CONFIG_PROVENANCE: false
           NPM_CONFIG_PROVENANCE: false
+          LATEST_YML_DIR: /tmp/latest-yml

+ 38 - 0
.opencode/glossary/tr.md

@@ -0,0 +1,38 @@
+# tr Glossary
+
+## Sources
+
+- PR #15835: https://github.com/anomalyco/opencode/pull/15835
+
+## Do Not Translate (Locale Additions)
+
+- `OpenCode` (preserve casing in prose, docs, and UI copy)
+- Keep lowercase `opencode` in commands, package names, paths, URLs, and other exact identifiers
+- `<TAB>` stays the literal key token in code blocks; use `Tab` for the nearby explanatory label in prose
+- Commands, flags, file paths, and code literals (keep exactly as written)
+
+## Preferred Terms
+
+These are PR-backed wording preferences and may evolve.
+
+| English / Context         | Preferred                               | Notes                                                         |
+| ------------------------- | --------------------------------------- | ------------------------------------------------------------- |
+| available in beta         | `beta olarak mevcut`                    | Prefer this over `beta olarak kullanılabilir`                 |
+| privacy-first             | `Gizlilik öncelikli tasarlandı`         | Prefer this over `Önce gizlilik için tasarlandı`              |
+| connect your local models | `yerel modellerinizi bağlayabilirsiniz` | Use the fuller, more direct action phrase                     |
+| `<TAB>` key label         | `Tab`                                   | Use `Tab` in prose; keep `<TAB>` in literal UI or code blocks |
+| cross-platform            | `cross-platform (tüm platformlarda)`    | Keep the English term, add a short clarification when helpful |
+
+## Guidance
+
+- Prefer natural Turkish phrasing over literal translation
+- Merge broken sentence fragments into one clear sentence when the source is a single thought
+- Keep product naming consistent: `OpenCode` in prose, `opencode` only for exact technical identifiers
+- When an English technical term is intentionally kept, add a short Turkish clarification only if it improves readability
+
+## Avoid
+
+- Avoid `beta olarak kullanılabilir` when `beta olarak mevcut` fits
+- Avoid `Önce gizlilik için tasarlandı`; use the more natural reviewed wording instead
+- Avoid `Sekme` for the translated key label in prose when referring to `<TAB>`
+- Avoid changing `opencode` to `OpenCode` inside commands, URLs, package names, or code literals

+ 11 - 0
AGENTS.md

@@ -20,6 +20,17 @@
 
 
 Prefer single word names for variables and functions. Only use multiple words if necessary.
 Prefer single word names for variables and functions. Only use multiple words if necessary.
 
 
+### Naming Enforcement (Read This)
+
+THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
+
+- Use single word names by default for new locals, params, and helper functions.
+- Multi-word names are allowed only when a single word would be unclear or ambiguous.
+- Do not introduce new camelCase compounds when a short single-word alternative is clear.
+- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
+- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
+- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
+
 ```ts
 ```ts
 // Good
 // Good
 const foo = 1
 const foo = 1

+ 1 - 0
README.ar.md

@@ -27,6 +27,7 @@
   <a href="README.ja.md">日本語</a> |
   <a href="README.ja.md">日本語</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.ru.md">Русский</a> |
   <a href="README.ru.md">Русский</a> |
+  <a href="README.bs.md">Bosanski</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.br.md">Português (Brasil)</a> |
   <a href="README.br.md">Português (Brasil)</a> |

File diff suppressed because it is too large
+ 284 - 47
bun.lock


+ 3 - 3
flake.lock

@@ -2,11 +2,11 @@
   "nodes": {
   "nodes": {
     "nixpkgs": {
     "nixpkgs": {
       "locked": {
       "locked": {
-        "lastModified": 1770812194,
-        "narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=",
+        "lastModified": 1772091128,
+        "narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=",
         "owner": "NixOS",
         "owner": "NixOS",
         "repo": "nixpkgs",
         "repo": "nixpkgs",
-        "rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8",
+        "rev": "3f0336406035444b4a24b942788334af5f906259",
         "type": "github"
         "type": "github"
       },
       },
       "original": {
       "original": {

+ 1 - 4
infra/console.ts

@@ -118,7 +118,6 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
     price: zenLitePrice.id,
     price: zenLitePrice.id,
   },
   },
 })
 })
-const ZEN_LITE_LIMITS = new sst.Secret("ZEN_LITE_LIMITS")
 
 
 const zenBlackProduct = new stripe.Product("ZenBlack", {
 const zenBlackProduct = new stripe.Product("ZenBlack", {
   name: "OpenCode Black",
   name: "OpenCode Black",
@@ -142,7 +141,6 @@ const ZEN_BLACK_PRICE = new sst.Linkable("ZEN_BLACK_PRICE", {
     plan20: zenBlackPrice20.id,
     plan20: zenBlackPrice20.id,
   },
   },
 })
 })
-const ZEN_BLACK_LIMITS = new sst.Secret("ZEN_BLACK_LIMITS")
 
 
 const ZEN_MODELS = [
 const ZEN_MODELS = [
   new sst.Secret("ZEN_MODELS1"),
   new sst.Secret("ZEN_MODELS1"),
@@ -215,9 +213,8 @@ new sst.cloudflare.x.SolidStart("Console", {
     AWS_SES_ACCESS_KEY_ID,
     AWS_SES_ACCESS_KEY_ID,
     AWS_SES_SECRET_ACCESS_KEY,
     AWS_SES_SECRET_ACCESS_KEY,
     ZEN_BLACK_PRICE,
     ZEN_BLACK_PRICE,
-    ZEN_BLACK_LIMITS,
     ZEN_LITE_PRICE,
     ZEN_LITE_PRICE,
-    ZEN_LITE_LIMITS,
+    new sst.Secret("ZEN_LIMITS"),
     new sst.Secret("ZEN_SESSION_SECRET"),
     new sst.Secret("ZEN_SESSION_SECRET"),
     ...ZEN_MODELS,
     ...ZEN_MODELS,
     ...($dev
     ...($dev

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
 {
   "nodeModules": {
   "nodeModules": {
-    "x86_64-linux": "sha256-dZoLhWe4smBsOF7WczMySLXSAB1YRO1vfhiOCL1rBf0=",
-    "aarch64-linux": "sha256-J7nIz1xuVZEHun5WRZkYRySz29B0A8g5g0RRxnIWTYU=",
-    "aarch64-darwin": "sha256-R2PuhX+EjUBuLE8MF0G0fcUwNaU+5n6V6uVeK89ulzw=",
-    "x86_64-darwin": "sha256-Bvzfz9TsTpYriZNLSLgpNcNb+BgtkgpjoWqdOtF2IBg="
+    "x86_64-linux": "sha256-jtBYpfiE9g0otqZEtOksW1Nbg+O8CJP9OEOEhsa7sa8=",
+    "aarch64-linux": "sha256-m+YNZIB7I7EMPyfqkKsvDvmBX9R1szmEKxXpxTNFLH8=",
+    "aarch64-darwin": "sha256-1gVmtkC1/I8sdHZcaeSFJheySVlpCyKCjf9zbVsVqAQ=",
+    "x86_64-darwin": "sha256-Tvk5YL6Z0xRul4jopbGme/997iHBylXC0Cq3RnjQb+I="
   }
   }
 }
 }

+ 1 - 0
nix/node_modules.nix

@@ -31,6 +31,7 @@ stdenvNoCC.mkDerivation {
         ../package.json
         ../package.json
         ../patches
         ../patches
         ../install # required by desktop build (cli.rs include_str!)
         ../install # required by desktop build (cli.rs include_str!)
+        ../.github/TEAM_MEMBERS # required by @opencode-ai/script
       ]
       ]
     );
     );
   };
   };

+ 4 - 2
package.json

@@ -9,6 +9,7 @@
     "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
     "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
     "dev:desktop": "bun --cwd packages/desktop tauri dev",
     "dev:desktop": "bun --cwd packages/desktop tauri dev",
     "dev:web": "bun --cwd packages/app dev",
     "dev:web": "bun --cwd packages/app dev",
+    "dev:storybook": "bun --cwd packages/storybook storybook",
     "typecheck": "bun turbo typecheck",
     "typecheck": "bun turbo typecheck",
     "prepare": "husky",
     "prepare": "husky",
     "random": "echo 'Random script'",
     "random": "echo 'Random script'",
@@ -35,7 +36,7 @@
       "@tsconfig/bun": "1.0.9",
       "@tsconfig/bun": "1.0.9",
       "@cloudflare/workers-types": "4.20251008.0",
       "@cloudflare/workers-types": "4.20251008.0",
       "@openauthjs/openauth": "0.0.0-20250322224806",
       "@openauthjs/openauth": "0.0.0-20250322224806",
-      "@pierre/diffs": "1.1.0-beta.13",
+      "@pierre/diffs": "1.1.0-beta.18",
       "@solid-primitives/storage": "4.3.3",
       "@solid-primitives/storage": "4.3.3",
       "@tailwindcss/vite": "4.1.11",
       "@tailwindcss/vite": "4.1.11",
       "diff": "8.0.2",
       "diff": "8.0.2",
@@ -98,7 +99,8 @@
     "protobufjs",
     "protobufjs",
     "tree-sitter",
     "tree-sitter",
     "tree-sitter-bash",
     "tree-sitter-bash",
-    "web-tree-sitter"
+    "web-tree-sitter",
+    "electron"
   ],
   ],
   "overrides": {
   "overrides": {
     "@types/bun": "catalog:",
     "@types/bun": "catalog:",

+ 515 - 0
packages/app/create-effect-simplification-spec.md

@@ -0,0 +1,515 @@
+# CreateEffect Simplification Implementation Spec
+
+Reduce reactive misuse across `packages/app`.
+
+---
+
+## Context
+
+This work targets `packages/app/src`, which currently has 101 `createEffect` calls across 37 files.
+
+The biggest clusters are `pages/session.tsx` (19), `pages/layout.tsx` (13), `pages/session/file-tabs.tsx` (6), and several context providers that mirror one store into another.
+
+Key issues from the audit:
+
+- Derived state is being written through effects instead of computed directly
+- Session and file resets are handled by watch-and-clear effects instead of keyed state boundaries
+- User-driven actions are hidden inside reactive effects
+- Context layers mirror and hydrate child stores with multiple sync effects
+- Several areas repeat the same imperative trigger pattern in multiple effects
+
+Keep the implementation focused on removing unnecessary effects, not on broad UI redesign.
+
+## Goals
+
+- Cut high-churn `createEffect` usage in the hottest files first
+- Replace effect-driven derived state with reactive derivation
+- Replace reset-on-key effects with keyed ownership boundaries
+- Move event-driven work to direct actions and write paths
+- Remove mirrored store hydration where a single source of truth can exist
+- Leave necessary external sync effects in place, but make them narrower and clearer
+
+## Non-Goals
+
+- Do not rewrite unrelated component structure just to reduce the count
+- Do not change product behavior, navigation flow, or persisted data shape unless required for a cleaner write boundary
+- Do not remove effects that bridge to DOM, editors, polling, or external APIs unless there is a clearly safer equivalent
+- Do not attempt a repo-wide cleanup outside `packages/app`
+
+## Effect Taxonomy And Replacement Rules
+
+Use these rules during implementation.
+
+### Prefer `createMemo`
+
+Use `createMemo` when the target value is pure derived state from other signals or stores.
+
+Do this when an effect only reads reactive inputs and writes another reactive value that could be computed instead.
+
+Apply this to:
+
+- `packages/app/src/pages/session.tsx:141`
+- `packages/app/src/pages/layout.tsx:557`
+- `packages/app/src/components/terminal.tsx:261`
+- `packages/app/src/components/session/session-header.tsx:309`
+
+Rules:
+
+- If no external system is touched, do not use `createEffect`
+- Derive once, then read the memo where needed
+- If normalization is required, prefer normalizing at the write boundary before falling back to a memo
+
+### Prefer Keyed Remounts
+
+Use keyed remounts when local UI state should reset because an identity changed.
+
+Do this with `sessionKey`, `scope()`, or another stable identity instead of watching the key and manually clearing signals.
+
+Apply this to:
+
+- `packages/app/src/pages/session.tsx:325`
+- `packages/app/src/pages/session.tsx:336`
+- `packages/app/src/pages/session.tsx:477`
+- `packages/app/src/pages/session.tsx:869`
+- `packages/app/src/pages/session.tsx:963`
+- `packages/app/src/pages/session/message-timeline.tsx:149`
+- `packages/app/src/context/file.tsx:100`
+
+Rules:
+
+- If the desired behavior is "new identity, fresh local state," key the owner subtree
+- Keep state local to the keyed boundary so teardown and recreation handle the reset naturally
+
+### Prefer Event Handlers And Actions
+
+Use direct handlers, store actions, and async command functions when work happens because a user clicked, selected, reloaded, or navigated.
+
+Do this when an effect is just watching for a flag change, command token, or event-bus signal to trigger imperative logic.
+
+Apply this to:
+
+- `packages/app/src/pages/layout.tsx:484`
+- `packages/app/src/pages/layout.tsx:652`
+- `packages/app/src/pages/layout.tsx:776`
+- `packages/app/src/pages/layout.tsx:1489`
+- `packages/app/src/pages/layout.tsx:1519`
+- `packages/app/src/components/file-tree.tsx:328`
+- `packages/app/src/pages/session/terminal-panel.tsx:55`
+- `packages/app/src/context/global-sync.tsx:148`
+- Duplicated trigger sets in:
+  - `packages/app/src/pages/session/review-tab.tsx:122`
+  - `packages/app/src/pages/session/review-tab.tsx:130`
+  - `packages/app/src/pages/session/review-tab.tsx:138`
+  - `packages/app/src/pages/session/file-tabs.tsx:367`
+  - `packages/app/src/pages/session/file-tabs.tsx:378`
+  - `packages/app/src/pages/session/file-tabs.tsx:389`
+  - `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
+  - `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
+  - `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
+
+Rules:
+
+- If the trigger is user intent, call the action at the source of that intent
+- If the same imperative work is triggered from multiple places, extract one function and call it directly
+
+### Prefer `onMount` And `onCleanup`
+
+Use `onMount` and `onCleanup` for lifecycle-only setup and teardown.
+
+This is the right fit for subscriptions, one-time wiring, timers, and imperative integration that should not rerun for ordinary reactive changes.
+
+Use this when:
+
+- Setup should happen once per owner lifecycle
+- Cleanup should always pair with teardown
+- The work is not conceptually derived state
+
+### Keep `createEffect` When It Is A Real Bridge
+
+Keep `createEffect` when it synchronizes reactive data to an external imperative sink.
+
+Examples that should remain, though they may be narrowed or split:
+
+- DOM/editor sync in `packages/app/src/components/prompt-input.tsx:690`
+- Scroll sync in `packages/app/src/pages/session.tsx:685`
+- Scroll/hash sync in `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
+- External sync in:
+  - `packages/app/src/context/language.tsx:207`
+  - `packages/app/src/context/settings.tsx:110`
+  - `packages/app/src/context/sdk.tsx:26`
+- Polling in:
+  - `packages/app/src/components/status-popover.tsx:59`
+  - `packages/app/src/components/dialog-select-server.tsx:273`
+
+Rules:
+
+- Keep the effect single-purpose
+- Make dependencies explicit and narrow
+- Avoid writing back into the same reactive graph unless absolutely required
+
+## Implementation Plan
+
+### Phase 0: Classification Pass
+
+Before changing code, tag each targeted effect as one of: derive, reset, event, lifecycle, or external bridge.
+
+Acceptance criteria:
+
+- Every targeted effect in this spec is tagged with a replacement strategy before refactoring starts
+- Shared helpers to be introduced are identified up front to avoid repeating patterns
+
+### Phase 1: Derived-State Cleanup
+
+Tackle highest-value, lowest-risk derived-state cleanup first.
+
+Priority items:
+
+- Normalize tabs at write boundaries and remove `packages/app/src/pages/session.tsx:141`
+- Stop syncing `workspaceOrder` in `packages/app/src/pages/layout.tsx:557`
+- Make prompt slash filtering reactive so `packages/app/src/components/prompt-input.tsx:652` can be removed
+- Replace other obvious derived-state effects in terminal and session header
+
+Acceptance criteria:
+
+- No behavior change in tab ordering, prompt filtering, terminal display, or header state
+- Targeted derived-state effects are deleted, not just moved
+
+### Phase 2: Keyed Reset Cleanup
+
+Replace reset-on-key effects with keyed ownership boundaries.
+
+Priority items:
+
+- Key session-scoped UI and state by `sessionKey`
+- Key file-scoped state by `scope()`
+- Remove manual clear-and-reseed effects in session and file context
+
+Acceptance criteria:
+
+- Switching session or file scope recreates the intended local state cleanly
+- No stale state leaks across session or scope changes
+- Target reset effects are deleted
+
+### Phase 3: Event-Driven Work Extraction
+
+Move event-driven work out of reactive effects.
+
+Priority items:
+
+- Replace `globalStore.reload` effect dispatching with direct calls
+- Split mixed-responsibility effect in `packages/app/src/pages/layout.tsx:1489`
+- Collapse duplicated imperative trigger triplets into single functions
+- Move file-tree and terminal-panel imperative work to explicit handlers
+
+Acceptance criteria:
+
+- User-triggered behavior still fires exactly once per intended action
+- No effect remains whose only job is to notice a command-like state and trigger an imperative function
+
+### Phase 4: Context Ownership Cleanup
+
+Remove mirrored child-store hydration patterns.
+
+Priority items:
+
+- Remove child-store hydration mirrors in `packages/app/src/context/global-sync/child-store.ts:184`, `:190`, `:193`
+- Simplify mirror logic in `packages/app/src/context/global-sync.tsx:130`, `:138`
+- Revisit `packages/app/src/context/layout.tsx:424` if it still mirrors instead of deriving
+
+Acceptance criteria:
+
+- There is one clear source of truth for each synced value
+- Child stores no longer need effect-based hydration to stay consistent
+- Initialization and updates both work without manual mirror effects
+
+### Phase 5: Cleanup And Keeper Review
+
+Clean up remaining targeted hotspots and narrow the effects that should stay.
+
+Acceptance criteria:
+
+- Remaining `createEffect` calls in touched files are all true bridges or clearly justified lifecycle sync
+- Mixed-responsibility effects are split into smaller units where still needed
+
+## Detailed Work Items By Area
+
+### 1. Normalize Tab State
+
+Files:
+
+- `packages/app/src/pages/session.tsx:141`
+
+Work:
+
+- Move tab normalization into the functions that create, load, or update tab state
+- Make readers consume already-normalized tab data
+- Remove the effect that rewrites derived tab state after the fact
+
+Rationale:
+
+- Tabs should become valid when written, not be repaired later
+- This removes a feedback loop and makes state easier to trust
+
+Acceptance criteria:
+
+- The effect at `packages/app/src/pages/session.tsx:141` is removed
+- Newly created and restored tabs are normalized before they enter local state
+- Tab rendering still matches current behavior for valid and edge-case inputs
+
+### 2. Key Session-Owned State
+
+Files:
+
+- `packages/app/src/pages/session.tsx:325`
+- `packages/app/src/pages/session.tsx:336`
+- `packages/app/src/pages/session.tsx:477`
+- `packages/app/src/pages/session.tsx:869`
+- `packages/app/src/pages/session.tsx:963`
+- `packages/app/src/pages/session/message-timeline.tsx:149`
+
+Work:
+
+- Identify state that should reset when `sessionKey` changes
+- Move that state under a keyed subtree or keyed owner boundary
+- Remove effects that watch `sessionKey` just to clear local state, refs, or temporary UI flags
+
+Rationale:
+
+- Session identity already defines the lifetime of this UI state
+- Keyed ownership makes reset behavior automatic and easier to reason about
+
+Acceptance criteria:
+
+- The targeted reset effects are removed
+- Changing sessions resets only the intended session-local state
+- Scroll and editor state that should persist are not accidentally reset
+
+### 3. Derive Workspace Order
+
+Files:
+
+- `packages/app/src/pages/layout.tsx:557`
+
+Work:
+
+- Stop writing `workspaceOrder` from live workspace data in an effect
+- Represent user overrides separately from live workspace data
+- Compute effective order from current data plus overrides with a memo or pure helper
+
+Rationale:
+
+- Persisted user intent and live source data should not mirror each other through an effect
+- A computed effective order avoids drift and racey resync behavior
+
+Acceptance criteria:
+
+- The effect at `packages/app/src/pages/layout.tsx:557` is removed
+- Workspace order updates correctly when workspaces appear, disappear, or are reordered by the user
+- User overrides persist without requiring a sync-back effect
+
+### 4. Remove Child-Store Mirrors
+
+Files:
+
+- `packages/app/src/context/global-sync.tsx:130`
+- `packages/app/src/context/global-sync.tsx:138`
+- `packages/app/src/context/global-sync.tsx:148`
+- `packages/app/src/context/global-sync/child-store.ts:184`
+- `packages/app/src/context/global-sync/child-store.ts:190`
+- `packages/app/src/context/global-sync/child-store.ts:193`
+- `packages/app/src/context/layout.tsx:424`
+
+Work:
+
+- Trace the actual ownership of global and child store values
+- Replace hydration and mirror effects with explicit initialization and direct updates
+- Remove the `globalStore.reload` event-bus pattern and call the needed reload paths directly
+
+Rationale:
+
+- Mirrors make it hard to tell which state is authoritative
+- Event-bus style state toggles hide control flow and create accidental reruns
+
+Acceptance criteria:
+
+- Child store hydration no longer depends on effect-based copying
+- Reload work can be followed from the event source to the handler without a reactive relay
+- State remains correct on first load, child creation, and subsequent updates
+
+### 5. Key File-Scoped State
+
+Files:
+
+- `packages/app/src/context/file.tsx:100`
+
+Work:
+
+- Move file-scoped local state under a boundary keyed by `scope()`
+- Remove any effect that watches `scope()` only to reset file-local state
+
+Rationale:
+
+- File scope changes are identity changes
+- Keyed ownership gives a cleaner reset than manual clear logic
+
+Acceptance criteria:
+
+- The effect at `packages/app/src/context/file.tsx:100` is removed
+- Switching scopes resets only scope-local state
+- No previous-scope data appears after a scope change
+
+### 6. Split Layout Side Effects
+
+Files:
+
+- `packages/app/src/pages/layout.tsx:1489`
+- Related event-driven effects near `packages/app/src/pages/layout.tsx:484`, `:652`, `:776`, `:1519`
+
+Work:
+
+- Break the mixed-responsibility effect at `:1489` into direct actions and smaller bridge effects only where required
+- Move user-triggered branches into the actual command or handler that causes them
+- Remove any branch that only exists because one effect is handling unrelated concerns
+
+Rationale:
+
+- Mixed effects hide cause and make reruns hard to predict
+- Smaller units reduce accidental coupling and make future cleanup safer
+
+Acceptance criteria:
+
+- The effect at `packages/app/src/pages/layout.tsx:1489` no longer mixes unrelated responsibilities
+- Event-driven branches execute from direct handlers
+- Remaining effects in this area each have one clear external sync purpose
+
+### 7. Remove Duplicate Triggers
+
+Files:
+
+- `packages/app/src/pages/session/review-tab.tsx:122`
+- `packages/app/src/pages/session/review-tab.tsx:130`
+- `packages/app/src/pages/session/review-tab.tsx:138`
+- `packages/app/src/pages/session/file-tabs.tsx:367`
+- `packages/app/src/pages/session/file-tabs.tsx:378`
+- `packages/app/src/pages/session/file-tabs.tsx:389`
+- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
+- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
+- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
+
+Work:
+
+- Extract one explicit imperative function per behavior
+- Call that function from each source event instead of replicating the same effect pattern multiple times
+- Preserve the scroll-sync effect that is truly syncing with the DOM, but remove duplicate trigger scaffolding around it
+
+Rationale:
+
+- Duplicate triggers make it easy to miss a case or fire twice
+- One named action is easier to test and reason about
+
+Acceptance criteria:
+
+- Repeated imperative effect triplets are collapsed into shared functions
+- Scroll behavior still works, including hash-based navigation
+- No duplicate firing is introduced
+
+### 8. Make Prompt Filtering Reactive
+
+Files:
+
+- `packages/app/src/components/prompt-input.tsx:652`
+- Keep `packages/app/src/components/prompt-input.tsx:690` as needed
+
+Work:
+
+- Convert slash filtering into a pure reactive derivation from the current input and candidate command list
+- Keep only the editor or DOM bridge effect if it is still needed for imperative syncing
+
+Rationale:
+
+- Filtering is classic derived state
+- It should not need an effect if it can be computed from current inputs
+
+Acceptance criteria:
+
+- The effect at `packages/app/src/components/prompt-input.tsx:652` is removed
+- Filtered slash-command results update correctly as the input changes
+- The editor sync effect at `:690` still behaves correctly
+
+### 9. Clean Up Smaller Derived-State Cases
+
+Files:
+
+- `packages/app/src/components/terminal.tsx:261`
+- `packages/app/src/components/session/session-header.tsx:309`
+
+Work:
+
+- Replace effect-written local state with memos or inline derivation
+- Remove intermediate setters when the value can be computed directly
+
+Rationale:
+
+- These are low-risk wins that reinforce the same pattern
+- They also help keep follow-up cleanup consistent
+
+Acceptance criteria:
+
+- Targeted effects are removed
+- UI output remains unchanged under the same inputs
+
+## Verification And Regression Checks
+
+Run focused checks after each phase, not only at the end.
+
+### Suggested Verification
+
+- Switch between sessions rapidly and confirm local session UI resets only where intended
+- Open, close, and reorder tabs and confirm order and normalization remain stable
+- Change workspaces, reload workspace data, and verify effective ordering is correct
+- Change file scope and confirm stale file state does not bleed across scopes
+- Trigger layout actions that previously depended on effects and confirm they still fire once
+- Use slash commands in the prompt and verify filtering updates as you type
+- Test review tab, file tab, and hash-scroll flows for duplicate or missing triggers
+- Verify global sync initialization, reload, and child-store creation paths
+
+### Regression Checks
+
+- No accidental infinite reruns
+- No double-firing network or command actions
+- No lost cleanup for listeners, timers, or scroll handlers
+- No preserved stale state after identity changes
+- No removed effect that was actually bridging to DOM or an external API
+
+If available, add or update tests around pure helpers introduced during this cleanup.
+
+Favor tests for derived ordering, normalization, and action extraction, since those are easiest to lock down.
+
+## Definition Of Done
+
+This work is done when all of the following are true:
+
+- The highest-leverage targets in this spec are implemented
+- Each removed effect has been replaced by a clearer pattern: memo, keyed boundary, direct action, or lifecycle hook
+- The "should remain" effects still exist only where they serve a real external sync purpose
+- Touched files have fewer mixed-responsibility effects and clearer ownership of state
+- Manual verification covers session switching, file scope changes, workspace ordering, prompt filtering, and reload flows
+- No behavior regressions are found in the targeted areas
+
+A reduced raw `createEffect` count is helpful, but it is not the main success metric.
+
+The main success metric is clearer ownership and fewer effect-driven state repairs.
+
+## Risks And Rollout Notes
+
+Main risks:
+
+- Keyed remounts can reset too much if state boundaries are drawn too high
+- Store mirror removal can break initialization order if ownership is not mapped first
+- Moving event work out of effects can accidentally skip triggers that were previously implicit
+
+Rollout notes:
+
+- Land in small phases, with each phase keeping the app behaviorally stable
+- Prefer isolated PRs by phase or by file cluster, especially for context-store changes
+- Review each remaining effect in touched files and leave it only if it clearly bridges to something external

+ 11 - 6
packages/app/e2e/projects/projects-switch.spec.ts

@@ -92,14 +92,19 @@ test("switching back to a project opens the latest workspace session", async ({
 
 
         await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
         await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
 
 
-        const created = await createSdk(workspaceDir)
-          .session.create()
-          .then((x) => x.data?.id)
-        if (!created) throw new Error(`Failed to create session for workspace: ${workspaceDir}`)
+        // Create a session by sending a prompt
+        const prompt = page.locator(promptSelector)
+        await expect(prompt).toBeVisible()
+        await prompt.fill("test")
+        await page.keyboard.press("Enter")
+
+        // Wait for the URL to update with the new session ID
+        await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
+
+        const created = sessionIDFromUrl(page.url())
+        if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
         sessionID = created
         sessionID = created
 
 
-        await page.goto(sessionPath(workspaceDir, created))
-        await expect(page.locator(promptSelector)).toBeVisible()
         await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
         await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
 
 
         await openSidebar(page)
         await openSidebar(page)

+ 11 - 0
packages/app/e2e/session/session-composer-dock.spec.ts

@@ -142,6 +142,17 @@ test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
   })
   })
 })
 })
 
 
+test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const button = page.locator('[data-action="prompt-permissions"]').first()
+  await expect(button).toBeVisible()
+  await expect(button).toHaveAttribute("aria-pressed", "false")
+
+  await setAutoAccept(page, true)
+  await setAutoAccept(page, false)
+})
+
 test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
 test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
   await withDockSession(sdk, "e2e composer dock question", async (session) => {
   await withDockSession(sdk, "e2e composer dock question", async (session) => {
     await withDockSeed(sdk, session.id, async () => {
     await withDockSeed(sdk, session.id, async () => {

+ 1 - 1
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/app",
   "name": "@opencode-ai/app",
-  "version": "1.2.15",
+  "version": "1.2.16",
   "description": "",
   "description": "",
   "type": "module",
   "type": "module",
   "exports": {
   "exports": {

+ 1 - 0
packages/app/script/e2e-local.ts

@@ -145,6 +145,7 @@ try {
     Object.assign(process.env, serverEnv)
     Object.assign(process.env, serverEnv)
     process.env.AGENT = "1"
     process.env.AGENT = "1"
     process.env.OPENCODE = "1"
     process.env.OPENCODE = "1"
+    process.env.OPENCODE_PID = String(process.pid)
 
 
     const log = await import("../../opencode/src/util/log")
     const log = await import("../../opencode/src/util/log")
     const install = await import("../../opencode/src/installation")
     const install = await import("../../opencode/src/installation")

+ 7 - 4
packages/app/src/app.tsx

@@ -7,8 +7,8 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked"
 import { Font } from "@opencode-ai/ui/font"
 import { Font } from "@opencode-ai/ui/font"
 import { ThemeProvider } from "@opencode-ai/ui/theme"
 import { ThemeProvider } from "@opencode-ai/ui/theme"
 import { MetaProvider } from "@solidjs/meta"
 import { MetaProvider } from "@solidjs/meta"
-import { Navigate, Route, Router } from "@solidjs/router"
-import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
+import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
+import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
 import { CommandProvider } from "@/context/command"
 import { CommandProvider } from "@/context/command"
 import { CommentsProvider } from "@/context/comments"
 import { CommentsProvider } from "@/context/comments"
 import { FileProvider } from "@/context/file"
 import { FileProvider } from "@/context/file"
@@ -28,6 +28,7 @@ import { TerminalProvider } from "@/context/terminal"
 import DirectoryLayout from "@/pages/directory-layout"
 import DirectoryLayout from "@/pages/directory-layout"
 import Layout from "@/pages/layout"
 import Layout from "@/pages/layout"
 import { ErrorPage } from "./pages/error"
 import { ErrorPage } from "./pages/error"
+import { Dynamic } from "solid-js/web"
 
 
 const Home = lazy(() => import("@/pages/home"))
 const Home = lazy(() => import("@/pages/home"))
 const Session = lazy(() => import("@/pages/session"))
 const Session = lazy(() => import("@/pages/session"))
@@ -144,13 +145,15 @@ export function AppInterface(props: {
   children?: JSX.Element
   children?: JSX.Element
   defaultServer: ServerConnection.Key
   defaultServer: ServerConnection.Key
   servers?: Array<ServerConnection.Any>
   servers?: Array<ServerConnection.Any>
+  router?: Component<BaseRouterProps>
 }) {
 }) {
   return (
   return (
     <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
     <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
       <ServerKey>
       <ServerKey>
         <GlobalSDKProvider>
         <GlobalSDKProvider>
           <GlobalSyncProvider>
           <GlobalSyncProvider>
-            <Router
+            <Dynamic
+              component={props.router ?? Router}
               root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
               root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
             >
             >
               <Route path="/" component={HomeRoute} />
               <Route path="/" component={HomeRoute} />
@@ -158,7 +161,7 @@ export function AppInterface(props: {
                 <Route path="/" component={SessionIndexRoute} />
                 <Route path="/" component={SessionIndexRoute} />
                 <Route path="/session/:id?" component={SessionRoute} />
                 <Route path="/session/:id?" component={SessionRoute} />
               </Route>
               </Route>
-            </Router>
+            </Dynamic>
           </GlobalSyncProvider>
           </GlobalSyncProvider>
         </GlobalSDKProvider>
         </GlobalSDKProvider>
       </ServerKey>
       </ServerKey>

+ 1 - 2
packages/app/src/components/dialog-connect-provider.tsx

@@ -4,7 +4,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { IconButton } from "@opencode-ai/ui/icon-button"
-import type { IconName } from "@opencode-ai/ui/icons/provider"
 import { List, type ListRef } from "@opencode-ai/ui/list"
 import { List, type ListRef } from "@opencode-ai/ui/list"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Spinner } from "@opencode-ai/ui/spinner"
 import { Spinner } from "@opencode-ai/ui/spinner"
@@ -447,7 +446,7 @@ export function DialogConnectProvider(props: { provider: string }) {
     >
     >
       <div class="flex flex-col gap-6 px-2.5 pb-3">
       <div class="flex flex-col gap-6 px-2.5 pb-3">
         <div class="px-2.5 flex gap-4 items-center">
         <div class="px-2.5 flex gap-4 items-center">
-          <ProviderIcon id={props.provider as IconName} class="size-5 shrink-0 icon-strong-base" />
+          <ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
           <div class="text-16-medium text-text-strong">
           <div class="text-16-medium text-text-strong">
             <Switch>
             <Switch>
               <Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
               <Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>

+ 1 - 2
packages/app/src/components/dialog-select-model-unpaid.tsx

@@ -1,7 +1,6 @@
 import { Button } from "@opencode-ai/ui/button"
 import { Button } from "@opencode-ai/ui/button"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
-import type { IconName } from "@opencode-ai/ui/icons/provider"
 import { List, type ListRef } from "@opencode-ai/ui/list"
 import { List, type ListRef } from "@opencode-ai/ui/list"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Tag } from "@opencode-ai/ui/tag"
 import { Tag } from "@opencode-ai/ui/tag"
@@ -95,7 +94,7 @@ export const DialogSelectModelUnpaid: Component = () => {
               >
               >
                 {(i) => (
                 {(i) => (
                   <div class="w-full flex items-center gap-x-3">
                   <div class="w-full flex items-center gap-x-3">
-                    <ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
+                    <ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
                     <span>{i.name}</span>
                     <span>{i.name}</span>
                     <Show when={i.id === "opencode"}>
                     <Show when={i.id === "opencode"}>
                       <div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
                       <div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>

+ 1 - 7
packages/app/src/components/dialog-select-provider.tsx

@@ -5,18 +5,12 @@ import { Dialog } from "@opencode-ai/ui/dialog"
 import { List } from "@opencode-ai/ui/list"
 import { List } from "@opencode-ai/ui/list"
 import { Tag } from "@opencode-ai/ui/tag"
 import { Tag } from "@opencode-ai/ui/tag"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import { iconNames, type IconName } from "@opencode-ai/ui/icons/provider"
 import { DialogConnectProvider } from "./dialog-connect-provider"
 import { DialogConnectProvider } from "./dialog-connect-provider"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { DialogCustomProvider } from "./dialog-custom-provider"
 import { DialogCustomProvider } from "./dialog-custom-provider"
 
 
 const CUSTOM_ID = "_custom"
 const CUSTOM_ID = "_custom"
 
 
-function icon(id: string): IconName {
-  if (iconNames.includes(id as IconName)) return id as IconName
-  return "synthetic"
-}
-
 export const DialogSelectProvider: Component = () => {
 export const DialogSelectProvider: Component = () => {
   const dialog = useDialog()
   const dialog = useDialog()
   const providers = useProviders()
   const providers = useProviders()
@@ -69,7 +63,7 @@ export const DialogSelectProvider: Component = () => {
       >
       >
         {(i) => (
         {(i) => (
           <div class="px-1.25 w-full flex items-center gap-x-3">
           <div class="px-1.25 w-full flex items-center gap-x-3">
-            <ProviderIcon data-slot="list-item-extra-icon" id={icon(i.id)} />
+            <ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
             <span>{i.name}</span>
             <span>{i.name}</span>
             <Show when={i.id === "opencode"}>
             <Show when={i.id === "opencode"}>
               <div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
               <div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>

+ 0 - 6
packages/app/src/components/file-tree.tsx

@@ -325,12 +325,6 @@ export default function FileTree(props: {
     ),
     ),
   )
   )
 
 
-  createEffect(() => {
-    const dir = file.tree.state(props.path)
-    if (!shouldListExpanded({ level, dir })) return
-    void file.tree.list(props.path)
-  })
-
   const nodes = createMemo(() => {
   const nodes = createMemo(() => {
     const nodes = file.tree.children(props.path)
     const nodes = file.tree.children(props.path)
     const current = filter()
     const current = filter()

+ 82 - 38
packages/app/src/components/prompt-input.tsx

@@ -1,4 +1,5 @@
 import { useFilteredList } from "@opencode-ai/ui/hooks"
 import { useFilteredList } from "@opencode-ai/ui/hooks"
+import { useSpring } from "@opencode-ai/ui/motion-spring"
 import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
 import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
 import { createFocusSignal } from "@solid-primitives/active-element"
 import { createFocusSignal } from "@solid-primitives/active-element"
@@ -23,7 +24,6 @@ import { Button } from "@opencode-ai/ui/button"
 import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
 import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Icon } from "@opencode-ai/ui/icon"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import type { IconName } from "@opencode-ai/ui/icons/provider"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Select } from "@opencode-ai/ui/select"
 import { Select } from "@opencode-ai/ui/select"
@@ -244,6 +244,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     draggingType: "image" | "@mention" | null
     draggingType: "image" | "@mention" | null
     mode: "normal" | "shell"
     mode: "normal" | "shell"
     applyingHistory: boolean
     applyingHistory: boolean
+    pendingAutoAccept: boolean
   }>({
   }>({
     popover: null,
     popover: null,
     historyIndex: -1,
     historyIndex: -1,
@@ -252,8 +253,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     draggingType: null,
     draggingType: null,
     mode: "normal",
     mode: "normal",
     applyingHistory: false,
     applyingHistory: false,
+    pendingAutoAccept: false,
   })
   })
 
 
+  const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
+
   const commentCount = createMemo(() => {
   const commentCount = createMemo(() => {
     if (store.mode === "shell") return 0
     if (store.mode === "shell") return 0
     return prompt.context.items().filter((item) => !!item.comment?.trim()).length
     return prompt.context.items().filter((item) => !!item.comment?.trim()).length
@@ -302,6 +306,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }),
     }),
   )
   )
 
 
+  createEffect(
+    on(sessionKey, () => {
+      setStore("pendingAutoAccept", false)
+    }),
+  )
+
   const historyComments = () => {
   const historyComments = () => {
     const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
     const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
     return prompt.context.items().flatMap((item) => {
     return prompt.context.items().flatMap((item) => {
@@ -592,7 +602,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     setActive: setSlashActive,
     setActive: setSlashActive,
     onInput: slashOnInput,
     onInput: slashOnInput,
     onKeyDown: slashOnKeyDown,
     onKeyDown: slashOnKeyDown,
-    refetch: slashRefetch,
   } = useFilteredList<SlashCommand>({
   } = useFilteredList<SlashCommand>({
     items: slashCommands,
     items: slashCommands,
     key: (x) => x?.id,
     key: (x) => x?.id,
@@ -649,14 +658,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
     }
   }
   }
 
 
-  createEffect(
-    on(
-      () => sync.data.command,
-      () => slashRefetch(),
-      { defer: true },
-    ),
-  )
-
   // Auto-scroll active command into view when navigating with keyboard
   // Auto-scroll active command into view when navigating with keyboard
   createEffect(() => {
   createEffect(() => {
     const activeId = slashActive()
     const activeId = slashActive()
@@ -957,10 +958,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     readClipboardImage: platform.readClipboardImage,
     readClipboardImage: platform.readClipboardImage,
   })
   })
 
 
+  const variants = createMemo(() => ["default", ...local.model.variant.list()])
+  const accepting = createMemo(() => {
+    const id = params.id
+    if (!id) return store.pendingAutoAccept
+    return permission.isAutoAccepting(id, sdk.directory)
+  })
+
   const { abort, handleSubmit } = createPromptSubmit({
   const { abort, handleSubmit } = createPromptSubmit({
     info,
     info,
     imageAttachments,
     imageAttachments,
     commentCount,
     commentCount,
+    autoAccept: () => accepting(),
     mode: () => store.mode,
     mode: () => store.mode,
     working,
     working,
     editor: () => editorRef,
     editor: () => editorRef,
@@ -1125,13 +1134,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
     }
   }
   }
 
 
-  const variants = createMemo(() => ["default", ...local.model.variant.list()])
-  const accepting = createMemo(() => {
-    const id = params.id
-    if (!id) return false
-    return permission.isAutoAccepting(id, sdk.directory)
-  })
-
   return (
   return (
     <div class="relative size-full _max-h-[320px] flex flex-col gap-0">
     <div class="relative size-full _max-h-[320px] flex flex-col gap-0">
       <PromptPopover
       <PromptPopover
@@ -1251,10 +1253,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
 
             <div
             <div
               aria-hidden={store.mode !== "normal"}
               aria-hidden={store.mode !== "normal"}
-              class="flex items-center gap-1 transition-all duration-200 ease-out"
-              classList={{
-                "opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
-                "opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
+              class="flex items-center gap-1"
+              style={{
+                "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
               }}
               }}
             >
             >
               <TooltipKeybind
               <TooltipKeybind
@@ -1267,6 +1268,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   type="button"
                   type="button"
                   variant="ghost"
                   variant="ghost"
                   class="size-8 p-0"
                   class="size-8 p-0"
+                  style={{
+                    opacity: buttonsSpring(),
+                    transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
+                    filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
+                  }}
                   onClick={pick}
                   onClick={pick}
                   disabled={store.mode !== "normal"}
                   disabled={store.mode !== "normal"}
                   tabIndex={store.mode === "normal" ? undefined : -1}
                   tabIndex={store.mode === "normal" ? undefined : -1}
@@ -1304,6 +1310,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   icon={working() ? "stop" : "arrow-up"}
                   icon={working() ? "stop" : "arrow-up"}
                   variant="primary"
                   variant="primary"
                   class="size-8"
                   class="size-8"
+                  style={{
+                    opacity: buttonsSpring(),
+                    transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
+                    filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
+                  }}
                   aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
                   aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
                 />
                 />
               </Tooltip>
               </Tooltip>
@@ -1323,9 +1334,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 <Button
                 <Button
                   data-action="prompt-permissions"
                   data-action="prompt-permissions"
                   variant="ghost"
                   variant="ghost"
-                  disabled={!params.id}
                   onClick={() => {
                   onClick={() => {
-                    if (!params.id) return
+                    if (!params.id) {
+                      setStore("pendingAutoAccept", (value) => !value)
+                      return
+                    }
                     permission.toggleAutoAccept(params.id, sdk.directory)
                     permission.toggleAutoAccept(params.id, sdk.directory)
                   }}
                   }}
                   classList={{
                   classList={{
@@ -1354,14 +1367,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       <Show when={store.mode === "normal" || store.mode === "shell"}>
       <Show when={store.mode === "normal" || store.mode === "shell"}>
         <DockTray attach="top">
         <DockTray attach="top">
           <div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
           <div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
-            <div class="flex items-center gap-1.5 min-w-0 flex-1">
-              <Show when={store.mode === "shell"}>
-                <div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
-                  <span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
-                  <div class="size-4 shrink-0" />
-                </div>
-              </Show>
-              <Show when={store.mode === "normal"}>
+            <div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
+              <div
+                class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
+                style={{
+                  padding: "0 4px 0 8px",
+                  opacity: 1 - buttonsSpring(),
+                  transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
+                  filter: `blur(${buttonsSpring() * 2}px)`,
+                  "pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
+                }}
+              >
+                <span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
+                <div class="size-4 shrink-0" />
+              </div>
+              <div class="flex items-center gap-1.5 min-w-0 flex-1">
                 <TooltipKeybind
                 <TooltipKeybind
                   placement="top"
                   placement="top"
                   gutter={4}
                   gutter={4}
@@ -1375,7 +1395,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                     onSelect={local.agent.set}
                     onSelect={local.agent.set}
                     class="capitalize max-w-[160px]"
                     class="capitalize max-w-[160px]"
                     valueClass="truncate text-13-regular"
                     valueClass="truncate text-13-regular"
-                    triggerStyle={{ height: "28px" }}
+                    triggerStyle={{
+                      height: "28px",
+                      opacity: buttonsSpring(),
+                      transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
+                      filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
+                      "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
+                    }}
                     variant="ghost"
                     variant="ghost"
                   />
                   />
                 </TooltipKeybind>
                 </TooltipKeybind>
@@ -1393,12 +1419,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                         variant="ghost"
                         variant="ghost"
                         size="normal"
                         size="normal"
                         class="min-w-0 max-w-[320px] text-13-regular group"
                         class="min-w-0 max-w-[320px] text-13-regular group"
-                        style={{ height: "28px" }}
+                        style={{
+                          height: "28px",
+                          opacity: buttonsSpring(),
+                          transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
+                          filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
+                          "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
+                        }}
                         onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
                         onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
                       >
                       >
                         <Show when={local.model.current()?.provider?.id}>
                         <Show when={local.model.current()?.provider?.id}>
                           <ProviderIcon
                           <ProviderIcon
-                            id={local.model.current()!.provider.id as IconName}
+                            id={local.model.current()!.provider.id}
                             class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
                             class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
                             style={{ "will-change": "opacity", transform: "translateZ(0)" }}
                             style={{ "will-change": "opacity", transform: "translateZ(0)" }}
                           />
                           />
@@ -1422,13 +1454,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                       triggerProps={{
                       triggerProps={{
                         variant: "ghost",
                         variant: "ghost",
                         size: "normal",
                         size: "normal",
-                        style: { height: "28px" },
+                        style: {
+                          height: "28px",
+                          opacity: buttonsSpring(),
+                          transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
+                          filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
+                          "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
+                        },
                         class: "min-w-0 max-w-[320px] text-13-regular group",
                         class: "min-w-0 max-w-[320px] text-13-regular group",
                       }}
                       }}
                     >
                     >
                       <Show when={local.model.current()?.provider?.id}>
                       <Show when={local.model.current()?.provider?.id}>
                         <ProviderIcon
                         <ProviderIcon
-                          id={local.model.current()!.provider.id as IconName}
+                          id={local.model.current()!.provider.id}
                           class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
                           class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
                           style={{ "will-change": "opacity", transform: "translateZ(0)" }}
                           style={{ "will-change": "opacity", transform: "translateZ(0)" }}
                         />
                         />
@@ -1454,11 +1492,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                     onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
                     onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
                     class="capitalize max-w-[160px]"
                     class="capitalize max-w-[160px]"
                     valueClass="truncate text-13-regular"
                     valueClass="truncate text-13-regular"
-                    triggerStyle={{ height: "28px" }}
+                    triggerStyle={{
+                      height: "28px",
+                      opacity: buttonsSpring(),
+                      transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
+                      filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
+                      "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
+                    }}
                     variant="ghost"
                     variant="ghost"
                   />
                   />
                 </TooltipKeybind>
                 </TooltipKeybind>
-              </Show>
+              </div>
             </div>
             </div>
             <div class="shrink-0">
             <div class="shrink-0">
               <RadioGroup
               <RadioGroup

+ 38 - 0
packages/app/src/components/prompt-input/submit.test.ts

@@ -5,6 +5,7 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
 
 
 const createdClients: string[] = []
 const createdClients: string[] = []
 const createdSessions: string[] = []
 const createdSessions: string[] = []
+const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
 const sentShell: string[] = []
 const sentShell: string[] = []
 const syncedDirectories: string[] = []
 const syncedDirectories: string[] = []
 
 
@@ -69,6 +70,14 @@ beforeAll(async () => {
     }),
     }),
   }))
   }))
 
 
+  mock.module("@/context/permission", () => ({
+    usePermission: () => ({
+      enableAutoAccept(sessionID: string, directory: string) {
+        enabledAutoAccept.push({ sessionID, directory })
+      },
+    }),
+  }))
+
   mock.module("@/context/prompt", () => ({
   mock.module("@/context/prompt", () => ({
     usePrompt: () => ({
     usePrompt: () => ({
       current: () => promptValue,
       current: () => promptValue,
@@ -145,6 +154,7 @@ beforeAll(async () => {
 beforeEach(() => {
 beforeEach(() => {
   createdClients.length = 0
   createdClients.length = 0
   createdSessions.length = 0
   createdSessions.length = 0
+  enabledAutoAccept.length = 0
   sentShell.length = 0
   sentShell.length = 0
   syncedDirectories.length = 0
   syncedDirectories.length = 0
   selected = "/repo/worktree-a"
   selected = "/repo/worktree-a"
@@ -156,6 +166,7 @@ describe("prompt submit worktree selection", () => {
       info: () => undefined,
       info: () => undefined,
       imageAttachments: () => [],
       imageAttachments: () => [],
       commentCount: () => 0,
       commentCount: () => 0,
+      autoAccept: () => false,
       mode: () => "shell",
       mode: () => "shell",
       working: () => false,
       working: () => false,
       editor: () => undefined,
       editor: () => undefined,
@@ -181,4 +192,31 @@ describe("prompt submit worktree selection", () => {
     expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
     expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
     expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
     expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
   })
   })
+
+  test("applies auto-accept to newly created sessions", async () => {
+    const submit = createPromptSubmit({
+      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 event = { preventDefault: () => undefined } as unknown as Event
+
+    await submit.handleSubmit(event)
+
+    expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
+  })
 })
 })

+ 5 - 0
packages/app/src/components/prompt-input/submit.ts

@@ -8,6 +8,7 @@ import { useGlobalSync } from "@/context/global-sync"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 import { useLayout } from "@/context/layout"
 import { useLocal } from "@/context/local"
 import { useLocal } from "@/context/local"
+import { usePermission } from "@/context/permission"
 import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
 import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
 import { useSDK } from "@/context/sdk"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
 import { useSync } from "@/context/sync"
@@ -27,6 +28,7 @@ type PromptSubmitInput = {
   info: Accessor<{ id: string } | undefined>
   info: Accessor<{ id: string } | undefined>
   imageAttachments: Accessor<ImageAttachmentPart[]>
   imageAttachments: Accessor<ImageAttachmentPart[]>
   commentCount: Accessor<number>
   commentCount: Accessor<number>
+  autoAccept: Accessor<boolean>
   mode: Accessor<"normal" | "shell">
   mode: Accessor<"normal" | "shell">
   working: Accessor<boolean>
   working: Accessor<boolean>
   editor: () => HTMLDivElement | undefined
   editor: () => HTMLDivElement | undefined
@@ -56,6 +58,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
   const sync = useSync()
   const sync = useSync()
   const globalSync = useGlobalSync()
   const globalSync = useGlobalSync()
   const local = useLocal()
   const local = useLocal()
+  const permission = usePermission()
   const prompt = usePrompt()
   const prompt = usePrompt()
   const layout = useLayout()
   const layout = useLayout()
   const language = useLanguage()
   const language = useLanguage()
@@ -140,6 +143,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
 
 
     const projectDirectory = sdk.directory
     const projectDirectory = sdk.directory
     const isNewSession = !params.id
     const isNewSession = !params.id
+    const shouldAutoAccept = isNewSession && input.autoAccept()
     const worktreeSelection = input.newSessionWorktree?.() || "main"
     const worktreeSelection = input.newSessionWorktree?.() || "main"
 
 
     let sessionDirectory = projectDirectory
     let sessionDirectory = projectDirectory
@@ -197,6 +201,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
           return undefined
           return undefined
         })
         })
       if (session) {
       if (session) {
+        if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
         layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
         layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
         navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
         navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
       }
       }

+ 15 - 15
packages/app/src/components/session/session-header.tsx

@@ -138,12 +138,12 @@ function useSessionShare(args: {
   globalSDK: ReturnType<typeof useGlobalSDK>
   globalSDK: ReturnType<typeof useGlobalSDK>
   currentSession: () =>
   currentSession: () =>
     | {
     | {
-        id: string
         share?: {
         share?: {
           url?: string
           url?: string
         }
         }
       }
       }
     | undefined
     | undefined
+  sessionID: () => string | undefined
   projectDirectory: () => string
   projectDirectory: () => string
   platform: ReturnType<typeof usePlatform>
   platform: ReturnType<typeof usePlatform>
 }) {
 }) {
@@ -167,11 +167,11 @@ function useSessionShare(args: {
   })
   })
 
 
   const shareSession = () => {
   const shareSession = () => {
-    const session = args.currentSession()
-    if (!session || state.share) return
+    const sessionID = args.sessionID()
+    if (!sessionID || state.share) return
     setState("share", true)
     setState("share", true)
     args.globalSDK.client.session
     args.globalSDK.client.session
-      .share({ sessionID: session.id, directory: args.projectDirectory() })
+      .share({ sessionID, directory: args.projectDirectory() })
       .catch((error) => {
       .catch((error) => {
         console.error("Failed to share session", error)
         console.error("Failed to share session", error)
       })
       })
@@ -181,11 +181,11 @@ function useSessionShare(args: {
   }
   }
 
 
   const unshareSession = () => {
   const unshareSession = () => {
-    const session = args.currentSession()
-    if (!session || state.unshare) return
+    const sessionID = args.sessionID()
+    if (!sessionID || state.unshare) return
     setState("unshare", true)
     setState("unshare", true)
     args.globalSDK.client.session
     args.globalSDK.client.session
-      .unshare({ sessionID: session.id, directory: args.projectDirectory() })
+      .unshare({ sessionID, directory: args.projectDirectory() })
       .catch((error) => {
       .catch((error) => {
         console.error("Failed to unshare session", error)
         console.error("Failed to unshare session", error)
       })
       })
@@ -243,9 +243,9 @@ export function SessionHeader() {
   })
   })
   const hotkey = createMemo(() => command.keybind("file.open"))
   const hotkey = createMemo(() => command.keybind("file.open"))
 
 
-  const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
+  const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
   const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
   const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
-  const showShare = createMemo(() => shareEnabled() && !!currentSession())
+  const showShare = createMemo(() => shareEnabled() && !!params.id)
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const view = createMemo(() => layout.view(sessionKey))
   const view = createMemo(() => layout.view(sessionKey))
   const os = createMemo(() => detectOS(platform))
   const os = createMemo(() => detectOS(platform))
@@ -306,11 +306,10 @@ export function SessionHeader() {
   const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
   const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
   const opening = createMemo(() => openRequest.app !== undefined)
   const opening = createMemo(() => openRequest.app !== undefined)
 
 
-  createEffect(() => {
-    const value = prefs.app
-    if (options().some((o) => o.id === value)) return
-    setPrefs("app", options()[0]?.id ?? "finder")
-  })
+  const selectApp = (app: OpenApp) => {
+    if (!options().some((item) => item.id === app)) return
+    setPrefs("app", app)
+  }
 
 
   const openDir = (app: OpenApp) => {
   const openDir = (app: OpenApp) => {
     if (opening() || !canOpen() || !platform.openPath) return
     if (opening() || !canOpen() || !platform.openPath) return
@@ -347,6 +346,7 @@ export function SessionHeader() {
   const share = useSessionShare({
   const share = useSessionShare({
     globalSDK,
     globalSDK,
     currentSession,
     currentSession,
+    sessionID: () => params.id,
     projectDirectory,
     projectDirectory,
     platform,
     platform,
   })
   })
@@ -458,7 +458,7 @@ export function SessionHeader() {
                                   value={current().id}
                                   value={current().id}
                                   onChange={(value) => {
                                   onChange={(value) => {
                                     if (!OPEN_APPS.includes(value as OpenApp)) return
                                     if (!OPEN_APPS.includes(value as OpenApp)) return
-                                    setPrefs("app", value as OpenApp)
+                                    selectApp(value as OpenApp)
                                   }}
                                   }}
                                 >
                                 >
                                   <For each={options()}>
                                   <For each={options()}>

+ 1 - 2
packages/app/src/components/settings-models.tsx

@@ -4,7 +4,6 @@ import { Switch } from "@opencode-ai/ui/switch"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { TextField } from "@opencode-ai/ui/text-field"
-import type { IconName } from "@opencode-ai/ui/icons/provider"
 import { type Component, For, Show } from "solid-js"
 import { type Component, For, Show } from "solid-js"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { useModels } from "@/context/models"
 import { useModels } from "@/context/models"
@@ -98,7 +97,7 @@ export const SettingsModels: Component = () => {
               {(group) => (
               {(group) => (
                 <div class="flex flex-col gap-1">
                 <div class="flex flex-col gap-1">
                   <div class="flex items-center gap-2 pb-2">
                   <div class="flex items-center gap-2 pb-2">
-                    <ProviderIcon id={group.category as IconName} class="size-5 shrink-0 icon-strong-base" />
+                    <ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" />
                     <span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
                     <span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
                   </div>
                   </div>
                   <div class="bg-surface-raised-base px-4 rounded-lg">
                   <div class="bg-surface-raised-base px-4 rounded-lg">

+ 3 - 9
packages/app/src/components/settings-providers.tsx

@@ -3,7 +3,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Tag } from "@opencode-ai/ui/tag"
 import { Tag } from "@opencode-ai/ui/tag"
 import { showToast } from "@opencode-ai/ui/toast"
 import { showToast } from "@opencode-ai/ui/toast"
-import { iconNames, type IconName } from "@opencode-ai/ui/icons/provider"
 import { popularProviders, useProviders } from "@/hooks/use-providers"
 import { popularProviders, useProviders } from "@/hooks/use-providers"
 import { createMemo, type Component, For, Show } from "solid-js"
 import { createMemo, type Component, For, Show } from "solid-js"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
@@ -33,11 +32,6 @@ export const SettingsProviders: Component = () => {
   const globalSync = useGlobalSync()
   const globalSync = useGlobalSync()
   const providers = useProviders()
   const providers = useProviders()
 
 
-  const icon = (id: string): IconName => {
-    if (iconNames.includes(id as IconName)) return id as IconName
-    return "synthetic"
-  }
-
   const connected = createMemo(() => {
   const connected = createMemo(() => {
     return providers
     return providers
       .connected()
       .connected()
@@ -154,7 +148,7 @@ export const SettingsProviders: Component = () => {
                 {(item) => (
                 {(item) => (
                   <div class="group flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
                   <div class="group flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
                     <div class="flex items-center gap-3 min-w-0">
                     <div class="flex items-center gap-3 min-w-0">
-                      <ProviderIcon id={icon(item.id)} class="size-5 shrink-0 icon-strong-base" />
+                      <ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
                       <span class="text-14-medium text-text-strong truncate">{item.name}</span>
                       <span class="text-14-medium text-text-strong truncate">{item.name}</span>
                       <Tag>{type(item)}</Tag>
                       <Tag>{type(item)}</Tag>
                     </div>
                     </div>
@@ -185,7 +179,7 @@ export const SettingsProviders: Component = () => {
                 <div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
                 <div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
                   <div class="flex flex-col min-w-0">
                   <div class="flex flex-col min-w-0">
                     <div class="flex items-center gap-x-3">
                     <div class="flex items-center gap-x-3">
-                      <ProviderIcon id={icon(item.id)} class="size-5 shrink-0 icon-strong-base" />
+                      <ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
                       <span class="text-14-medium text-text-strong">{item.name}</span>
                       <span class="text-14-medium text-text-strong">{item.name}</span>
                       <Show when={item.id === "opencode"}>
                       <Show when={item.id === "opencode"}>
                         <span class="text-14-regular text-text-weak">
                         <span class="text-14-regular text-text-weak">
@@ -228,7 +222,7 @@ export const SettingsProviders: Component = () => {
             >
             >
               <div class="flex flex-col min-w-0">
               <div class="flex flex-col min-w-0">
                 <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
                 <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
-                  <ProviderIcon id={icon("synthetic")} class="size-5 shrink-0 icon-strong-base" />
+                  <ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
                   <span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
                   <span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
                   <Tag>{language.t("settings.providers.tag.custom")}</Tag>
                   <Tag>{language.t("settings.providers.tag.custom")}</Tag>
                 </div>
                 </div>

+ 12 - 15
packages/app/src/components/status-popover.tsx

@@ -202,29 +202,26 @@ export function StatusPopover() {
       triggerAs={Button}
       triggerAs={Button}
       triggerProps={{
       triggerProps={{
         variant: "ghost",
         variant: "ghost",
-        class:
-          "rounded-md h-[24px] pr-3 pl-0.5 gap-2 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
+        class: "titlebar-icon w-6 h-6 p-0 box-border",
+        "aria-label": language.t("status.popover.trigger"),
         style: { scale: 1 },
         style: { scale: 1 },
       }}
       }}
       trigger={
       trigger={
-        <div class="flex items-center gap-0.5">
-          <div class="size-4 flex items-center justify-center">
-            <div
-              classList={{
-                "size-1.5 rounded-full": true,
-                "bg-icon-success-base": overallHealthy(),
-                "bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
-                "bg-border-weak-base": server.healthy() === undefined,
-              }}
-            />
-          </div>
-          <span class="text-12-regular text-text-strong">{language.t("status.popover.trigger")}</span>
+        <div class="flex size-4 items-center justify-center">
+          <div
+            classList={{
+              "size-1.5 rounded-full": true,
+              "bg-icon-success-base": overallHealthy(),
+              "bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
+              "bg-border-weak-base": server.healthy() === undefined,
+            }}
+          />
         </div>
         </div>
       }
       }
       class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
       class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
       gutter={4}
       gutter={4}
       placement="bottom-end"
       placement="bottom-end"
-      shift={-136}
+      shift={-168}
     >
     >
       <div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
       <div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
         <Tabs
         <Tabs

+ 3 - 4
packages/app/src/components/terminal.tsx

@@ -1,7 +1,7 @@
 import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
 import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
 import { showToast } from "@opencode-ai/ui/toast"
 import { showToast } from "@opencode-ai/ui/toast"
 import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
 import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
-import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
+import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
 import { SerializeAddon } from "@/addons/serialize"
 import { SerializeAddon } from "@/addons/serialize"
 import { matchKeybind, parseKeybind } from "@/context/command"
 import { matchKeybind, parseKeybind } from "@/context/command"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
@@ -219,7 +219,7 @@ export const Terminal = (props: TerminalProps) => {
     }
     }
   }
   }
 
 
-  const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
+  const terminalColors = createMemo(getTerminalColors)
 
 
   const scheduleFit = () => {
   const scheduleFit = () => {
     if (disposed) return
     if (disposed) return
@@ -259,8 +259,7 @@ export const Terminal = (props: TerminalProps) => {
   }
   }
 
 
   createEffect(() => {
   createEffect(() => {
-    const colors = getTerminalColors()
-    setTerminalColors(colors)
+    const colors = terminalColors()
     if (!term) return
     if (!term) return
     setOptionIfSupported(term, "theme", colors)
     setOptionIfSupported(term, "theme", colors)
   })
   })

+ 2 - 0
packages/app/src/components/titlebar.tsx

@@ -157,6 +157,7 @@ export function Titlebar() {
     <header
     <header
       class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
       class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
       style={{ "min-height": minHeight() }}
       style={{ "min-height": minHeight() }}
+      data-tauri-drag-region
       onMouseDown={drag}
       onMouseDown={drag}
       onDblClick={maximize}
       onDblClick={maximize}
     >
     >
@@ -276,6 +277,7 @@ export function Titlebar() {
           "flex items-center min-w-0 justify-end": true,
           "flex items-center min-w-0 justify-end": true,
           "pr-2": !windows(),
           "pr-2": !windows(),
         }}
         }}
+        data-tauri-drag-region
         onMouseDown={drag}
         onMouseDown={drag}
       >
       >
         <div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
         <div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />

+ 58 - 38
packages/app/src/context/global-sync.tsx

@@ -11,7 +11,6 @@ import { showToast } from "@opencode-ai/ui/toast"
 import { getFilename } from "@opencode-ai/util/path"
 import { getFilename } from "@opencode-ai/util/path"
 import {
 import {
   createContext,
   createContext,
-  createEffect,
   getOwner,
   getOwner,
   Match,
   Match,
   onCleanup,
   onCleanup,
@@ -35,7 +34,6 @@ import { trimSessions } from "./global-sync/session-trim"
 import type { ProjectMeta } from "./global-sync/types"
 import type { ProjectMeta } from "./global-sync/types"
 import { SESSION_RECENT_LIMIT } from "./global-sync/types"
 import { SESSION_RECENT_LIMIT } from "./global-sync/types"
 import { sanitizeProject } from "./global-sync/utils"
 import { sanitizeProject } from "./global-sync/utils"
-import { usePlatform } from "./platform"
 import { formatServerError } from "@/utils/server-errors"
 import { formatServerError } from "@/utils/server-errors"
 
 
 type GlobalStore = {
 type GlobalStore = {
@@ -54,7 +52,6 @@ type GlobalStore = {
 
 
 function createGlobalSync() {
 function createGlobalSync() {
   const globalSDK = useGlobalSDK()
   const globalSDK = useGlobalSDK()
-  const platform = usePlatform()
   const language = useLanguage()
   const language = useLanguage()
   const owner = getOwner()
   const owner = getOwner()
   if (!owner) throw new Error("GlobalSync must be created within owner")
   if (!owner) throw new Error("GlobalSync must be created within owner")
@@ -64,7 +61,7 @@ function createGlobalSync() {
   const sessionLoads = new Map<string, Promise<void>>()
   const sessionLoads = new Map<string, Promise<void>>()
   const sessionMeta = new Map<string, { limit: number }>()
   const sessionMeta = new Map<string, { limit: number }>()
 
 
-  const [projectCache, setProjectCache, , projectCacheReady] = persisted(
+  const [projectCache, setProjectCache, projectInit] = persisted(
     Persist.global("globalSync.project", ["globalSync.project.v1"]),
     Persist.global("globalSync.project", ["globalSync.project.v1"]),
     createStore({ value: [] as Project[] }),
     createStore({ value: [] as Project[] }),
   )
   )
@@ -80,6 +77,57 @@ function createGlobalSync() {
     reload: undefined,
     reload: undefined,
   })
   })
 
 
+  let active = true
+  let projectWritten = false
+
+  onCleanup(() => {
+    active = false
+  })
+
+  const cacheProjects = () => {
+    setProjectCache(
+      "value",
+      untrack(() => globalStore.project.map(sanitizeProject)),
+    )
+  }
+
+  const setProjects = (next: Project[] | ((draft: Project[]) => void)) => {
+    projectWritten = true
+    if (typeof next === "function") {
+      setGlobalStore("project", produce(next))
+      cacheProjects()
+      return
+    }
+    setGlobalStore("project", next)
+    cacheProjects()
+  }
+
+  const setBootStore = ((...input: unknown[]) => {
+    if (input[0] === "project" && Array.isArray(input[1])) {
+      setProjects(input[1] as Project[])
+      return input[1]
+    }
+    return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
+  }) as typeof setGlobalStore
+
+  const set = ((...input: unknown[]) => {
+    if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
+      setProjects(input[1] as Project[] | ((draft: Project[]) => void))
+      return input[1]
+    }
+    return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
+  }) as typeof setGlobalStore
+
+  if (projectInit instanceof Promise) {
+    void projectInit.then(() => {
+      if (!active) return
+      if (projectWritten) return
+      const cached = projectCache.value
+      if (cached.length === 0) return
+      setGlobalStore("project", cached)
+    })
+  }
+
   const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
   const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
     if (!sessionID) return
     if (!sessionID) return
     if (!todos) {
     if (!todos) {
@@ -127,30 +175,6 @@ function createGlobalSync() {
     return sdk
     return sdk
   }
   }
 
 
-  createEffect(() => {
-    if (!projectCacheReady()) return
-    if (globalStore.project.length !== 0) return
-    const cached = projectCache.value
-    if (cached.length === 0) return
-    setGlobalStore("project", cached)
-  })
-
-  createEffect(() => {
-    if (!projectCacheReady()) return
-    const projects = globalStore.project
-    if (projects.length === 0) {
-      const cachedLength = untrack(() => projectCache.value.length)
-      if (cachedLength !== 0) return
-    }
-    setProjectCache("value", projects.map(sanitizeProject))
-  })
-
-  createEffect(() => {
-    if (globalStore.reload !== "complete") return
-    setGlobalStore("reload", undefined)
-    queue.refresh()
-  })
-
   async function loadSessions(directory: string) {
   async function loadSessions(directory: string) {
     const pending = sessionLoads.get(directory)
     const pending = sessionLoads.get(directory)
     if (pending) return pending
     if (pending) return pending
@@ -259,13 +283,7 @@ function createGlobalSync() {
         event,
         event,
         project: globalStore.project,
         project: globalStore.project,
         refresh: queue.refresh,
         refresh: queue.refresh,
-        setGlobalProject(next) {
-          if (typeof next === "function") {
-            setGlobalStore("project", produce(next))
-            return
-          }
-          setGlobalStore("project", next)
-        },
+        setGlobalProject: setProjects,
       })
       })
       if (event.type === "server.connected" || event.type === "global.disposed") {
       if (event.type === "server.connected" || event.type === "global.disposed") {
         for (const directory of Object.keys(children.children)) {
         for (const directory of Object.keys(children.children)) {
@@ -316,7 +334,7 @@ function createGlobalSync() {
       unknownError: language.t("error.chain.unknown"),
       unknownError: language.t("error.chain.unknown"),
       invalidConfigurationError: language.t("error.server.invalidConfiguration"),
       invalidConfigurationError: language.t("error.server.invalidConfiguration"),
       formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
       formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
-      setGlobalStore,
+      setGlobalStore: setBootStore,
     })
     })
   }
   }
 
 
@@ -340,7 +358,9 @@ function createGlobalSync() {
       .update({ config })
       .update({ config })
       .then(bootstrap)
       .then(bootstrap)
       .then(() => {
       .then(() => {
-        setGlobalStore("reload", "complete")
+        queue.refresh()
+        setGlobalStore("reload", undefined)
+        queue.refresh()
       })
       })
       .catch((error) => {
       .catch((error) => {
         setGlobalStore("reload", undefined)
         setGlobalStore("reload", undefined)
@@ -350,7 +370,7 @@ function createGlobalSync() {
 
 
   return {
   return {
     data: globalStore,
     data: globalStore,
-    set: setGlobalStore,
+    set,
     get ready() {
     get ready() {
       return globalStore.ready
       return globalStore.ready
     },
     },

+ 21 - 9
packages/app/src/context/global-sync/child-store.ts

@@ -1,4 +1,4 @@
-import { createRoot, createEffect, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
+import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
 import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
 import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
 import { Persist, persisted } from "@/utils/persist"
 import { Persist, persisted } from "@/utils/persist"
 import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
 import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
@@ -131,8 +131,7 @@ export function createChildStoreManager(input: {
       )
       )
       if (!vcs) throw new Error("Failed to create persisted cache")
       if (!vcs) throw new Error("Failed to create persisted cache")
       const vcsStore = vcs[0]
       const vcsStore = vcs[0]
-      const vcsReady = vcs[3]
-      vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
+      vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
 
 
       const meta = runWithOwner(input.owner, () =>
       const meta = runWithOwner(input.owner, () =>
         persisted(
         persisted(
@@ -154,10 +153,12 @@ export function createChildStoreManager(input: {
 
 
       const init = () =>
       const init = () =>
         createRoot((dispose) => {
         createRoot((dispose) => {
+          const initialMeta = meta[0].value
+          const initialIcon = icon[0].value
           const child = createStore<State>({
           const child = createStore<State>({
             project: "",
             project: "",
-            projectMeta: meta[0].value,
-            icon: icon[0].value,
+            projectMeta: initialMeta,
+            icon: initialIcon,
             provider: { all: [], connected: [], default: {} },
             provider: { all: [], connected: [], default: {} },
             config: {},
             config: {},
             path: { state: "", config: "", worktree: "", directory: "", home: "" },
             path: { state: "", config: "", worktree: "", directory: "", home: "" },
@@ -181,16 +182,27 @@ export function createChildStoreManager(input: {
           children[directory] = child
           children[directory] = child
           disposers.set(directory, dispose)
           disposers.set(directory, dispose)
 
 
-          createEffect(() => {
-            if (!vcsReady()) return
+          const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
+            if (!(init instanceof Promise)) return
+            void init.then(() => {
+              if (children[directory] !== child) return
+              run()
+            })
+          }
+
+          onPersistedInit(vcs[2], () => {
             const cached = vcsStore.value
             const cached = vcsStore.value
             if (!cached?.branch) return
             if (!cached?.branch) return
             child[1]("vcs", (value) => value ?? cached)
             child[1]("vcs", (value) => value ?? cached)
           })
           })
-          createEffect(() => {
+
+          onPersistedInit(meta[2], () => {
+            if (child[0].projectMeta !== initialMeta) return
             child[1]("projectMeta", meta[0].value)
             child[1]("projectMeta", meta[0].value)
           })
           })
-          createEffect(() => {
+
+          onPersistedInit(icon[2], () => {
+            if (child[0].icon !== initialIcon) return
             child[1]("icon", icon[0].value)
             child[1]("icon", icon[0].value)
           })
           })
         })
         })

+ 77 - 5
packages/app/src/context/layout.tsx

@@ -7,8 +7,10 @@ import { useServer } from "./server"
 import { usePlatform } from "./platform"
 import { usePlatform } from "./platform"
 import { Project } from "@opencode-ai/sdk/v2"
 import { Project } from "@opencode-ai/sdk/v2"
 import { Persist, persisted, removePersisted } from "@/utils/persist"
 import { Persist, persisted, removePersisted } from "@/utils/persist"
+import { decode64 } from "@/utils/base64"
 import { same } from "@/utils/same"
 import { same } from "@/utils/same"
 import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
 import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
+import { createPathHelpers } from "./file/path"
 
 
 const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
 const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
 const DEFAULT_PANEL_WIDTH = 344
 const DEFAULT_PANEL_WIDTH = 344
@@ -96,6 +98,38 @@ function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string):
   return { all, active: tab }
   return { all, active: tab }
 }
 }
 
 
+const sessionPath = (key: string) => {
+  const dir = key.split("/")[0]
+  if (!dir) return
+  const root = decode64(dir)
+  if (!root) return
+  return createPathHelpers(() => root)
+}
+
+const normalizeSessionTab = (path: ReturnType<typeof createPathHelpers> | undefined, tab: string) => {
+  if (!tab.startsWith("file://")) return tab
+  if (!path) return tab
+  return path.tab(tab)
+}
+
+const normalizeSessionTabList = (path: ReturnType<typeof createPathHelpers> | undefined, all: string[]) => {
+  const seen = new Set<string>()
+  return all.flatMap((tab) => {
+    const value = normalizeSessionTab(path, tab)
+    if (seen.has(value)) return []
+    seen.add(value)
+    return [value]
+  })
+}
+
+const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
+  const path = sessionPath(key)
+  return {
+    all: normalizeSessionTabList(path, tabs.all),
+    active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
+  }
+}
+
 export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
 export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
   name: "Layout",
   name: "Layout",
   init: () => {
   init: () => {
@@ -147,12 +181,46 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         }
         }
       })()
       })()
 
 
-      if (migratedSidebar === sidebar && migratedReview === review && migratedFileTree === fileTree) return value
+      const sessionTabs = value.sessionTabs
+      const migratedSessionTabs = (() => {
+        if (!isRecord(sessionTabs)) return sessionTabs
+
+        let changed = false
+        const next = Object.fromEntries(
+          Object.entries(sessionTabs).map(([key, tabs]) => {
+            if (!isRecord(tabs) || !Array.isArray(tabs.all)) return [key, tabs]
+
+            const current = {
+              all: tabs.all.filter((tab): tab is string => typeof tab === "string"),
+              active: typeof tabs.active === "string" ? tabs.active : undefined,
+            }
+            const normalized = normalizeStoredSessionTabs(key, current)
+            if (current.all.length !== tabs.all.length) changed = true
+            if (!same(current.all, normalized.all) || current.active !== normalized.active) changed = true
+            if (tabs.active !== undefined && typeof tabs.active !== "string") changed = true
+            return [key, normalized]
+          }),
+        )
+
+        if (!changed) return sessionTabs
+        return next
+      })()
+
+      if (
+        migratedSidebar === sidebar &&
+        migratedReview === review &&
+        migratedFileTree === fileTree &&
+        migratedSessionTabs === sessionTabs
+      ) {
+        return value
+      }
+
       return {
       return {
         ...value,
         ...value,
         sidebar: migratedSidebar,
         sidebar: migratedSidebar,
         review: migratedReview,
         review: migratedReview,
         fileTree: migratedFileTree,
         fileTree: migratedFileTree,
+        sessionTabs: migratedSessionTabs,
       }
       }
     }
     }
 
 
@@ -745,22 +813,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       },
       },
       tabs(sessionKey: string | Accessor<string>) {
       tabs(sessionKey: string | Accessor<string>) {
         const key = createSessionKeyReader(sessionKey, ensureKey)
         const key = createSessionKeyReader(sessionKey, ensureKey)
+        const path = createMemo(() => sessionPath(key()))
         const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
         const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
+        const normalize = (tab: string) => normalizeSessionTab(path(), tab)
+        const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
         return {
         return {
           tabs,
           tabs,
           active: createMemo(() => tabs().active),
           active: createMemo(() => tabs().active),
           all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
           all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
           setActive(tab: string | undefined) {
           setActive(tab: string | undefined) {
             const session = key()
             const session = key()
+            const next = tab ? normalize(tab) : tab
             if (!store.sessionTabs[session]) {
             if (!store.sessionTabs[session]) {
-              setStore("sessionTabs", session, { all: [], active: tab })
+              setStore("sessionTabs", session, { all: [], active: next })
             } else {
             } else {
-              setStore("sessionTabs", session, "active", tab)
+              setStore("sessionTabs", session, "active", next)
             }
             }
           },
           },
           setAll(all: string[]) {
           setAll(all: string[]) {
             const session = key()
             const session = key()
-            const next = all.filter((tab) => tab !== "review")
+            const next = normalizeAll(all).filter((tab) => tab !== "review")
             if (!store.sessionTabs[session]) {
             if (!store.sessionTabs[session]) {
               setStore("sessionTabs", session, { all: next, active: undefined })
               setStore("sessionTabs", session, { all: next, active: undefined })
             } else {
             } else {
@@ -769,7 +841,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           },
           },
           async open(tab: string) {
           async open(tab: string) {
             const session = key()
             const session = key()
-            const next = nextSessionTabsForOpen(store.sessionTabs[session], tab)
+            const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
             setStore("sessionTabs", session, next)
             setStore("sessionTabs", session, next)
           },
           },
           close(tab: string) {
           close(tab: string) {

+ 2 - 2
packages/app/src/context/permission-auto-respond.test.ts

@@ -31,13 +31,13 @@ describe("autoRespondsPermission", () => {
     expect(autoRespondsPermission({ root: true }, sessions, permission("child"), "/tmp/project")).toBe(true)
     expect(autoRespondsPermission({ root: true }, sessions, permission("child"), "/tmp/project")).toBe(true)
   })
   })
 
 
-  test("defaults to auto-accept when no lineage override exists", () => {
+  test("defaults to requiring approval when no lineage override exists", () => {
     const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })]
     const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })]
     const autoAccept = {
     const autoAccept = {
       other: true,
       other: true,
     }
     }
 
 
-    expect(autoRespondsPermission(autoAccept, sessions, permission("child"), "/tmp/project")).toBe(true)
+    expect(autoRespondsPermission(autoAccept, sessions, permission("child"), "/tmp/project")).toBe(false)
   })
   })
 
 
   test("inherits a parent session's false override", () => {
   test("inherits a parent session's false override", () => {

+ 1 - 1
packages/app/src/context/permission-auto-respond.ts

@@ -37,5 +37,5 @@ export function autoRespondsPermission(
   const value = sessionLineage(session, permission.sessionID)
   const value = sessionLineage(session, permission.sessionID)
     .map((id) => accepted(autoAccept, id, directory))
     .map((id) => accepted(autoAccept, id, directory))
     .find((item): item is boolean => item !== undefined)
     .find((item): item is boolean => item !== undefined)
-  return value ?? true
+  return value ?? false
 }
 }

+ 20 - 39
packages/app/src/context/sync.tsx

@@ -43,12 +43,11 @@ type OptimisticRemoveInput = {
 
 
 export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
 export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
   const messages = draft.message[input.sessionID]
   const messages = draft.message[input.sessionID]
-  if (!messages) {
-    draft.message[input.sessionID] = [input.message]
-  }
   if (messages) {
   if (messages) {
     const result = Binary.search(messages, input.message.id, (m) => m.id)
     const result = Binary.search(messages, input.message.id, (m) => m.id)
     messages.splice(result.index, 0, input.message)
     messages.splice(result.index, 0, input.message)
+  } else {
+    draft.message[input.sessionID] = [input.message]
   }
   }
   draft.part[input.message.id] = sortParts(input.parts)
   draft.part[input.message.id] = sortParts(input.parts)
 }
 }
@@ -105,7 +104,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       return globalSync.child(directory)
       return globalSync.child(directory)
     }
     }
     const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
     const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
-    const messagePageSize = 400
+    const messagePageSize = 200
     const inflight = new Map<string, Promise<void>>()
     const inflight = new Map<string, Promise<void>>()
     const inflightDiff = new Map<string, Promise<void>>()
     const inflightDiff = new Map<string, Promise<void>>()
     const inflightTodo = new Map<string, Promise<void>>()
     const inflightTodo = new Map<string, Promise<void>>()
@@ -122,20 +121,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       return undefined
       return undefined
     }
     }
 
 
-    const limitFor = (count: number) => {
-      if (count <= messagePageSize) return messagePageSize
-      return Math.ceil(count / messagePageSize) * messagePageSize
-    }
-
     const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
     const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
       const messages = await retry(() =>
       const messages = await retry(() =>
         input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
         input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
       )
       )
       const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
       const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
-      const session = items
-        .map((x) => x.info)
-        .filter((m) => !!m?.id)
-        .sort((a, b) => cmp(a.id, b.id))
+      const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
       const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
       const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
       return {
       return {
         session,
         session,
@@ -159,8 +150,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         .then((next) => {
         .then((next) => {
           batch(() => {
           batch(() => {
             input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
             input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
-            for (const message of next.part) {
-              input.setStore("part", message.id, reconcile(message.part, { key: "id" }))
+            for (const p of next.part) {
+              input.setStore("part", p.id, p.part)
             }
             }
             setMeta("limit", key, input.limit)
             setMeta("limit", key, input.limit)
             setMeta("complete", key, next.complete)
             setMeta("complete", key, next.complete)
@@ -229,17 +220,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           const client = sdk.client
           const client = sdk.client
           const [store, setStore] = globalSync.child(directory)
           const [store, setStore] = globalSync.child(directory)
           const key = keyFor(directory, sessionID)
           const key = keyFor(directory, sessionID)
-          const hasSession = (() => {
-            const match = Binary.search(store.session, sessionID, (s) => s.id)
-            return match.found
-          })()
+          const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
 
 
-          const hasMessages = store.message[sessionID] !== undefined
-          const hydrated = meta.limit[key] !== undefined
-          if (hasSession && hasMessages && hydrated) return
-
-          const count = store.message[sessionID]?.length ?? 0
-          const limit = hydrated ? (meta.limit[key] ?? messagePageSize) : limitFor(count)
+          const limit = meta.limit[key] ?? messagePageSize
 
 
           const sessionReq = hasSession
           const sessionReq = hasSession
             ? Promise.resolve()
             ? Promise.resolve()
@@ -259,16 +242,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
                 )
                 )
               })
               })
 
 
-          const messagesReq =
-            hasMessages && hydrated
-              ? Promise.resolve()
-              : loadMessages({
-                  directory,
-                  client,
-                  setStore,
-                  sessionID,
-                  limit,
-                })
+          const messagesReq = loadMessages({
+            directory,
+            client,
+            setStore,
+            sessionID,
+            limit,
+          })
 
 
           return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
           return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
         },
         },
@@ -290,14 +270,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           const client = sdk.client
           const client = sdk.client
           const [store, setStore] = globalSync.child(directory)
           const [store, setStore] = globalSync.child(directory)
           const existing = store.todo[sessionID]
           const existing = store.todo[sessionID]
+          const cached = globalSync.data.session_todo[sessionID]
           if (existing !== undefined) {
           if (existing !== undefined) {
-            if (globalSync.data.session_todo[sessionID] === undefined) {
+            if (cached === undefined) {
               globalSync.todo.set(sessionID, existing)
               globalSync.todo.set(sessionID, existing)
             }
             }
             return
             return
           }
           }
 
 
-          const cached = globalSync.data.session_todo[sessionID]
           if (cached !== undefined) {
           if (cached !== undefined) {
             setStore("todo", sessionID, reconcile(cached, { key: "id" }))
             setStore("todo", sessionID, reconcile(cached, { key: "id" }))
           }
           }
@@ -324,11 +304,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             const key = keyFor(sdk.directory, sessionID)
             const key = keyFor(sdk.directory, sessionID)
             return meta.loading[key] ?? false
             return meta.loading[key] ?? false
           },
           },
-          async loadMore(sessionID: string, count = messagePageSize) {
+          async loadMore(sessionID: string, count?: number) {
             const directory = sdk.directory
             const directory = sdk.directory
             const client = sdk.client
             const client = sdk.client
             const [, setStore] = globalSync.child(directory)
             const [, setStore] = globalSync.child(directory)
             const key = keyFor(directory, sessionID)
             const key = keyFor(directory, sessionID)
+            const step = count ?? messagePageSize
             if (meta.loading[key]) return
             if (meta.loading[key]) return
             if (meta.complete[key]) return
             if (meta.complete[key]) return
 
 
@@ -338,7 +319,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
               client,
               client,
               setStore,
               setStore,
               sessionID,
               sessionID,
-              limit: currentLimit + count,
+              limit: currentLimit + step,
             })
             })
           },
           },
         },
         },

+ 206 - 134
packages/app/src/pages/layout.tsx

@@ -42,6 +42,7 @@ import { Binary } from "@opencode-ai/util/binary"
 import { retry } from "@opencode-ai/util/retry"
 import { retry } from "@opencode-ai/util/retry"
 import { playSound, soundSrc } from "@/utils/sound"
 import { playSound, soundSrc } from "@/utils/sound"
 import { createAim } from "@/utils/aim"
 import { createAim } from "@/utils/aim"
+import { setNavigate } from "@/utils/notification-click"
 import { Worktree as WorktreeState } from "@/utils/worktree"
 import { Worktree as WorktreeState } from "@/utils/worktree"
 
 
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -59,11 +60,11 @@ import { useLanguage, type Locale } from "@/context/language"
 import {
 import {
   childMapByParent,
   childMapByParent,
   displayName,
   displayName,
+  effectiveWorkspaceOrder,
   errorMessage,
   errorMessage,
   getDraggableId,
   getDraggableId,
   latestRootSession,
   latestRootSession,
   sortedRootSessions,
   sortedRootSessions,
-  syncWorkspaceOrder,
   workspaceKey,
   workspaceKey,
 } from "./layout/helpers"
 } from "./layout/helpers"
 import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links"
 import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links"
@@ -107,6 +108,7 @@ export default function Layout(props: ParentProps) {
   const notification = useNotification()
   const notification = useNotification()
   const permission = usePermission()
   const permission = usePermission()
   const navigate = useNavigate()
   const navigate = useNavigate()
+  setNavigate(navigate)
   const providers = useProviders()
   const providers = useProviders()
   const dialog = useDialog()
   const dialog = useDialog()
   const command = useCommand()
   const command = useCommand()
@@ -481,21 +483,6 @@ export default function Layout(props: ParentProps) {
     return projects.find((p) => p.worktree === root)
     return projects.find((p) => p.worktree === root)
   })
   })
 
 
-  createEffect(
-    on(
-      () => ({ ready: pageReady(), project: currentProject() }),
-      (value) => {
-        if (!value.ready) return
-        const project = value.project
-        if (!project) return
-        const last = server.projects.last()
-        if (last === project.worktree) return
-        server.projects.touch(project.worktree)
-      },
-      { defer: true },
-    ),
-  )
-
   createEffect(
   createEffect(
     on(
     on(
       () => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
       () => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
@@ -554,29 +541,17 @@ export default function Layout(props: ParentProps) {
     return layout.sidebar.workspaces(project.worktree)()
     return layout.sidebar.workspaces(project.worktree)()
   })
   })
 
 
-  createEffect(() => {
-    if (!pageReady()) return
-    if (!layoutReady()) return
+  const visibleSessionDirs = createMemo(() => {
     const project = currentProject()
     const project = currentProject()
-    if (!project) return
-
-    const local = project.worktree
-    const dirs = [project.worktree, ...(project.sandboxes ?? [])]
-    const existing = store.workspaceOrder[project.worktree]
-    const merged = syncWorkspaceOrder(local, dirs, existing)
-    if (!existing) {
-      setStore("workspaceOrder", project.worktree, merged)
-      return
-    }
-
-    if (merged.length !== existing.length) {
-      setStore("workspaceOrder", project.worktree, merged)
-      return
-    }
-
-    if (merged.some((d, i) => d !== existing[i])) {
-      setStore("workspaceOrder", project.worktree, merged)
-    }
+    if (!project) return [] as string[]
+    if (!workspaceSetting()) return [project.worktree]
+
+    const activeDir = currentDir()
+    return workspaceIds(project).filter((directory) => {
+      const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
+      const active = directory === activeDir
+      return expanded || active
+    })
   })
   })
 
 
   createEffect(() => {
   createEffect(() => {
@@ -593,25 +568,17 @@ export default function Layout(props: ParentProps) {
   })
   })
 
 
   const currentSessions = createMemo(() => {
   const currentSessions = createMemo(() => {
-    const project = currentProject()
-    if (!project) return [] as Session[]
     const now = Date.now()
     const now = Date.now()
-    if (workspaceSetting()) {
-      const dirs = workspaceIds(project)
-      const activeDir = currentDir()
-      const result: Session[] = []
-      for (const dir of dirs) {
-        const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree
-        const active = dir === activeDir
-        if (!expanded && !active) continue
-        const [dirStore] = globalSync.child(dir, { bootstrap: true })
-        const dirSessions = sortedRootSessions(dirStore, now)
-        result.push(...dirSessions)
-      }
-      return result
+    const dirs = visibleSessionDirs()
+    if (dirs.length === 0) return [] as Session[]
+
+    const result: Session[] = []
+    for (const dir of dirs) {
+      const [dirStore] = globalSync.child(dir, { bootstrap: true })
+      const dirSessions = sortedRootSessions(dirStore, now)
+      result.push(...dirSessions)
     }
     }
-    const [projectStore] = globalSync.child(project.worktree)
-    return sortedRootSessions(projectStore, now)
+    return result
   })
   })
 
 
   type PrefetchQueue = {
   type PrefetchQueue = {
@@ -826,7 +793,6 @@ export default function Layout(props: ParentProps) {
     }
     }
 
 
     navigateToSession(session)
     navigateToSession(session)
-    queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
   }
   }
 
 
   function navigateSessionByUnseen(offset: number) {
   function navigateSessionByUnseen(offset: number) {
@@ -861,7 +827,6 @@ export default function Layout(props: ParentProps) {
       }
       }
 
 
       navigateToSession(session)
       navigateToSession(session)
-      queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
       return
       return
     }
     }
   }
   }
@@ -1094,34 +1059,90 @@ export default function Layout(props: ParentProps) {
     return meta?.worktree ?? directory
     return meta?.worktree ?? directory
   }
   }
 
 
+  function activeProjectRoot(directory: string) {
+    return currentProject()?.worktree ?? projectRoot(directory)
+  }
+
+  function touchProjectRoute() {
+    const root = currentProject()?.worktree
+    if (!root) return
+    if (server.projects.last() !== root) server.projects.touch(root)
+    return root
+  }
+
+  function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
+    setStore("lastProjectSession", root, { directory, id, at: Date.now() })
+    return root
+  }
+
+  function clearLastProjectSession(root: string) {
+    if (!store.lastProjectSession[root]) return
+    setStore(
+      "lastProjectSession",
+      produce((draft) => {
+        delete draft[root]
+      }),
+    )
+  }
+
+  function syncSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
+    rememberSessionRoute(directory, id, root)
+    notification.session.markViewed(id)
+    const expanded = untrack(() => store.workspaceExpanded[directory])
+    if (expanded === false) {
+      setStore("workspaceExpanded", directory, true)
+    }
+    requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
+    return root
+  }
+
   async function navigateToProject(directory: string | undefined) {
   async function navigateToProject(directory: string | undefined) {
     if (!directory) return
     if (!directory) return
     const root = projectRoot(directory)
     const root = projectRoot(directory)
     server.projects.touch(root)
     server.projects.touch(root)
     const project = layout.projects.list().find((item) => item.worktree === root)
     const project = layout.projects.list().find((item) => item.worktree === root)
-    const dirs = Array.from(new Set([root, ...(store.workspaceOrder[root] ?? []), ...(project?.sandboxes ?? [])]))
+    let dirs = project
+      ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
+      : [root]
+    const canOpen = (value: string | undefined) => {
+      if (!value) return false
+      return dirs.some((item) => workspaceKey(item) === workspaceKey(value))
+    }
+    const refreshDirs = async (target?: string) => {
+      if (!target || target === root || canOpen(target)) return canOpen(target)
+      const listed = await globalSDK.client.worktree
+        .list({ directory: root })
+        .then((x) => x.data ?? [])
+        .catch(() => [] as string[])
+      dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
+      return canOpen(target)
+    }
     const openSession = async (target: { directory: string; id: string }) => {
     const openSession = async (target: { directory: string; id: string }) => {
+      if (!canOpen(target.directory)) return false
       const resolved = await globalSDK.client.session
       const resolved = await globalSDK.client.session
         .get({ sessionID: target.id })
         .get({ sessionID: target.id })
         .then((x) => x.data)
         .then((x) => x.data)
         .catch(() => undefined)
         .catch(() => undefined)
-      const next = resolved?.directory ? resolved : target
-      setStore("lastProjectSession", root, { directory: next.directory, id: next.id, at: Date.now() })
-      navigateWithSidebarReset(`/${base64Encode(next.directory)}/session/${next.id}`)
+      if (!resolved?.directory) return false
+      if (!canOpen(resolved.directory)) return false
+      setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() })
+      navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`)
+      return true
     }
     }
 
 
     const projectSession = store.lastProjectSession[root]
     const projectSession = store.lastProjectSession[root]
     if (projectSession?.id) {
     if (projectSession?.id) {
-      await openSession(projectSession)
-      return
+      await refreshDirs(projectSession.directory)
+      const opened = await openSession(projectSession)
+      if (opened) return
+      clearLastProjectSession(root)
     }
     }
 
 
     const latest = latestRootSession(
     const latest = latestRootSession(
       dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
       dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
       Date.now(),
       Date.now(),
     )
     )
-    if (latest) {
-      await openSession(latest)
+    if (latest && (await openSession(latest))) {
       return
       return
     }
     }
 
 
@@ -1137,8 +1158,7 @@ export default function Layout(props: ParentProps) {
       ),
       ),
       Date.now(),
       Date.now(),
     )
     )
-    if (fetched) {
-      await openSession(fetched)
+    if (fetched && (await openSession(fetched))) {
       return
       return
     }
     }
 
 
@@ -1195,11 +1215,28 @@ export default function Layout(props: ParentProps) {
   }
   }
 
 
   function closeProject(directory: string) {
   function closeProject(directory: string) {
-    const index = layout.projects.list().findIndex((x) => x.worktree === directory)
-    const next = layout.projects.list()[index + 1]
+    const list = layout.projects.list()
+    const index = list.findIndex((x) => x.worktree === directory)
+    const active = currentProject()?.worktree === directory
+    if (index === -1) return
+    const next = list[index + 1]
+
+    if (!active) {
+      layout.projects.close(directory)
+      return
+    }
+
+    if (!next) {
+      layout.projects.close(directory)
+      navigate("/")
+      return
+    }
+
+    navigateWithSidebarReset(`/${base64Encode(next.worktree)}/session`)
     layout.projects.close(directory)
     layout.projects.close(directory)
-    if (next) navigateToProject(next.worktree)
-    else navigate("/")
+    queueMicrotask(() => {
+      void navigateToProject(next.worktree)
+    })
   }
   }
 
 
   function toggleProjectWorkspaces(project: LocalProject) {
   function toggleProjectWorkspaces(project: LocalProject) {
@@ -1240,9 +1277,17 @@ export default function Layout(props: ParentProps) {
     }
     }
   }
   }
 
 
-  const deleteWorkspace = async (root: string, directory: string) => {
+  const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
     if (directory === root) return
     if (directory === root) return
 
 
+    const current = currentDir()
+    const currentKey = workspaceKey(current)
+    const deletedKey = workspaceKey(directory)
+    const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey)
+    if (!leaveDeletedWorkspace && shouldLeave) {
+      navigateWithSidebarReset(`/${base64Encode(root)}/session`)
+    }
+
     setBusy(directory, true)
     setBusy(directory, true)
 
 
     const result = await globalSDK.client.worktree
     const result = await globalSDK.client.worktree
@@ -1260,6 +1305,10 @@ export default function Layout(props: ParentProps) {
 
 
     if (!result) return
     if (!result) return
 
 
+    if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) {
+      clearLastProjectSession(root)
+    }
+
     globalSync.set(
     globalSync.set(
       "project",
       "project",
       produce((draft) => {
       produce((draft) => {
@@ -1273,8 +1322,18 @@ export default function Layout(props: ParentProps) {
     layout.projects.close(directory)
     layout.projects.close(directory)
     layout.projects.open(root)
     layout.projects.open(root)
 
 
-    if (params.dir && currentDir() === directory) {
-      navigateToProject(root)
+    if (shouldLeave) return
+
+    const nextCurrent = currentDir()
+    const nextKey = workspaceKey(nextCurrent)
+    const project = layout.projects.list().find((item) => item.worktree === root)
+    const dirs = project
+      ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
+      : [root]
+    const valid = dirs.some((item) => workspaceKey(item) === nextKey)
+
+    if (params.dir && projectRoot(nextCurrent) === root && !valid) {
+      navigateWithSidebarReset(`/${base64Encode(root)}/session`)
     }
     }
   }
   }
 
 
@@ -1377,8 +1436,12 @@ export default function Layout(props: ParentProps) {
     })
     })
 
 
     const handleDelete = () => {
     const handleDelete = () => {
+      const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory)
+      if (leaveDeletedWorkspace) {
+        navigateWithSidebarReset(`/${base64Encode(props.root)}/session`)
+      }
       dialog.close()
       dialog.close()
-      void deleteWorkspace(props.root, props.directory)
+      void deleteWorkspace(props.root, props.directory, leaveDeletedWorkspace)
     }
     }
 
 
     const description = () => {
     const description = () => {
@@ -1486,26 +1549,42 @@ export default function Layout(props: ParentProps) {
     )
     )
   }
   }
 
 
+  const activeRoute = {
+    session: "",
+    sessionProject: "",
+  }
+
   createEffect(
   createEffect(
     on(
     on(
-      () => ({ ready: pageReady(), dir: params.dir, id: params.id }),
-      (value) => {
-        if (!value.ready) return
-        const dir = value.dir
-        const id = value.id
-        if (!dir || !id) return
+      () => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const,
+      ([ready, dir, id]) => {
+        if (!ready || !dir) {
+          activeRoute.session = ""
+          activeRoute.sessionProject = ""
+          return
+        }
+
         const directory = decode64(dir)
         const directory = decode64(dir)
         if (!directory) return
         if (!directory) return
-        const at = Date.now()
-        setStore("lastProjectSession", projectRoot(directory), { directory, id, at })
-        notification.session.markViewed(id)
-        const expanded = untrack(() => store.workspaceExpanded[directory])
-        if (expanded === false) {
-          setStore("workspaceExpanded", directory, true)
+
+        const root = touchProjectRoute() ?? activeProjectRoot(directory)
+
+        if (!id) {
+          activeRoute.session = ""
+          activeRoute.sessionProject = ""
+          return
         }
         }
-        requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
+
+        const session = `${dir}/${id}`
+        if (session !== activeRoute.session) {
+          activeRoute.session = session
+          activeRoute.sessionProject = syncSessionRoute(directory, id, root)
+          return
+        }
+
+        if (root === activeRoute.sessionProject) return
+        activeRoute.sessionProject = rememberSessionRoute(directory, id, root)
       },
       },
-      { defer: true },
     ),
     ),
   )
   )
 
 
@@ -1516,40 +1595,29 @@ export default function Layout(props: ParentProps) {
 
 
   const loadedSessionDirs = new Set<string>()
   const loadedSessionDirs = new Set<string>()
 
 
-  createEffect(() => {
-    const project = currentProject()
-    const workspaces = workspaceSetting()
-    const next = new Set<string>()
-    if (!project) {
-      loadedSessionDirs.clear()
-      return
-    }
-
-    if (workspaces) {
-      const activeDir = currentDir()
-      const dirs = [project.worktree, ...(project.sandboxes ?? [])]
-      for (const directory of dirs) {
-        const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
-        const active = directory === activeDir
-        if (!expanded && !active) continue
-        next.add(directory)
-      }
-    }
-
-    if (!workspaces) {
-      next.add(project.worktree)
-    }
+  createEffect(
+    on(
+      visibleSessionDirs,
+      (dirs) => {
+        if (dirs.length === 0) {
+          loadedSessionDirs.clear()
+          return
+        }
 
 
-    for (const directory of next) {
-      if (loadedSessionDirs.has(directory)) continue
-      globalSync.project.loadSessions(directory)
-    }
+        const next = new Set(dirs)
+        for (const directory of next) {
+          if (loadedSessionDirs.has(directory)) continue
+          globalSync.project.loadSessions(directory)
+        }
 
 
-    loadedSessionDirs.clear()
-    for (const directory of next) {
-      loadedSessionDirs.add(directory)
-    }
-  })
+        loadedSessionDirs.clear()
+        for (const directory of next) {
+          loadedSessionDirs.add(directory)
+        }
+      },
+      { defer: true },
+    ),
+  )
 
 
   function handleDragStart(event: unknown) {
   function handleDragStart(event: unknown) {
     const id = getDraggableId(event)
     const id = getDraggableId(event)
@@ -1583,14 +1651,11 @@ export default function Layout(props: ParentProps) {
     const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
     const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
     const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
     const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
 
 
-    const existing = store.workspaceOrder[project.worktree]
-    if (!existing) return extra ? [...dirs, extra] : dirs
-
-    const merged = syncWorkspaceOrder(local, dirs, existing)
-    if (pending && extra) return [local, extra, ...merged.filter((directory) => directory !== local)]
-    if (!extra) return merged
-    if (pending) return merged
-    return [...merged, extra]
+    const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
+    if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
+    if (!extra) return ordered
+    if (pending) return ordered
+    return [...ordered, extra]
   }
   }
 
 
   const sidebarProject = createMemo(() => {
   const sidebarProject = createMemo(() => {
@@ -1623,7 +1688,11 @@ export default function Layout(props: ParentProps) {
     const [item] = result.splice(fromIndex, 1)
     const [item] = result.splice(fromIndex, 1)
     if (!item) return
     if (!item) return
     result.splice(toIndex, 0, item)
     result.splice(toIndex, 0, item)
-    setStore("workspaceOrder", project.worktree, result)
+    setStore(
+      "workspaceOrder",
+      project.worktree,
+      result.filter((directory) => workspaceKey(directory) !== workspaceKey(project.worktree)),
+    )
   }
   }
 
 
   function handleWorkspaceDragEnd() {
   function handleWorkspaceDragEnd() {
@@ -1661,10 +1730,9 @@ export default function Layout(props: ParentProps) {
       const existing = prev ?? []
       const existing = prev ?? []
       const next = existing.filter((item) => {
       const next = existing.filter((item) => {
         const id = workspaceKey(item)
         const id = workspaceKey(item)
-        if (id === root) return false
-        return id !== key
+        return id !== root && id !== key
       })
       })
-      return [local, created.directory, ...next]
+      return [created.directory, ...next]
     })
     })
 
 
     globalSync.child(created.directory)
     globalSync.child(created.directory)
@@ -2015,7 +2083,11 @@ export default function Layout(props: ParentProps) {
               onOpenSettings={openSettings}
               onOpenSettings={openSettings}
               helpLabel={() => language.t("sidebar.help")}
               helpLabel={() => language.t("sidebar.help")}
               onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
               onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
-              renderPanel={() => <SidebarPanel project={currentProject()} />}
+              renderPanel={() => (
+                <Show when={currentProject()} keyed>
+                  {(project) => <SidebarPanel project={project} />}
+                </Show>
+              )}
             />
             />
           </div>
           </div>
           <Show when={!layout.sidebar.opened() ? hoverProjectData()?.worktree : undefined} keyed>
           <Show when={!layout.sidebar.opened() ? hoverProjectData()?.worktree : undefined} keyed>

+ 25 - 5
packages/app/src/pages/layout/helpers.ts

@@ -74,9 +74,29 @@ export const errorMessage = (err: unknown, fallback: string) => {
   return fallback
   return fallback
 }
 }
 
 
-export const syncWorkspaceOrder = (local: string, dirs: string[], existing?: string[]) => {
-  if (!existing) return dirs
-  const keep = existing.filter((d) => d !== local && dirs.includes(d))
-  const missing = dirs.filter((d) => d !== local && !existing.includes(d))
-  return [local, ...missing, ...keep]
+export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted?: string[]) => {
+  const root = workspaceKey(local)
+  const live = new Map<string, string>()
+
+  for (const dir of dirs) {
+    const key = workspaceKey(dir)
+    if (key === root) continue
+    if (!live.has(key)) live.set(key, dir)
+  }
+
+  if (!persisted?.length) return [local, ...live.values()]
+
+  const result = [local]
+  for (const dir of persisted) {
+    const key = workspaceKey(dir)
+    if (key === root) continue
+    const match = live.get(key)
+    if (!match) continue
+    result.push(match)
+    live.delete(key)
+  }
+
+  return [...result, ...live.values()]
 }
 }
+
+export const syncWorkspaceOrder = effectiveWorkspaceOrder

+ 324 - 197
packages/app/src/pages/session.tsx

@@ -1,4 +1,16 @@
-import { onCleanup, Show, Match, Switch, createMemo, createEffect, on, onMount } from "solid-js"
+import {
+  onCleanup,
+  Show,
+  Match,
+  Switch,
+  createMemo,
+  createEffect,
+  createComputed,
+  on,
+  onMount,
+  untrack,
+  createSignal,
+} from "solid-js"
 import { createMediaQuery } from "@solid-primitives/media"
 import { createMediaQuery } from "@solid-primitives/media"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { useLocal } from "@/context/local"
 import { useLocal } from "@/context/local"
@@ -32,6 +44,215 @@ import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
 import { SessionSidePanel } from "@/pages/session/session-side-panel"
 import { SessionSidePanel } from "@/pages/session/session-side-panel"
 import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
 import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
 
 
+const emptyUserMessages: UserMessage[] = []
+
+type SessionHistoryWindowInput = {
+  sessionID: () => string | undefined
+  messagesReady: () => boolean
+  visibleUserMessages: () => UserMessage[]
+  historyMore: () => boolean
+  historyLoading: () => boolean
+  loadMore: (sessionID: string) => Promise<void>
+  userScrolled: () => boolean
+  scroller: () => HTMLDivElement | undefined
+}
+
+/**
+ * Maintains the rendered history window for a session timeline.
+ *
+ * It keeps initial paint bounded to recent turns, reveals cached turns in
+ * small batches while scrolling upward, and prefetches older history near top.
+ */
+function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
+  const turnInit = 10
+  const turnBatch = 8
+  const turnScrollThreshold = 200
+  const turnPrefetchBuffer = 16
+  const prefetchCooldownMs = 400
+  const prefetchNoGrowthLimit = 2
+
+  const [state, setState] = createStore({
+    turnID: undefined as string | undefined,
+    turnStart: 0,
+    prefetchUntil: 0,
+    prefetchNoGrowth: 0,
+  })
+
+  const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
+
+  const turnStart = createMemo(() => {
+    const id = input.sessionID()
+    const len = input.visibleUserMessages().length
+    if (!id || len <= 0) return 0
+    if (state.turnID !== id) return initialTurnStart(len)
+    if (state.turnStart <= 0) return 0
+    if (state.turnStart >= len) return initialTurnStart(len)
+    return state.turnStart
+  })
+
+  const setTurnStart = (start: number) => {
+    const id = input.sessionID()
+    const next = start > 0 ? start : 0
+    if (!id) {
+      setState({ turnID: undefined, turnStart: next })
+      return
+    }
+    setState({ turnID: id, turnStart: next })
+  }
+
+  const renderedUserMessages = createMemo(
+    () => {
+      const msgs = input.visibleUserMessages()
+      const start = turnStart()
+      if (start <= 0) return msgs
+      return msgs.slice(start)
+    },
+    emptyUserMessages,
+    {
+      equals: same,
+    },
+  )
+
+  const preserveScroll = (fn: () => void) => {
+    const el = input.scroller()
+    if (!el) {
+      fn()
+      return
+    }
+    const beforeTop = el.scrollTop
+    const beforeHeight = el.scrollHeight
+    fn()
+    requestAnimationFrame(() => {
+      const delta = el.scrollHeight - beforeHeight
+      if (!delta) return
+      el.scrollTop = beforeTop + delta
+    })
+  }
+
+  const backfillTurns = () => {
+    const start = turnStart()
+    if (start <= 0) return
+
+    const next = start - turnBatch
+    const nextStart = next > 0 ? next : 0
+
+    preserveScroll(() => setTurnStart(nextStart))
+  }
+
+  /** Button path: reveal all cached turns, fetch older history, reveal one batch. */
+  const loadAndReveal = async () => {
+    const id = input.sessionID()
+    if (!id) return
+
+    const start = turnStart()
+    const beforeVisible = input.visibleUserMessages().length
+
+    if (start > 0) setTurnStart(0)
+
+    if (!input.historyMore() || input.historyLoading()) return
+
+    await input.loadMore(id)
+    if (input.sessionID() !== id) return
+
+    const afterVisible = input.visibleUserMessages().length
+    const growth = afterVisible - beforeVisible
+    if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
+    if (growth <= 0) return
+    if (turnStart() !== 0) return
+
+    const target = Math.min(afterVisible, Math.max(beforeVisible, renderedUserMessages().length) + turnBatch)
+    const nextStart = Math.max(0, afterVisible - target)
+    preserveScroll(() => setTurnStart(nextStart))
+  }
+
+  /** Scroll/prefetch path: fetch older history from server. */
+  const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
+    const id = input.sessionID()
+    if (!id) return
+    if (!input.historyMore() || input.historyLoading()) return
+
+    if (opts?.prefetch) {
+      const now = Date.now()
+      if (state.prefetchUntil > now) return
+      if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
+      setState("prefetchUntil", now + prefetchCooldownMs)
+    }
+
+    const start = turnStart()
+    const beforeVisible = input.visibleUserMessages().length
+    const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
+
+    await input.loadMore(id)
+    if (input.sessionID() !== id) return
+
+    const afterVisible = input.visibleUserMessages().length
+    const growth = afterVisible - beforeVisible
+
+    if (opts?.prefetch) {
+      setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
+    } else if (growth > 0 && state.prefetchNoGrowth) {
+      setState("prefetchNoGrowth", 0)
+    }
+
+    if (growth <= 0) return
+    if (turnStart() !== start) return
+
+    const reveal = !opts?.prefetch
+    const currentRendered = renderedUserMessages().length
+    const base = Math.max(beforeRendered, currentRendered)
+    const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
+    const nextStart = Math.max(0, afterVisible - target)
+    preserveScroll(() => setTurnStart(nextStart))
+  }
+
+  const onScrollerScroll = () => {
+    if (!input.userScrolled()) return
+    const el = input.scroller()
+    if (!el) return
+    if (el.scrollTop >= turnScrollThreshold) return
+
+    const start = turnStart()
+    if (start > 0) {
+      if (start <= turnPrefetchBuffer) {
+        void fetchOlderMessages({ prefetch: true })
+      }
+      backfillTurns()
+      return
+    }
+
+    void fetchOlderMessages()
+  }
+
+  createEffect(
+    on(
+      input.sessionID,
+      () => {
+        setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
+      },
+      { defer: true },
+    ),
+  )
+
+  createEffect(
+    on(
+      () => [input.sessionID(), input.messagesReady()] as const,
+      ([id, ready]) => {
+        if (!id || !ready) return
+        setTurnStart(initialTurnStart(input.visibleUserMessages().length))
+      },
+      { defer: true },
+    ),
+  )
+
+  return {
+    turnStart,
+    setTurnStart,
+    renderedUserMessages,
+    loadAndReveal,
+    onScrollerScroll,
+  }
+}
+
 export default function Page() {
 export default function Page() {
   const layout = useLayout()
   const layout = useLayout()
   const local = useLocal()
   const local = useLocal()
@@ -138,24 +359,6 @@ export default function Page() {
     if (path) file.load(path)
     if (path) file.load(path)
   })
   })
 
 
-  createEffect(() => {
-    const current = tabs().all()
-    if (current.length === 0) return
-
-    const next = normalizeTabs(current)
-    if (same(current, next)) return
-
-    tabs().setAll(next)
-
-    const active = tabs().active()
-    if (!active) return
-    if (!active.startsWith("file://")) return
-
-    const normalized = normalizeTab(active)
-    if (active === normalized) return
-    tabs().setActive(normalized)
-  })
-
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
   const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
   const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
   const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
   const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
@@ -178,7 +381,6 @@ export default function Page() {
     return sync.session.history.loading(id)
     return sync.session.history.loading(id)
   })
   })
 
 
-  const emptyUserMessages: UserMessage[] = []
   const userMessages = createMemo(
   const userMessages = createMemo(
     () => messages().filter((m) => m.role === "user") as UserMessage[],
     () => messages().filter((m) => m.role === "user") as UserMessage[],
     emptyUserMessages,
     emptyUserMessages,
@@ -211,29 +413,26 @@ export default function Page() {
 
 
   const [store, setStore] = createStore({
   const [store, setStore] = createStore({
     messageId: undefined as string | undefined,
     messageId: undefined as string | undefined,
-    turnStart: 0,
     mobileTab: "session" as "session" | "changes",
     mobileTab: "session" as "session" | "changes",
     changes: "session" as "session" | "turn",
     changes: "session" as "session" | "turn",
     newSessionWorktree: "main",
     newSessionWorktree: "main",
+    deferRender: false,
   })
   })
 
 
+  createComputed((prev) => {
+    const key = sessionKey()
+    if (key !== prev) {
+      setStore("deferRender", true)
+      requestAnimationFrame(() => {
+        setTimeout(() => setStore("deferRender", false), 0)
+      })
+    }
+    return key
+  }, sessionKey())
+
   const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
   const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
   const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
   const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
 
 
-  const renderedUserMessages = createMemo(
-    () => {
-      const msgs = visibleUserMessages()
-      const start = store.turnStart
-      if (start <= 0) return msgs
-      if (start >= msgs.length) return emptyUserMessages
-      return msgs.slice(start)
-    },
-    emptyUserMessages,
-    {
-      equals: same,
-    },
-  )
-
   const newSessionWorktree = createMemo(() => {
   const newSessionWorktree = createMemo(() => {
     if (store.newSessionWorktree === "create") return "create"
     if (store.newSessionWorktree === "create") return "create"
     const project = sync.project
     const project = sync.project
@@ -302,13 +501,15 @@ export default function Page() {
 
 
   const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
   const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
 
 
-  createEffect(() => {
-    sdk.directory
-    const id = params.id
-    if (!id) return
-    void sync.session.sync(id)
-    void sync.session.todo(id)
-  })
+  createEffect(
+    on([() => sdk.directory, () => params.id] as const, ([, id]) => {
+      if (!id) return
+      untrack(() => {
+        void sync.session.sync(id)
+        void sync.session.todo(id)
+      })
+    }),
+  )
 
 
   createEffect(
   createEffect(
     on(
     on(
@@ -535,35 +736,12 @@ export default function Page() {
     loadingClass: string
     loadingClass: string
     emptyClass: string
     emptyClass: string
   }) => (
   }) => (
-    <Switch>
-      <Match when={store.changes === "turn" && !!params.id}>
-        <SessionReviewTab
-          title={changesTitle()}
-          empty={emptyTurn()}
-          diffs={reviewDiffs}
-          view={view}
-          diffStyle={input.diffStyle}
-          onDiffStyleChange={input.onDiffStyleChange}
-          onScrollRef={(el) => setTree("reviewScroll", el)}
-          focusedFile={tree.activeDiff}
-          onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
-          onLineCommentUpdate={updateCommentInContext}
-          onLineCommentDelete={removeCommentFromContext}
-          lineCommentActions={reviewCommentActions()}
-          comments={comments.all()}
-          focusedComment={comments.focus()}
-          onFocusedCommentChange={comments.setFocus}
-          onViewFile={openReviewFile}
-          classes={input.classes}
-        />
-      </Match>
-      <Match when={hasReview()}>
-        <Show
-          when={diffsReady()}
-          fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
-        >
+    <Show when={!store.deferRender}>
+      <Switch>
+        <Match when={store.changes === "turn" && !!params.id}>
           <SessionReviewTab
           <SessionReviewTab
             title={changesTitle()}
             title={changesTitle()}
+            empty={emptyTurn()}
             diffs={reviewDiffs}
             diffs={reviewDiffs}
             view={view}
             view={view}
             diffStyle={input.diffStyle}
             diffStyle={input.diffStyle}
@@ -580,39 +758,64 @@ export default function Page() {
             onViewFile={openReviewFile}
             onViewFile={openReviewFile}
             classes={input.classes}
             classes={input.classes}
           />
           />
-        </Show>
-      </Match>
-      <Match when={true}>
-        <SessionReviewTab
-          title={changesTitle()}
-          empty={
-            store.changes === "turn" ? (
-              emptyTurn()
-            ) : (
-              <div class={input.emptyClass}>
-                <Mark class="w-14 opacity-10" />
-                <div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
-              </div>
-            )
-          }
-          diffs={reviewDiffs}
-          view={view}
-          diffStyle={input.diffStyle}
-          onDiffStyleChange={input.onDiffStyleChange}
-          onScrollRef={(el) => setTree("reviewScroll", el)}
-          focusedFile={tree.activeDiff}
-          onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
-          onLineCommentUpdate={updateCommentInContext}
-          onLineCommentDelete={removeCommentFromContext}
-          lineCommentActions={reviewCommentActions()}
-          comments={comments.all()}
-          focusedComment={comments.focus()}
-          onFocusedCommentChange={comments.setFocus}
-          onViewFile={openReviewFile}
-          classes={input.classes}
-        />
-      </Match>
-    </Switch>
+        </Match>
+        <Match when={hasReview()}>
+          <Show
+            when={diffsReady()}
+            fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
+          >
+            <SessionReviewTab
+              title={changesTitle()}
+              diffs={reviewDiffs}
+              view={view}
+              diffStyle={input.diffStyle}
+              onDiffStyleChange={input.onDiffStyleChange}
+              onScrollRef={(el) => setTree("reviewScroll", el)}
+              focusedFile={tree.activeDiff}
+              onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+              onLineCommentUpdate={updateCommentInContext}
+              onLineCommentDelete={removeCommentFromContext}
+              lineCommentActions={reviewCommentActions()}
+              comments={comments.all()}
+              focusedComment={comments.focus()}
+              onFocusedCommentChange={comments.setFocus}
+              onViewFile={openReviewFile}
+              classes={input.classes}
+            />
+          </Show>
+        </Match>
+        <Match when={true}>
+          <SessionReviewTab
+            title={changesTitle()}
+            empty={
+              store.changes === "turn" ? (
+                emptyTurn()
+              ) : (
+                <div class={input.emptyClass}>
+                  <Mark class="w-14 opacity-10" />
+                  <div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
+                </div>
+              )
+            }
+            diffs={reviewDiffs}
+            view={view}
+            diffStyle={input.diffStyle}
+            onDiffStyleChange={input.onDiffStyleChange}
+            onScrollRef={(el) => setTree("reviewScroll", el)}
+            focusedFile={tree.activeDiff}
+            onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+            onLineCommentUpdate={updateCommentInContext}
+            onLineCommentDelete={removeCommentFromContext}
+            lineCommentActions={reviewCommentActions()}
+            comments={comments.all()}
+            focusedComment={comments.focus()}
+            onFocusedCommentChange={comments.setFocus}
+            onViewFile={openReviewFile}
+            classes={input.classes}
+          />
+        </Match>
+      </Switch>
+    </Show>
   )
   )
 
 
   const reviewPanel = () => (
   const reviewPanel = () => (
@@ -894,88 +1097,16 @@ export default function Page() {
     },
     },
   )
   )
 
 
-  const turnInit = 20
-  const turnBatch = 20
-  let turnHandle: number | undefined
-  let turnIdle = false
-
-  function cancelTurnBackfill() {
-    const handle = turnHandle
-    if (handle === undefined) return
-    turnHandle = undefined
-
-    if (turnIdle && window.cancelIdleCallback) {
-      window.cancelIdleCallback(handle)
-      return
-    }
-
-    clearTimeout(handle)
-  }
-
-  function scheduleTurnBackfill() {
-    if (turnHandle !== undefined) return
-    if (store.turnStart <= 0) return
-
-    if (window.requestIdleCallback) {
-      turnIdle = true
-      turnHandle = window.requestIdleCallback(() => {
-        turnHandle = undefined
-        backfillTurns()
-      })
-      return
-    }
-
-    turnIdle = false
-    turnHandle = window.setTimeout(() => {
-      turnHandle = undefined
-      backfillTurns()
-    }, 0)
-  }
-
-  function backfillTurns() {
-    const start = store.turnStart
-    if (start <= 0) return
-
-    const next = start - turnBatch
-    const nextStart = next > 0 ? next : 0
-
-    const el = scroller
-    if (!el) {
-      setStore("turnStart", nextStart)
-      scheduleTurnBackfill()
-      return
-    }
-
-    const beforeTop = el.scrollTop
-    const beforeHeight = el.scrollHeight
-
-    setStore("turnStart", nextStart)
-
-    requestAnimationFrame(() => {
-      const delta = el.scrollHeight - beforeHeight
-      if (!delta) return
-      el.scrollTop = beforeTop + delta
-    })
-
-    scheduleTurnBackfill()
-  }
-
-  createEffect(
-    on(
-      () => [params.id, messagesReady()] as const,
-      ([id, ready]) => {
-        cancelTurnBackfill()
-        setStore("turnStart", 0)
-        if (!id || !ready) return
-
-        const len = visibleUserMessages().length
-        const start = len > turnInit ? len - turnInit : 0
-        setStore("turnStart", start)
-        scheduleTurnBackfill()
-      },
-      { defer: true },
-    ),
-  )
+  const historyWindow = createSessionHistoryWindow({
+    sessionID: () => params.id,
+    messagesReady,
+    visibleUserMessages,
+    historyMore,
+    historyLoading,
+    loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
+    userScrolled: autoScroll.userScrolled,
+    scroller: () => scroller,
+  })
 
 
   createResizeObserver(
   createResizeObserver(
     () => promptDock,
     () => promptDock,
@@ -986,7 +1117,9 @@ export default function Page() {
 
 
       const el = scroller
       const el = scroller
       const delta = next - dockHeight
       const delta = next - dockHeight
-      const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta) : false
+      const stick = el
+        ? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
+        : false
 
 
       dockHeight = next
       dockHeight = next
 
 
@@ -1002,13 +1135,12 @@ export default function Page() {
     sessionID: () => params.id,
     sessionID: () => params.id,
     messagesReady,
     messagesReady,
     visibleUserMessages,
     visibleUserMessages,
-    turnStart: () => store.turnStart,
+    turnStart: historyWindow.turnStart,
     currentMessageId: () => store.messageId,
     currentMessageId: () => store.messageId,
     pendingMessage: () => ui.pendingMessage,
     pendingMessage: () => ui.pendingMessage,
     setPendingMessage: (value) => setUi("pendingMessage", value),
     setPendingMessage: (value) => setUi("pendingMessage", value),
     setActiveMessage,
     setActiveMessage,
-    setTurnStart: (value) => setStore("turnStart", value),
-    scheduleTurnBackfill,
+    setTurnStart: historyWindow.setTurnStart,
     autoScroll,
     autoScroll,
     scroller: () => scroller,
     scroller: () => scroller,
     anchor,
     anchor,
@@ -1021,7 +1153,6 @@ export default function Page() {
   })
   })
 
 
   onCleanup(() => {
   onCleanup(() => {
-    cancelTurnBackfill()
     document.removeEventListener("keydown", handleKeyDown)
     document.removeEventListener("keydown", handleKeyDown)
     scrollSpy.destroy()
     scrollSpy.destroy()
     if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
     if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
@@ -1076,6 +1207,7 @@ export default function Page() {
                     hasScrollGesture={hasScrollGesture}
                     hasScrollGesture={hasScrollGesture}
                     isDesktop={isDesktop()}
                     isDesktop={isDesktop()}
                     onScrollSpyScroll={scrollSpy.onScroll}
                     onScrollSpyScroll={scrollSpy.onScroll}
+                    onTurnBackfillScroll={historyWindow.onScrollerScroll}
                     onAutoScrollInteraction={autoScroll.handleInteraction}
                     onAutoScrollInteraction={autoScroll.handleInteraction}
                     centered={centered()}
                     centered={centered()}
                     setContentRef={(el) => {
                     setContentRef={(el) => {
@@ -1085,21 +1217,16 @@ export default function Page() {
                       const root = scroller
                       const root = scroller
                       if (root) scheduleScrollState(root)
                       if (root) scheduleScrollState(root)
                     }}
                     }}
-                    turnStart={store.turnStart}
-                    onRenderEarlier={() => setStore("turnStart", 0)}
+                    turnStart={historyWindow.turnStart()}
                     historyMore={historyMore()}
                     historyMore={historyMore()}
                     historyLoading={historyLoading()}
                     historyLoading={historyLoading()}
                     onLoadEarlier={() => {
                     onLoadEarlier={() => {
-                      const id = params.id
-                      if (!id) return
-                      setStore("turnStart", 0)
-                      sync.session.history.loadMore(id)
+                      void historyWindow.loadAndReveal()
                     }}
                     }}
-                    renderedUserMessages={renderedUserMessages()}
+                    renderedUserMessages={historyWindow.renderedUserMessages()}
                     anchor={anchor}
                     anchor={anchor}
                     onRegisterMessage={scrollSpy.register}
                     onRegisterMessage={scrollSpy.register}
                     onUnregisterMessage={scrollSpy.unregister}
                     onUnregisterMessage={scrollSpy.unregister}
-                    lastUserMessageID={lastUserMessage()?.id}
                   />
                   />
                 </Show>
                 </Show>
               </Match>
               </Match>

+ 81 - 16
packages/app/src/pages/session/composer/session-composer-region.tsx

@@ -1,5 +1,6 @@
-import { Show, createEffect, createMemo } from "solid-js"
+import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
 import { useParams } from "@solidjs/router"
 import { useParams } from "@solidjs/router"
+import { useSpring } from "@opencode-ai/ui/motion-spring"
 import { PromptInput } from "@/components/prompt-input"
 import { PromptInput } from "@/components/prompt-input"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { usePrompt } from "@/context/prompt"
 import { usePrompt } from "@/context/prompt"
@@ -18,6 +19,23 @@ export function SessionComposerRegion(props: {
   onSubmit: () => void
   onSubmit: () => void
   onResponseSubmit: () => void
   onResponseSubmit: () => void
   setPromptDockRef: (el: HTMLDivElement) => void
   setPromptDockRef: (el: HTMLDivElement) => void
+  visualDuration?: number
+  bounce?: number
+  dockOpenVisualDuration?: number
+  dockOpenBounce?: number
+  dockCloseVisualDuration?: number
+  dockCloseBounce?: number
+  drawerExpandVisualDuration?: number
+  drawerExpandBounce?: number
+  drawerCollapseVisualDuration?: number
+  drawerCollapseBounce?: number
+  subtitleDuration?: number
+  subtitleTravel?: number
+  subtitleEdge?: number
+  countDuration?: number
+  countMask?: number
+  countMaskHeight?: number
+  countWidthDuration?: number
 }) {
 }) {
   const params = useParams()
   const params = useParams()
   const prompt = usePrompt()
   const prompt = usePrompt()
@@ -43,6 +61,37 @@ export function SessionComposerRegion(props: {
     setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
     setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
   })
   })
 
 
+  const open = createMemo(() => props.state.dock() && !props.state.closing())
+  const config = createMemo(() =>
+    open()
+      ? {
+          visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3,
+          bounce: props.dockOpenBounce ?? props.bounce ?? 0,
+        }
+      : {
+          visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3,
+          bounce: props.dockCloseBounce ?? props.bounce ?? 0,
+        },
+  )
+  const progress = useSpring(() => (open() ? 1 : 0), config)
+  const value = createMemo(() => Math.max(0, Math.min(1, progress())))
+  const [height, setHeight] = createSignal(320)
+  const dock = createMemo(() => props.state.dock() || value() > 0.001)
+  const full = createMemo(() => Math.max(78, height()))
+  const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
+
+  createEffect(() => {
+    const el = contentRef()
+    if (!el) return
+    const update = () => {
+      setHeight(el.getBoundingClientRect().height)
+    }
+    update()
+    const observer = new ResizeObserver(update)
+    observer.observe(el)
+    onCleanup(() => observer.disconnect())
+  })
+
   return (
   return (
     <div
     <div
       ref={props.setPromptDockRef}
       ref={props.setPromptDockRef}
@@ -87,30 +136,46 @@ export function SessionComposerRegion(props: {
               </div>
               </div>
             }
             }
           >
           >
-            <Show when={props.state.dock()}>
+            <Show when={dock()}>
               <div
               <div
                 classList={{
                 classList={{
-                  "transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
-                  "max-h-[320px]": !props.state.closing(),
-                  "max-h-0 pointer-events-none": props.state.closing(),
-                  "opacity-0 translate-y-9": props.state.closing() || props.state.opening(),
-                  "opacity-100 translate-y-0": !props.state.closing() && !props.state.opening(),
+                  "overflow-hidden": true,
+                  "pointer-events-none": value() < 0.98,
+                }}
+                style={{
+                  "max-height": `${full() * value()}px`,
                 }}
                 }}
               >
               >
-                <SessionTodoDock
-                  todos={props.state.todos()}
-                  title={language.t("session.todo.title")}
-                  collapseLabel={language.t("session.todo.collapse")}
-                  expandLabel={language.t("session.todo.expand")}
-                />
+                <div ref={setContentRef}>
+                  <SessionTodoDock
+                    todos={props.state.todos()}
+                    title={language.t("session.todo.title")}
+                    collapseLabel={language.t("session.todo.collapse")}
+                    expandLabel={language.t("session.todo.expand")}
+                    dockProgress={value()}
+                    visualDuration={props.visualDuration}
+                    bounce={props.bounce}
+                    expandVisualDuration={props.drawerExpandVisualDuration}
+                    expandBounce={props.drawerExpandBounce}
+                    collapseVisualDuration={props.drawerCollapseVisualDuration}
+                    collapseBounce={props.drawerCollapseBounce}
+                    subtitleDuration={props.subtitleDuration}
+                    subtitleTravel={props.subtitleTravel}
+                    subtitleEdge={props.subtitleEdge}
+                    countDuration={props.countDuration}
+                    countMask={props.countMask}
+                    countMaskHeight={props.countMaskHeight}
+                    countWidthDuration={props.countWidthDuration}
+                  />
+                </div>
               </div>
               </div>
             </Show>
             </Show>
             <div
             <div
               classList={{
               classList={{
                 "relative z-10": true,
                 "relative z-10": true,
-                "transition-[margin] duration-[400ms] ease-out": true,
-                "-mt-9": props.state.dock() && !props.state.closing(),
-                "mt-0": !props.state.dock() || props.state.closing(),
+              }}
+              style={{
+                "margin-top": `${-36 * value()}px`,
               }}
               }}
             >
             >
               <PromptInput
               <PromptInput

+ 9 - 2
packages/app/src/pages/session/composer/session-composer-state.ts

@@ -29,7 +29,7 @@ export function createSessionComposerBlocked() {
   })
   })
 }
 }
 
 
-export function createSessionComposerState() {
+export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
   const params = useParams()
   const params = useParams()
   const sdk = useSDK()
   const sdk = useSDK()
   const sync = useSync()
   const sync = useSync()
@@ -96,12 +96,19 @@ export function createSessionComposerState() {
   let timer: number | undefined
   let timer: number | undefined
   let raf: number | undefined
   let raf: number | undefined
 
 
+  const closeMs = () => {
+    const value = options?.closeMs
+    if (typeof value === "function") return Math.max(0, value())
+    if (typeof value === "number") return Math.max(0, value)
+    return 400
+  }
+
   const scheduleClose = () => {
   const scheduleClose = () => {
     if (timer) window.clearTimeout(timer)
     if (timer) window.clearTimeout(timer)
     timer = window.setTimeout(() => {
     timer = window.setTimeout(() => {
       setStore({ dock: false, closing: false })
       setStore({ dock: false, closing: false })
       timer = undefined
       timer = undefined
-    }, 400)
+    }, closeMs())
   }
   }
 
 
   createEffect(
   createEffect(

+ 173 - 65
packages/app/src/pages/session/composer/session-todo-dock.tsx

@@ -1,8 +1,12 @@
 import type { Todo } from "@opencode-ai/sdk/v2"
 import type { Todo } from "@opencode-ai/sdk/v2"
+import { AnimatedNumber } from "@opencode-ai/ui/animated-number"
 import { Checkbox } from "@opencode-ai/ui/checkbox"
 import { Checkbox } from "@opencode-ai/ui/checkbox"
 import { DockTray } from "@opencode-ai/ui/dock-surface"
 import { DockTray } from "@opencode-ai/ui/dock-surface"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { IconButton } from "@opencode-ai/ui/icon-button"
-import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
+import { useSpring } from "@opencode-ai/ui/motion-spring"
+import { TextReveal } from "@opencode-ai/ui/text-reveal"
+import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
+import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
 
 
 function dot(status: Todo["status"]) {
 function dot(status: Todo["status"]) {
@@ -30,19 +34,35 @@ function dot(status: Todo["status"]) {
   )
   )
 }
 }
 
 
-export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
+export function SessionTodoDock(props: {
+  todos: Todo[]
+  title: string
+  collapseLabel: string
+  expandLabel: string
+  dockProgress?: number
+  visualDuration?: number
+  bounce?: number
+  expandVisualDuration?: number
+  expandBounce?: number
+  collapseVisualDuration?: number
+  collapseBounce?: number
+  subtitleDuration?: number
+  subtitleTravel?: number
+  subtitleEdge?: number
+  countDuration?: number
+  countMask?: number
+  countMaskHeight?: number
+  countWidthDuration?: number
+}) {
   const [store, setStore] = createStore({
   const [store, setStore] = createStore({
     collapsed: false,
     collapsed: false,
   })
   })
 
 
   const toggle = () => setStore("collapsed", (value) => !value)
   const toggle = () => setStore("collapsed", (value) => !value)
 
 
-  const summary = createMemo(() => {
-    const total = props.todos.length
-    if (total === 0) return ""
-    const completed = props.todos.filter((todo) => todo.status === "completed").length
-    return `${completed} of ${total} ${props.title.toLowerCase()} completed`
-  })
+  const total = createMemo(() => props.todos.length)
+  const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length)
+  const label = createMemo(() => `${done()} of ${total()} ${props.title.toLowerCase()} completed`)
 
 
   const active = createMemo(
   const active = createMemo(
     () =>
     () =>
@@ -53,56 +73,134 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
   )
   )
 
 
   const preview = createMemo(() => active()?.content ?? "")
   const preview = createMemo(() => active()?.content ?? "")
+  const config = createMemo(() =>
+    store.collapsed
+      ? {
+          visualDuration: props.collapseVisualDuration ?? props.visualDuration ?? 0.3,
+          bounce: props.collapseBounce ?? props.bounce ?? 0,
+        }
+      : {
+          visualDuration: props.expandVisualDuration ?? props.visualDuration ?? 0.3,
+          bounce: props.expandBounce ?? props.bounce ?? 0,
+        },
+  )
+  const collapse = useSpring(() => (store.collapsed ? 1 : 0), config)
+  const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1)))
+  const shut = createMemo(() => 1 - dock())
+  const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
+  const hide = createMemo(() => Math.max(value(), shut()))
+  const off = createMemo(() => hide() > 0.98)
+  const turn = createMemo(() => Math.max(0, Math.min(1, value())))
+  const [height, setHeight] = createSignal(320)
+  const full = createMemo(() => Math.max(78, height()))
+  let contentRef: HTMLDivElement | undefined
+
+  createEffect(() => {
+    const el = contentRef
+    if (!el) return
+    const update = () => {
+      setHeight(el.getBoundingClientRect().height)
+    }
+    update()
+    const observer = new ResizeObserver(update)
+    observer.observe(el)
+    onCleanup(() => observer.disconnect())
+  })
 
 
   return (
   return (
     <DockTray
     <DockTray
       data-component="session-todo-dock"
       data-component="session-todo-dock"
-      classList={{
-        "h-[78px]": store.collapsed,
+      style={{
+        "overflow-x": "visible",
+        "overflow-y": "hidden",
+        "max-height": `${Math.max(78, full() - value() * (full() - 78))}px`,
       }}
       }}
     >
     >
-      <div
-        data-action="session-todo-toggle"
-        class="pl-3 pr-2 py-2 flex items-center gap-2"
-        role="button"
-        tabIndex={0}
-        onClick={toggle}
-        onKeyDown={(event) => {
-          if (event.key !== "Enter" && event.key !== " ") return
-          event.preventDefault()
-          toggle()
-        }}
-      >
-        <span class="text-14-regular text-text-strong cursor-default">{summary()}</span>
-        <Show when={store.collapsed}>
-          <div class="ml-1 flex-1 min-w-0">
-            <Show when={preview()}>
-              <div class="text-14-regular text-text-base truncate cursor-default">{preview()}</div>
-            </Show>
-          </div>
-        </Show>
-        <div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
-          <IconButton
-            data-action="session-todo-toggle-button"
-            icon="chevron-down"
-            size="normal"
-            variant="ghost"
-            classList={{ "rotate-180": store.collapsed }}
-            onMouseDown={(event) => {
-              event.preventDefault()
-              event.stopPropagation()
+      <div ref={contentRef}>
+        <div
+          data-action="session-todo-toggle"
+          class="pl-3 pr-2 py-2 flex items-center gap-2 overflow-visible"
+          role="button"
+          tabIndex={0}
+          onClick={toggle}
+          onKeyDown={(event) => {
+            if (event.key !== "Enter" && event.key !== " ") return
+            event.preventDefault()
+            toggle()
+          }}
+        >
+          <span
+            class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible"
+            aria-label={label()}
+            style={{
+              "--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`,
+              "--tool-motion-mask": `${props.countMask ?? 18}%`,
+              "--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
+              "--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
+              opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
+              filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`,
             }}
             }}
-            onClick={(event) => {
-              event.stopPropagation()
-              toggle()
+          >
+            <AnimatedNumber value={done()} />
+            <span class="mx-1">of</span>
+            <AnimatedNumber value={total()} />
+            <span>&nbsp;{props.title.toLowerCase()} completed</span>
+          </span>
+          <div
+            data-slot="session-todo-preview"
+            class="ml-1 min-w-0 overflow-hidden"
+            style={{
+              flex: "1 1 auto",
+              "max-width": "100%",
             }}
             }}
-            aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
-          />
+          >
+            <TextReveal
+              class="text-14-regular text-text-base cursor-default"
+              text={store.collapsed ? preview() : undefined}
+              duration={props.subtitleDuration ?? 600}
+              travel={props.subtitleTravel ?? 25}
+              edge={props.subtitleEdge ?? 17}
+              spring="cubic-bezier(0.34, 1, 0.64, 1)"
+              springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
+              growOnly
+              truncate
+            />
+          </div>
+          <div class="ml-auto">
+            <IconButton
+              data-action="session-todo-toggle-button"
+              data-collapsed={store.collapsed ? "true" : "false"}
+              icon="chevron-down"
+              size="normal"
+              variant="ghost"
+              style={{ transform: `rotate(${turn() * 180}deg)` }}
+              onMouseDown={(event) => {
+                event.preventDefault()
+                event.stopPropagation()
+              }}
+              onClick={(event) => {
+                event.stopPropagation()
+                toggle()
+              }}
+              aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
+            />
+          </div>
         </div>
         </div>
-      </div>
 
 
-      <div data-slot="session-todo-list" hidden={store.collapsed}>
-        <TodoList todos={props.todos} open={!store.collapsed} />
+        <div
+          data-slot="session-todo-list"
+          aria-hidden={store.collapsed || off()}
+          classList={{
+            "pointer-events-none": hide() > 0.1,
+          }}
+          style={{
+            visibility: off() ? "hidden" : "visible",
+            opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
+            filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`,
+          }}
+        >
+          <TodoList todos={props.todos} open={!store.collapsed} />
+        </div>
       </div>
       </div>
     </DockTray>
     </DockTray>
   )
   )
@@ -171,33 +269,43 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
           }, 250)
           }, 250)
         }}
         }}
       >
       >
-        <For each={props.todos}>
+        <Index each={props.todos}>
           {(todo) => (
           {(todo) => (
             <Checkbox
             <Checkbox
               readOnly
               readOnly
-              checked={todo.status === "completed"}
-              indeterminate={todo.status === "in_progress"}
-              data-in-progress={todo.status === "in_progress" ? "" : undefined}
-              icon={dot(todo.status)}
-              style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px" }}
+              checked={todo().status === "completed"}
+              indeterminate={todo().status === "in_progress"}
+              data-in-progress={todo().status === "in_progress" ? "" : undefined}
+              data-state={todo().status}
+              icon={dot(todo().status)}
+              style={{
+                "--checkbox-align": "flex-start",
+                "--checkbox-offset": "1px",
+                transition:
+                  "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), filter 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
+                opacity: todo().status === "pending" ? "0.94" : "1",
+                filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)",
+              }}
             >
             >
-              <span
+              <TextStrikethrough
+                active={todo().status === "completed" || todo().status === "cancelled"}
+                text={todo().content}
                 class="text-14-regular min-w-0 break-words"
                 class="text-14-regular min-w-0 break-words"
-                classList={{
-                  "text-text-weak": todo.status === "completed" || todo.status === "cancelled",
-                  "text-text-strong": todo.status !== "completed" && todo.status !== "cancelled",
-                }}
                 style={{
                 style={{
                   "line-height": "var(--line-height-normal)",
                   "line-height": "var(--line-height-normal)",
-                  "text-decoration":
-                    todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined,
+                  transition:
+                    "color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), filter 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
+                  color:
+                    todo().status === "completed" || todo().status === "cancelled"
+                      ? "var(--text-weak)"
+                      : "var(--text-strong)",
+                  opacity: todo().status === "pending" ? "0.92" : "1",
+                  filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)",
                 }}
                 }}
-              >
-                {todo.content}
-              </span>
+              />
             </Checkbox>
             </Checkbox>
           )}
           )}
-        </For>
+        </Index>
       </div>
       </div>
       <div
       <div
         class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"
         class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"

+ 28 - 35
packages/app/src/pages/session/file-tabs.tsx

@@ -67,6 +67,7 @@ export function FileTabContent(props: { tab: string }) {
 
 
   let scroll: HTMLDivElement | undefined
   let scroll: HTMLDivElement | undefined
   let scrollFrame: number | undefined
   let scrollFrame: number | undefined
+  let restoreFrame: number | undefined
   let pending: { x: number; y: number } | undefined
   let pending: { x: number; y: number } | undefined
   let codeScroll: HTMLElement[] = []
   let codeScroll: HTMLElement[] = []
   let find: FileSearchHandle | null = null
   let find: FileSearchHandle | null = null
@@ -349,6 +350,15 @@ export function FileTabContent(props: { tab: string }) {
     if (el.scrollLeft !== s.x) el.scrollLeft = s.x
     if (el.scrollLeft !== s.x) el.scrollLeft = s.x
   }
   }
 
 
+  const queueRestore = () => {
+    if (restoreFrame !== undefined) return
+
+    restoreFrame = requestAnimationFrame(() => {
+      restoreFrame = undefined
+      restoreScroll()
+    })
+  }
+
   const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
   const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
     if (codeScroll.length === 0) syncCodeScroll()
     if (codeScroll.length === 0) syncCodeScroll()
 
 
@@ -364,46 +374,29 @@ export function FileTabContent(props: { tab: string }) {
     setNote("commenting", null)
     setNote("commenting", null)
   }
   }
 
 
-  createEffect(
-    on(
-      () => state()?.loaded,
-      (loaded) => {
-        if (!loaded) return
-        requestAnimationFrame(restoreScroll)
-      },
-      { defer: true },
-    ),
-  )
-
-  createEffect(
-    on(
-      () => file.ready(),
-      (ready) => {
-        if (!ready) return
-        requestAnimationFrame(restoreScroll)
-      },
-      { defer: true },
-    ),
-  )
+  let prev = {
+    loaded: false,
+    ready: false,
+    active: false,
+  }
 
 
-  createEffect(
-    on(
-      () => tabs().active() === props.tab,
-      (active) => {
-        if (!active) return
-        if (!state()?.loaded) return
-        requestAnimationFrame(restoreScroll)
-      },
-    ),
-  )
+  createEffect(() => {
+    const loaded = !!state()?.loaded
+    const ready = file.ready()
+    const active = tabs().active() === props.tab
+    const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
+    prev = { loaded, ready, active }
+    if (!restore) return
+    queueRestore()
+  })
 
 
   onCleanup(() => {
   onCleanup(() => {
     for (const item of codeScroll) {
     for (const item of codeScroll) {
       item.removeEventListener("scroll", handleCodeScroll)
       item.removeEventListener("scroll", handleCodeScroll)
     }
     }
 
 
-    if (scrollFrame === undefined) return
-    cancelAnimationFrame(scrollFrame)
+    if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
+    if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
   })
   })
 
 
   const renderFile = (source: string) => (
   const renderFile = (source: string) => (
@@ -421,7 +414,7 @@ export function FileTabContent(props: { tab: string }) {
         selectedLines={activeSelection()}
         selectedLines={activeSelection()}
         commentedLines={commentedLines()}
         commentedLines={commentedLines()}
         onRendered={() => {
         onRendered={() => {
-          requestAnimationFrame(restoreScroll)
+          queueRestore()
         }}
         }}
         annotations={commentsUi.annotations()}
         annotations={commentsUi.annotations()}
         renderAnnotation={commentsUi.renderAnnotation}
         renderAnnotation={commentsUi.renderAnnotation}
@@ -440,7 +433,7 @@ export function FileTabContent(props: { tab: string }) {
           mode: "auto",
           mode: "auto",
           path: path(),
           path: path(),
           current: state()?.content,
           current: state()?.content,
-          onLoad: () => requestAnimationFrame(restoreScroll),
+          onLoad: queueRestore,
           onError: (args: { kind: "image" | "audio" | "svg" }) => {
           onError: (args: { kind: "image" | "audio" | "svg" }) => {
             if (args.kind !== "svg") return
             if (args.kind !== "svg") return
             showToast({
             showToast({

+ 201 - 45
packages/app/src/pages/session/message-timeline.tsx

@@ -1,4 +1,4 @@
-import { For, createEffect, createMemo, on, onCleanup, Show, type JSX } from "solid-js"
+import { For, createEffect, createMemo, on, onCleanup, Show, startTransition, Index, type JSX } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { createStore, produce } from "solid-js/store"
 import { useNavigate, useParams } from "@solidjs/router"
 import { useNavigate, useParams } from "@solidjs/router"
 import { Button } from "@opencode-ai/ui/button"
 import { Button } from "@opencode-ai/ui/button"
@@ -10,8 +10,9 @@ import { Dialog } from "@opencode-ai/ui/dialog"
 import { InlineInput } from "@opencode-ai/ui/inline-input"
 import { InlineInput } from "@opencode-ai/ui/inline-input"
 import { SessionTurn } from "@opencode-ai/ui/session-turn"
 import { SessionTurn } from "@opencode-ai/ui/session-turn"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
-import type { Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
+import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
 import { showToast } from "@opencode-ai/ui/toast"
 import { showToast } from "@opencode-ai/ui/toast"
+import { Binary } from "@opencode-ai/util/binary"
 import { getFilename } from "@opencode-ai/util/path"
 import { getFilename } from "@opencode-ai/util/path"
 import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
 import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
 import { SessionContextUsage } from "@/components/session-context-usage"
 import { SessionContextUsage } from "@/components/session-context-usage"
@@ -31,6 +32,9 @@ type MessageComment = {
   }
   }
 }
 }
 
 
+const emptyMessages: MessageType[] = []
+const idle = { type: "idle" as const }
+
 const messageComments = (parts: Part[]): MessageComment[] =>
 const messageComments = (parts: Part[]): MessageComment[] =>
   parts.flatMap((part) => {
   parts.flatMap((part) => {
     if (part.type !== "text" || !(part as TextPart).synthetic) return []
     if (part.type !== "text" || !(part as TextPart).synthetic) return []
@@ -81,6 +85,103 @@ const markBoundaryGesture = (input: {
   }
   }
 }
 }
 
 
+type StageConfig = {
+  init: number
+  batch: number
+}
+
+type TimelineStageInput = {
+  sessionKey: () => string
+  turnStart: () => number
+  messages: () => UserMessage[]
+  config: StageConfig
+}
+
+/**
+ * Defer-mounts small timeline windows so revealing older turns does not
+ * block first paint with a large DOM mount.
+ *
+ * Once staging completes for a session it never re-stages — backfill and
+ * new messages render immediately.
+ */
+function createTimelineStaging(input: TimelineStageInput) {
+  const [state, setState] = createStore({
+    activeSession: "",
+    completedSession: "",
+    count: 0,
+  })
+
+  const stagedCount = createMemo(() => {
+    const total = input.messages().length
+    if (input.turnStart() <= 0) return total
+    if (state.completedSession === input.sessionKey()) return total
+    const init = Math.min(total, input.config.init)
+    if (state.count <= init) return init
+    if (state.count >= total) return total
+    return state.count
+  })
+
+  const stagedUserMessages = createMemo(() => {
+    const list = input.messages()
+    const count = stagedCount()
+    if (count >= list.length) return list
+    return list.slice(Math.max(0, list.length - count))
+  })
+
+  let frame: number | undefined
+  const cancel = () => {
+    if (frame === undefined) return
+    cancelAnimationFrame(frame)
+    frame = undefined
+  }
+
+  createEffect(
+    on(
+      () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
+      ([sessionKey, isWindowed, total]) => {
+        cancel()
+        const shouldStage =
+          isWindowed &&
+          total > input.config.init &&
+          state.completedSession !== sessionKey &&
+          state.activeSession !== sessionKey
+        if (!shouldStage) {
+          setState({ activeSession: "", count: total })
+          return
+        }
+
+        let count = Math.min(total, input.config.init)
+        setState({ activeSession: sessionKey, count })
+
+        const step = () => {
+          if (input.sessionKey() !== sessionKey) {
+            frame = undefined
+            return
+          }
+          const currentTotal = input.messages().length
+          count = Math.min(currentTotal, count + input.config.batch)
+          startTransition(() => setState("count", count))
+          if (count >= currentTotal) {
+            setState({ completedSession: sessionKey, activeSession: "" })
+            frame = undefined
+            return
+          }
+          frame = requestAnimationFrame(step)
+        }
+        frame = requestAnimationFrame(step)
+      },
+    ),
+  )
+
+  const isStaging = createMemo(() => {
+    const key = input.sessionKey()
+    return state.activeSession === key && state.completedSession !== key
+  })
+
+  onCleanup(cancel)
+  return { messages: stagedUserMessages, isStaging }
+}
+
 export function MessageTimeline(props: {
 export function MessageTimeline(props: {
   mobileChanges: boolean
   mobileChanges: boolean
   mobileFallback: JSX.Element
   mobileFallback: JSX.Element
@@ -93,11 +194,11 @@ export function MessageTimeline(props: {
   hasScrollGesture: () => boolean
   hasScrollGesture: () => boolean
   isDesktop: boolean
   isDesktop: boolean
   onScrollSpyScroll: () => void
   onScrollSpyScroll: () => void
+  onTurnBackfillScroll: () => void
   onAutoScrollInteraction: (event: MouseEvent) => void
   onAutoScrollInteraction: (event: MouseEvent) => void
   centered: boolean
   centered: boolean
   setContentRef: (el: HTMLDivElement) => void
   setContentRef: (el: HTMLDivElement) => void
   turnStart: number
   turnStart: number
-  onRenderEarlier: () => void
   historyMore: boolean
   historyMore: boolean
   historyLoading: boolean
   historyLoading: boolean
   onLoadEarlier: () => void
   onLoadEarlier: () => void
@@ -105,7 +206,6 @@ export function MessageTimeline(props: {
   anchor: (id: string) => string
   anchor: (id: string) => string
   onRegisterMessage: (el: HTMLDivElement, id: string) => void
   onRegisterMessage: (el: HTMLDivElement, id: string) => void
   onUnregisterMessage: (id: string) => void
   onUnregisterMessage: (id: string) => void
-  lastUserMessageID?: string
 }) {
 }) {
   let touchGesture: number | undefined
   let touchGesture: number | undefined
 
 
@@ -117,8 +217,43 @@ export function MessageTimeline(props: {
   const dialog = useDialog()
   const dialog = useDialog()
   const language = useLanguage()
   const language = useLanguage()
 
 
+  const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const sessionID = createMemo(() => params.id)
   const sessionID = createMemo(() => params.id)
+  const sessionMessages = createMemo(() => {
+    const id = sessionID()
+    if (!id) return emptyMessages
+    return sync.data.message[id] ?? emptyMessages
+  })
+  const pending = createMemo(() =>
+    sessionMessages().findLast(
+      (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
+    ),
+  )
+  const sessionStatus = createMemo(() => {
+    const id = sessionID()
+    if (!id) return idle
+    return sync.data.session_status[id] ?? idle
+  })
+  const activeMessageID = createMemo(() => {
+    const parentID = pending()?.parentID
+    if (parentID) {
+      const messages = sessionMessages()
+      const result = Binary.search(messages, parentID, (message) => message.id)
+      const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
+      if (message && message.role === "user") return message.id
+    }
+
+    const status = sessionStatus()
+    if (status.type !== "idle") {
+      const messages = sessionMessages()
+      for (let i = messages.length - 1; i >= 0; i--) {
+        if (messages[i].role === "user") return messages[i].id
+      }
+    }
+
+    return undefined
+  })
   const info = createMemo(() => {
   const info = createMemo(() => {
     const id = sessionID()
     const id = sessionID()
     if (!id) return
     if (!id) return
@@ -127,6 +262,13 @@ export function MessageTimeline(props: {
   const titleValue = createMemo(() => info()?.title)
   const titleValue = createMemo(() => info()?.title)
   const parentID = createMemo(() => info()?.parentID)
   const parentID = createMemo(() => info()?.parentID)
   const showHeader = createMemo(() => !!(titleValue() || parentID()))
   const showHeader = createMemo(() => !!(titleValue() || parentID()))
+  const stageCfg = { init: 1, batch: 3 }
+  const staging = createTimelineStaging({
+    sessionKey,
+    turnStart: () => props.turnStart,
+    messages: () => props.renderedUserMessages,
+    config: stageCfg,
+  })
 
 
   const [title, setTitle] = createStore({
   const [title, setTitle] = createStore({
     draft: "",
     draft: "",
@@ -343,8 +485,10 @@ export function MessageTimeline(props: {
         <div
         <div
           class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
           class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
           classList={{
           classList={{
-            "opacity-100 translate-y-0 scale-100": props.scroll.overflow && !props.scroll.bottom,
-            "opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || props.scroll.bottom,
+            "opacity-100 translate-y-0 scale-100":
+              props.scroll.overflow && !props.scroll.bottom && !staging.isStaging(),
+            "opacity-0 translate-y-2 scale-95 pointer-events-none":
+              !props.scroll.overflow || props.scroll.bottom || staging.isStaging(),
           }}
           }}
         >
         >
           <button
           <button
@@ -393,6 +537,7 @@ export function MessageTimeline(props: {
           }}
           }}
           onScroll={(e) => {
           onScroll={(e) => {
             props.onScheduleScrollState(e.currentTarget)
             props.onScheduleScrollState(e.currentTarget)
+            props.onTurnBackfillScroll()
             if (!props.hasScrollGesture()) return
             if (!props.hasScrollGesture()) return
             props.onAutoScrollHandleScroll()
             props.onAutoScrollHandleScroll()
             props.onMarkScrollGesture(e.currentTarget)
             props.onMarkScrollGesture(e.currentTarget)
@@ -530,14 +675,7 @@ export function MessageTimeline(props: {
               "mt-0": !props.centered,
               "mt-0": !props.centered,
             }}
             }}
           >
           >
-            <Show when={props.turnStart > 0}>
-              <div class="w-full flex justify-center">
-                <Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}>
-                  {language.t("session.messages.renderEarlier")}
-                </Button>
-              </div>
-            </Show>
-            <Show when={props.historyMore}>
+            <Show when={props.turnStart > 0 || props.historyMore}>
               <div class="w-full flex justify-center">
               <div class="w-full flex justify-center">
                 <Button
                 <Button
                   variant="ghost"
                   variant="ghost"
@@ -552,56 +690,74 @@ export function MessageTimeline(props: {
                 </Button>
                 </Button>
               </div>
               </div>
             </Show>
             </Show>
-            <For each={props.renderedUserMessages}>
-              {(message) => {
-                const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? []))
+            <For each={rendered()}>
+              {(messageID) => {
+                const active = createMemo(() => activeMessageID() === messageID)
+                const queued = createMemo(() => {
+                  if (active()) return false
+                  const activeID = activeMessageID()
+                  if (activeID) return messageID > activeID
+                  return false
+                })
+                const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
+                  equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
+                })
+                const commentCount = createMemo(() => comments().length)
                 return (
                 return (
                   <div
                   <div
-                    id={props.anchor(message.id)}
-                    data-message-id={message.id}
+                    id={props.anchor(messageID)}
+                    data-message-id={messageID}
                     ref={(el) => {
                     ref={(el) => {
-                      props.onRegisterMessage(el, message.id)
-                      onCleanup(() => props.onUnregisterMessage(message.id))
+                      props.onRegisterMessage(el, messageID)
+                      onCleanup(() => props.onUnregisterMessage(messageID))
                     }}
                     }}
                     classList={{
                     classList={{
                       "min-w-0 w-full max-w-full": true,
                       "min-w-0 w-full max-w-full": true,
                       "md:max-w-200 2xl:max-w-[1000px]": props.centered,
                       "md:max-w-200 2xl:max-w-[1000px]": props.centered,
                     }}
                     }}
                   >
                   >
-                    <Show when={comments().length > 0}>
+                    <Show when={commentCount() > 0}>
                       <div class="w-full px-4 md:px-5 pb-2">
                       <div class="w-full px-4 md:px-5 pb-2">
                         <div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
                         <div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
                           <div class="flex w-max min-w-full justify-end gap-2">
                           <div class="flex w-max min-w-full justify-end gap-2">
-                            <For each={comments()}>
-                              {(comment) => (
-                                <div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
-                                  <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
-                                    <FileIcon node={{ path: comment.path, type: "file" }} class="size-3.5 shrink-0" />
-                                    <span class="truncate">{getFilename(comment.path)}</span>
-                                    <Show when={comment.selection}>
-                                      {(selection) => (
-                                        <span class="shrink-0 text-text-weak">
-                                          {selection().startLine === selection().endLine
-                                            ? `:${selection().startLine}`
-                                            : `:${selection().startLine}-${selection().endLine}`}
-                                        </span>
-                                      )}
-                                    </Show>
-                                  </div>
-                                  <div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
-                                    {comment.comment}
+                            <Index each={comments()}>
+                              {(commentAccessor: () => MessageComment) => {
+                                const comment = createMemo(() => commentAccessor())
+                                return (
+                                  <div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
+                                    <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
+                                      <FileIcon
+                                        node={{ path: comment().path, type: "file" }}
+                                        class="size-3.5 shrink-0"
+                                      />
+                                      <span class="truncate">{getFilename(comment().path)}</span>
+                                      <Show when={comment().selection}>
+                                        {(selection) => (
+                                          <span class="shrink-0 text-text-weak">
+                                            {selection().startLine === selection().endLine
+                                              ? `:${selection().startLine}`
+                                              : `:${selection().startLine}-${selection().endLine}`}
+                                          </span>
+                                        )}
+                                      </Show>
+                                    </div>
+                                    <div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
+                                      {comment().comment}
+                                    </div>
                                   </div>
                                   </div>
-                                </div>
-                              )}
-                            </For>
+                                )
+                              }}
+                            </Index>
                           </div>
                           </div>
                         </div>
                         </div>
                       </div>
                       </div>
                     </Show>
                     </Show>
                     <SessionTurn
                     <SessionTurn
                       sessionID={sessionID() ?? ""}
                       sessionID={sessionID() ?? ""}
-                      messageID={message.id}
-                      lastUserMessageID={props.lastUserMessageID}
+                      messageID={messageID}
+                      active={active()}
+                      queued={queued()}
+                      status={active() ? sessionStatus() : undefined}
                       showReasoningSummaries={settings.general.showReasoningSummaries()}
                       showReasoningSummaries={settings.general.showReasoningSummaries()}
                       shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
                       shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
                       editToolDefaultOpen={settings.general.editToolPartsExpanded()}
                       editToolDefaultOpen={settings.general.editToolPartsExpanded()}

+ 8 - 28
packages/app/src/pages/session/review-tab.tsx

@@ -1,4 +1,4 @@
-import { createEffect, on, onCleanup, type JSX } from "solid-js"
+import { createEffect, onCleanup, type JSX } from "solid-js"
 import type { FileDiff } from "@opencode-ai/sdk/v2"
 import type { FileDiff } from "@opencode-ai/sdk/v2"
 import { SessionReview } from "@opencode-ai/ui/session-review"
 import { SessionReview } from "@opencode-ai/ui/session-review"
 import type {
 import type {
@@ -119,32 +119,12 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
     })
     })
   }
   }
 
 
-  createEffect(
-    on(
-      () => props.diffs().length,
-      () => queueRestore(),
-      { defer: true },
-    ),
-  )
-
-  createEffect(
-    on(
-      () => props.diffStyle,
-      () => queueRestore(),
-      { defer: true },
-    ),
-  )
-
-  createEffect(
-    on(
-      () => layout.ready(),
-      (ready) => {
-        if (!ready) return
-        queueRestore()
-      },
-      { defer: true },
-    ),
-  )
+  createEffect(() => {
+    props.diffs().length
+    props.diffStyle
+    if (!layout.ready()) return
+    queueRestore()
+  })
 
 
   onCleanup(() => {
   onCleanup(() => {
     if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
     if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
@@ -176,7 +156,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
       open={props.view().review.open()}
       open={props.view().review.open()}
       onOpenChange={props.view().review.setOpen}
       onOpenChange={props.view().review.setOpen}
       classes={{
       classes={{
-        root: props.classes?.root ?? "pb-6 pr-3",
+        root: props.classes?.root ?? "pr-3",
         header: props.classes?.header ?? "px-3",
         header: props.classes?.header ?? "px-3",
         container: props.classes?.container ?? "pl-3",
         container: props.classes?.container ?? "pl-3",
       }}
       }}

+ 3 - 3
packages/app/src/pages/session/terminal-panel.tsx

@@ -56,9 +56,9 @@ export function TerminalPanel() {
     on(
     on(
       () => terminal.all().length,
       () => terminal.all().length,
       (count, prevCount) => {
       (count, prevCount) => {
-        if (prevCount !== undefined && prevCount > 0 && count === 0) {
-          if (opened()) view().terminal.toggle()
-        }
+        if (prevCount === undefined || prevCount <= 0 || count !== 0) return
+        if (!opened()) return
+        close()
       },
       },
     ),
     ),
   )
   )

+ 26 - 16
packages/app/src/pages/session/use-session-hash-scroll.ts

@@ -1,4 +1,4 @@
-import { createEffect, createMemo, on, onCleanup } from "solid-js"
+import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
 import { UserMessage } from "@opencode-ai/sdk/v2"
 import { UserMessage } from "@opencode-ai/sdk/v2"
 
 
 export const messageIdFromHash = (hash: string) => {
 export const messageIdFromHash = (hash: string) => {
@@ -19,7 +19,6 @@ export const useSessionHashScroll = (input: {
   setPendingMessage: (value: string | undefined) => void
   setPendingMessage: (value: string | undefined) => void
   setActiveMessage: (message: UserMessage | undefined) => void
   setActiveMessage: (message: UserMessage | undefined) => void
   setTurnStart: (value: number) => void
   setTurnStart: (value: number) => void
-  scheduleTurnBackfill: () => void
   autoScroll: { pause: () => void; forceScrollToBottom: () => void }
   autoScroll: { pause: () => void; forceScrollToBottom: () => void }
   scroller: () => HTMLDivElement | undefined
   scroller: () => HTMLDivElement | undefined
   anchor: (id: string) => string
   anchor: (id: string) => string
@@ -29,6 +28,7 @@ export const useSessionHashScroll = (input: {
   const visibleUserMessages = createMemo(() => input.visibleUserMessages())
   const visibleUserMessages = createMemo(() => input.visibleUserMessages())
   const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
   const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
   const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
   const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
+  let pendingKey = ""
 
 
   const clearMessageHash = () => {
   const clearMessageHash = () => {
     if (!window.location.hash) return
     if (!window.location.hash) return
@@ -58,7 +58,6 @@ export const useSessionHashScroll = (input: {
     const index = messageIndex().get(message.id) ?? -1
     const index = messageIndex().get(message.id) ?? -1
     if (index !== -1 && index < input.turnStart()) {
     if (index !== -1 && index < input.turnStart()) {
       input.setTurnStart(index)
       input.setTurnStart(index)
-      input.scheduleTurnBackfill()
 
 
       requestAnimationFrame(() => {
       requestAnimationFrame(() => {
         const el = document.getElementById(input.anchor(message.id))
         const el = document.getElementById(input.anchor(message.id))
@@ -132,15 +131,6 @@ export const useSessionHashScroll = (input: {
     if (el) input.scheduleScrollState(el)
     if (el) input.scheduleScrollState(el)
   }
   }
 
 
-  createEffect(
-    on(input.sessionKey, (key) => {
-      if (!input.sessionID()) return
-      const messageID = input.consumePendingMessage(key)
-      if (!messageID) return
-      input.setPendingMessage(messageID)
-    }),
-  )
-
   createEffect(() => {
   createEffect(() => {
     if (!input.sessionID() || !input.messagesReady()) return
     if (!input.sessionID() || !input.messagesReady()) return
     requestAnimationFrame(() => applyHash("auto"))
     requestAnimationFrame(() => applyHash("auto"))
@@ -152,7 +142,20 @@ export const useSessionHashScroll = (input: {
     visibleUserMessages()
     visibleUserMessages()
     input.turnStart()
     input.turnStart()
 
 
-    const targetId = input.pendingMessage() ?? messageIdFromHash(window.location.hash)
+    let targetId = input.pendingMessage()
+    if (!targetId) {
+      const key = input.sessionKey()
+      if (pendingKey !== key) {
+        pendingKey = key
+        const next = input.consumePendingMessage(key)
+        if (next) {
+          input.setPendingMessage(next)
+          targetId = next
+        }
+      }
+    }
+
+    if (!targetId) targetId = messageIdFromHash(window.location.hash)
     if (!targetId) return
     if (!targetId) return
     if (input.currentMessageId() === targetId) return
     if (input.currentMessageId() === targetId) return
 
 
@@ -164,9 +167,16 @@ export const useSessionHashScroll = (input: {
     requestAnimationFrame(() => scrollToMessage(msg, "auto"))
     requestAnimationFrame(() => scrollToMessage(msg, "auto"))
   })
   })
 
 
-  createEffect(() => {
-    if (!input.sessionID() || !input.messagesReady()) return
-    const handler = () => requestAnimationFrame(() => applyHash("auto"))
+  onMount(() => {
+    if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
+      window.history.scrollRestoration = "manual"
+    }
+
+    const handler = () => {
+      if (!input.sessionID() || !input.messagesReady()) return
+      requestAnimationFrame(() => applyHash("auto"))
+    }
+
     window.addEventListener("hashchange", handler)
     window.addEventListener("hashchange", handler)
     onCleanup(() => window.removeEventListener("hashchange", handler))
     onCleanup(() => window.removeEventListener("hashchange", handler))
   })
   })

+ 19 - 18
packages/app/src/utils/notification-click.test.ts

@@ -1,26 +1,27 @@
-import { describe, expect, test } from "bun:test"
-import { handleNotificationClick } from "./notification-click"
+import { afterEach, describe, expect, test } from "bun:test"
+import { handleNotificationClick, setNavigate } from "./notification-click"
 
 
 describe("notification click", () => {
 describe("notification click", () => {
-  test("focuses and navigates when href exists", () => {
+  afterEach(() => {
+    setNavigate(undefined as any)
+  })
+
+  test("navigates via registered navigate function", () => {
     const calls: string[] = []
     const calls: string[] = []
-    handleNotificationClick("/abc/session/123", {
-      focus: () => calls.push("focus"),
-      location: {
-        assign: (href) => calls.push(href),
-      },
-    })
-    expect(calls).toEqual(["focus", "/abc/session/123"])
+    setNavigate((href) => calls.push(href))
+    handleNotificationClick("/abc/session/123")
+    expect(calls).toEqual(["/abc/session/123"])
   })
   })
 
 
-  test("only focuses when href is missing", () => {
+  test("does not navigate when href is missing", () => {
     const calls: string[] = []
     const calls: string[] = []
-    handleNotificationClick(undefined, {
-      focus: () => calls.push("focus"),
-      location: {
-        assign: (href) => calls.push(href),
-      },
-    })
-    expect(calls).toEqual(["focus"])
+    setNavigate((href) => calls.push(href))
+    handleNotificationClick(undefined)
+    expect(calls).toEqual([])
+  })
+
+  test("falls back to location.assign without registered navigate", () => {
+    handleNotificationClick("/abc/session/123")
+    // falls back to window.location.assign — no error thrown
   })
   })
 })
 })

+ 8 - 8
packages/app/src/utils/notification-click.ts

@@ -1,12 +1,12 @@
-type WindowTarget = {
-  focus: () => void
-  location: {
-    assign: (href: string) => void
-  }
+let nav: ((href: string) => void) | undefined
+
+export const setNavigate = (fn: (href: string) => void) => {
+  nav = fn
 }
 }
 
 
-export const handleNotificationClick = (href?: string, target: WindowTarget = window) => {
-  target.focus()
+export const handleNotificationClick = (href?: string) => {
+  window.focus()
   if (!href) return
   if (!href) return
-  target.location.assign(href)
+  if (nav) nav(href)
+  else window.location.assign(href)
 }
 }

+ 1 - 1
packages/console/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/console-app",
   "name": "@opencode-ai/console-app",
-  "version": "1.2.15",
+  "version": "1.2.16",
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {

+ 14 - 5
packages/console/app/src/app.tsx

@@ -7,9 +7,21 @@ import { Font } from "@opencode-ai/ui/font"
 import "@ibm/plex/css/ibm-plex.css"
 import "@ibm/plex/css/ibm-plex.css"
 import "./app.css"
 import "./app.css"
 import { LanguageProvider } from "~/context/language"
 import { LanguageProvider } from "~/context/language"
-import { I18nProvider } from "~/context/i18n"
+import { I18nProvider, useI18n } from "~/context/i18n"
 import { strip } from "~/lib/language"
 import { strip } from "~/lib/language"
 
 
+function AppMeta() {
+  const i18n = useI18n()
+  return (
+    <>
+      <Title>opencode</Title>
+      <Meta name="description" content={i18n.t("app.meta.description")} />
+      <Favicon />
+      <Font />
+    </>
+  )
+}
+
 export default function App() {
 export default function App() {
   return (
   return (
     <Router
     <Router
@@ -19,10 +31,7 @@ export default function App() {
         <LanguageProvider>
         <LanguageProvider>
           <I18nProvider>
           <I18nProvider>
             <MetaProvider>
             <MetaProvider>
-              <Title>opencode</Title>
-              <Meta name="description" content="OpenCode - The open source coding agent." />
-              <Favicon />
-              <Font />
+              <AppMeta />
               <Suspense>{props.children}</Suspense>
               <Suspense>{props.children}</Suspense>
             </MetaProvider>
             </MetaProvider>
           </I18nProvider>
           </I18nProvider>

+ 2 - 2
packages/console/app/src/component/header.tsx

@@ -124,8 +124,8 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
     <section data-component="top">
     <section data-component="top">
       <div onContextMenu={handleLogoContextMenu}>
       <div onContextMenu={handleLogoContextMenu}>
         <A href={language.route("/")}>
         <A href={language.route("/")}>
-          <img data-slot="logo light" src={logoLight} alt="OpenCode" width="189" height="34" />
-          <img data-slot="logo dark" src={logoDark} alt="OpenCode" width="189" height="34" />
+          <img data-slot="logo light" src={logoLight} alt={i18n.t("nav.logoAlt")} width="189" height="34" />
+          <img data-slot="logo dark" src={logoDark} alt={i18n.t("nav.logoAlt")} width="189" height="34" />
         </A>
         </A>
       </div>
       </div>
 
 

+ 33 - 0
packages/console/app/src/i18n/ar.ts

@@ -15,6 +15,7 @@ export const dict = {
   "nav.home": "الرئيسية",
   "nav.home": "الرئيسية",
   "nav.openMenu": "فتح القائمة",
   "nav.openMenu": "فتح القائمة",
   "nav.getStartedFree": "ابدأ مجانا",
   "nav.getStartedFree": "ابدأ مجانا",
+  "nav.logoAlt": "OpenCode",
 
 
   "nav.context.copyLogo": "نسخ الشعار كـ SVG",
   "nav.context.copyLogo": "نسخ الشعار كـ SVG",
   "nav.context.copyWordmark": "نسخ اسم العلامة كـ SVG",
   "nav.context.copyWordmark": "نسخ اسم العلامة كـ SVG",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "الوثائق",
   "notFound.docs": "الوثائق",
   "notFound.github": "GitHub",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "شعار opencode الفاتح",
+  "notFound.logoDarkAlt": "شعار opencode الداكن",
 
 
   "user.logout": "تسجيل الخروج",
   "user.logout": "تسجيل الخروج",
 
 
+  "auth.callback.error.codeMissing": "لم يتم العثور على رمز التفويض.",
+
   "workspace.select": "اختر مساحة العمل",
   "workspace.select": "اختر مساحة العمل",
   "workspace.createNew": "+ إنشاء مساحة عمل جديدة",
   "workspace.createNew": "+ إنشاء مساحة عمل جديدة",
   "workspace.modal.title": "إنشاء مساحة عمل جديدة",
   "workspace.modal.title": "إنشاء مساحة عمل جديدة",
@@ -76,6 +81,8 @@ export const dict = {
   "error.reloadAmountMin": "يجب أن يكون مبلغ الشحن ${{amount}} على الأقل",
   "error.reloadAmountMin": "يجب أن يكون مبلغ الشحن ${{amount}} على الأقل",
   "error.reloadTriggerMin": "يجب أن يكون حد الرصيد ${{amount}} على الأقل",
   "error.reloadTriggerMin": "يجب أن يكون حد الرصيد ${{amount}} على الأقل",
 
 
+  "app.meta.description": "OpenCode - وكيل البرمجة مفتوح المصدر.",
+
   "home.title": "OpenCode | وكيل برمجة بالذكاء الاصطناعي مفتوح المصدر",
   "home.title": "OpenCode | وكيل برمجة بالذكاء الاصطناعي مفتوح المصدر",
 
 
   "temp.title": "opencode | وكيل برمجة بالذكاء الاصطناعي مبني للطرفية",
   "temp.title": "opencode | وكيل برمجة بالذكاء الاصطناعي مبني للطرفية",
@@ -91,6 +98,8 @@ export const dict = {
   "temp.feature.models.afterLink": "، بما في ذلك النماذج المحلية",
   "temp.feature.models.afterLink": "، بما في ذلك النماذج المحلية",
   "temp.screenshot.caption": "واجهة OpenCode الطرفية مع سمة tokyonight",
   "temp.screenshot.caption": "واجهة OpenCode الطرفية مع سمة tokyonight",
   "temp.screenshot.alt": "واجهة OpenCode الطرفية بسمة tokyonight",
   "temp.screenshot.alt": "واجهة OpenCode الطرفية بسمة tokyonight",
+  "temp.logoLightAlt": "شعار opencode الفاتح",
+  "temp.logoDarkAlt": "شعار opencode الداكن",
 
 
   "home.banner.badge": "جديد",
   "home.banner.badge": "جديد",
   "home.banner.text": "تطبيق سطح المكتب متاح بنسخة تجريبية",
   "home.banner.text": "تطبيق سطح المكتب متاح بنسخة تجريبية",
@@ -238,6 +247,24 @@ export const dict = {
     "تتم استضافة جميع نماذج Zen في الولايات المتحدة. يتبع المزودون سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج، مع",
     "تتم استضافة جميع نماذج Zen في الولايات المتحدة. يتبع المزودون سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج، مع",
   "zen.privacy.exceptionsLink": "الاستثناءات التالية",
   "zen.privacy.exceptionsLink": "الاستثناءات التالية",
 
 
+  "zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.",
+  "zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم",
+  "zen.api.error.modelFormatNotSupported": "النموذج {{model}} غير مدعوم للتنسيق {{format}}",
+  "zen.api.error.noProviderAvailable": "لا يوجد مزود متاح",
+  "zen.api.error.providerNotSupported": "المزود {{provider}} غير مدعوم",
+  "zen.api.error.missingApiKey": "مفتاح API مفقود.",
+  "zen.api.error.invalidApiKey": "مفتاح API غير صالح.",
+  "zen.api.error.subscriptionQuotaExceeded": "تم تجاوز حصة الاشتراك. أعد المحاولة خلال {{retryIn}}.",
+  "zen.api.error.subscriptionQuotaExceededUseFreeModels":
+    "تم تجاوز حصة الاشتراك. يمكنك الاستمرار في استخدام النماذج المجانية.",
+  "zen.api.error.noPaymentMethod": "لا توجد طريقة دفع. أضف طريقة دفع هنا: {{billingUrl}}",
+  "zen.api.error.insufficientBalance": "رصيد غير كاف. إدارة فواتيرك هنا: {{billingUrl}}",
+  "zen.api.error.workspaceMonthlyLimitReached":
+    "وصلت مساحة العمل الخاصة بك إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{billingUrl}}",
+  "zen.api.error.userMonthlyLimitReached":
+    "لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}",
+  "zen.api.error.modelDisabled": "النموذج معطل",
+
   "black.meta.title": "OpenCode Black | الوصول إلى أفضل نماذج البرمجة في العالم",
   "black.meta.title": "OpenCode Black | الوصول إلى أفضل نماذج البرمجة في العالم",
   "black.meta.description": "احصل على وصول إلى Claude، GPT، Gemini والمزيد مع خطط اشتراك OpenCode Black.",
   "black.meta.description": "احصل على وصول إلى Claude، GPT، Gemini والمزيد مع خطط اشتراك OpenCode Black.",
   "black.hero.title": "الوصول إلى أفضل نماذج البرمجة في العالم",
   "black.hero.title": "الوصول إلى أفضل نماذج البرمجة في العالم",
@@ -446,6 +473,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "يرجى تحديث طريقة الدفع والمحاولة مرة أخرى.",
   "workspace.reload.updatePaymentMethod": "يرجى تحديث طريقة الدفع والمحاولة مرة أخرى.",
   "workspace.reload.retrying": "جارٍ إعادة المحاولة...",
   "workspace.reload.retrying": "جارٍ إعادة المحاولة...",
   "workspace.reload.retry": "أعد المحاولة",
   "workspace.reload.retry": "أعد المحاولة",
+  "workspace.reload.error.paymentFailed": "فشلت عملية الدفع.",
 
 
   "workspace.payments.title": "سجل المدفوعات",
   "workspace.payments.title": "سجل المدفوعات",
   "workspace.payments.subtitle": "معاملات الدفع الأخيرة.",
   "workspace.payments.subtitle": "معاملات الدفع الأخيرة.",
@@ -563,6 +591,10 @@ export const dict = {
   "enterprise.form.send": "إرسال",
   "enterprise.form.send": "إرسال",
   "enterprise.form.sending": "جارٍ الإرسال...",
   "enterprise.form.sending": "جارٍ الإرسال...",
   "enterprise.form.success": "تم إرسال الرسالة، سنتواصل معك قريبًا.",
   "enterprise.form.success": "تم إرسال الرسالة، سنتواصل معك قريبًا.",
+  "enterprise.form.success.submitted": "تم إرسال النموذج بنجاح.",
+  "enterprise.form.error.allFieldsRequired": "جميع الحقول مطلوبة.",
+  "enterprise.form.error.invalidEmailFormat": "تنسيق البريد الإلكتروني غير صالح.",
+  "enterprise.form.error.internalServer": "خطأ داخلي في الخادم.",
   "enterprise.faq.title": "الأسئلة الشائعة",
   "enterprise.faq.title": "الأسئلة الشائعة",
   "enterprise.faq.q1": "ما هو OpenCode Enterprise؟",
   "enterprise.faq.q1": "ما هو OpenCode Enterprise؟",
   "enterprise.faq.a1":
   "enterprise.faq.a1":
@@ -595,6 +627,7 @@ export const dict = {
   "bench.list.table.agent": "الوكيل",
   "bench.list.table.agent": "الوكيل",
   "bench.list.table.model": "النموذج",
   "bench.list.table.model": "النموذج",
   "bench.list.table.score": "الدرجة",
   "bench.list.table.score": "الدرجة",
+  "bench.submission.error.allFieldsRequired": "جميع الحقول مطلوبة.",
 
 
   "bench.detail.title": "المعيار - {{task}}",
   "bench.detail.title": "المعيار - {{task}}",
   "bench.detail.notFound": "المهمة غير موجودة",
   "bench.detail.notFound": "المهمة غير موجودة",

+ 33 - 0
packages/console/app/src/i18n/br.ts

@@ -15,6 +15,7 @@ export const dict = {
   "nav.home": "Início",
   "nav.home": "Início",
   "nav.openMenu": "Abrir menu",
   "nav.openMenu": "Abrir menu",
   "nav.getStartedFree": "Começar grátis",
   "nav.getStartedFree": "Começar grátis",
+  "nav.logoAlt": "OpenCode",
 
 
   "nav.context.copyLogo": "Copiar logo como SVG",
   "nav.context.copyLogo": "Copiar logo como SVG",
   "nav.context.copyWordmark": "Copiar marca como SVG",
   "nav.context.copyWordmark": "Copiar marca como SVG",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "Documentação",
   "notFound.docs": "Documentação",
   "notFound.github": "GitHub",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "logo opencode claro",
+  "notFound.logoDarkAlt": "logo opencode escuro",
 
 
   "user.logout": "Sair",
   "user.logout": "Sair",
 
 
+  "auth.callback.error.codeMissing": "Nenhum código de autorização encontrado.",
+
   "workspace.select": "Selecionar workspace",
   "workspace.select": "Selecionar workspace",
   "workspace.createNew": "+ Criar novo workspace",
   "workspace.createNew": "+ Criar novo workspace",
   "workspace.modal.title": "Criar novo workspace",
   "workspace.modal.title": "Criar novo workspace",
@@ -76,6 +81,8 @@ export const dict = {
   "error.reloadAmountMin": "O valor de recarga deve ser de pelo menos ${{amount}}",
   "error.reloadAmountMin": "O valor de recarga deve ser de pelo menos ${{amount}}",
   "error.reloadTriggerMin": "O gatilho de saldo deve ser de pelo menos ${{amount}}",
   "error.reloadTriggerMin": "O gatilho de saldo deve ser de pelo menos ${{amount}}",
 
 
+  "app.meta.description": "OpenCode - O agente de codificação de código aberto.",
+
   "home.title": "OpenCode | O agente de codificação de código aberto com IA",
   "home.title": "OpenCode | O agente de codificação de código aberto com IA",
 
 
   "temp.title": "opencode | Agente de codificação com IA feito para o terminal",
   "temp.title": "opencode | Agente de codificação com IA feito para o terminal",
@@ -91,6 +98,8 @@ export const dict = {
   "temp.feature.models.afterLink": ", incluindo modelos locais",
   "temp.feature.models.afterLink": ", incluindo modelos locais",
   "temp.screenshot.caption": "OpenCode TUI com o tema tokyonight",
   "temp.screenshot.caption": "OpenCode TUI com o tema tokyonight",
   "temp.screenshot.alt": "OpenCode TUI com tema tokyonight",
   "temp.screenshot.alt": "OpenCode TUI com tema tokyonight",
+  "temp.logoLightAlt": "logo opencode claro",
+  "temp.logoDarkAlt": "logo opencode escuro",
 
 
   "home.banner.badge": "Novo",
   "home.banner.badge": "Novo",
   "home.banner.text": "App desktop disponível em beta",
   "home.banner.text": "App desktop disponível em beta",
@@ -242,6 +251,24 @@ export const dict = {
     "Todos os modelos Zen são hospedados nos EUA. Os provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelo, com as",
     "Todos os modelos Zen são hospedados nos EUA. Os provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelo, com as",
   "zen.privacy.exceptionsLink": "seguintes exceções",
   "zen.privacy.exceptionsLink": "seguintes exceções",
 
 
+  "zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.",
+  "zen.api.error.modelNotSupported": "Modelo {{model}} não suportado",
+  "zen.api.error.modelFormatNotSupported": "Modelo {{model}} não suportado para o formato {{format}}",
+  "zen.api.error.noProviderAvailable": "Nenhum provedor disponível",
+  "zen.api.error.providerNotSupported": "Provedor {{provider}} não suportado",
+  "zen.api.error.missingApiKey": "Chave de API ausente.",
+  "zen.api.error.invalidApiKey": "Chave de API inválida.",
+  "zen.api.error.subscriptionQuotaExceeded": "Cota de assinatura excedida. Tente novamente em {{retryIn}}.",
+  "zen.api.error.subscriptionQuotaExceededUseFreeModels":
+    "Cota de assinatura excedida. Você pode continuar usando modelos gratuitos.",
+  "zen.api.error.noPaymentMethod": "Nenhuma forma de pagamento. Adicione uma forma de pagamento aqui: {{billingUrl}}",
+  "zen.api.error.insufficientBalance": "Saldo insuficiente. Gerencie seu faturamento aqui: {{billingUrl}}",
+  "zen.api.error.workspaceMonthlyLimitReached":
+    "Seu workspace atingiu o limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{billingUrl}}",
+  "zen.api.error.userMonthlyLimitReached":
+    "Você atingiu seu limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{membersUrl}}",
+  "zen.api.error.modelDisabled": "O modelo está desabilitado",
+
   "black.meta.title": "OpenCode Black | Acesse os melhores modelos de codificação do mundo",
   "black.meta.title": "OpenCode Black | Acesse os melhores modelos de codificação do mundo",
   "black.meta.description": "Tenha acesso ao Claude, GPT, Gemini e mais com os planos de assinatura OpenCode Black.",
   "black.meta.description": "Tenha acesso ao Claude, GPT, Gemini e mais com os planos de assinatura OpenCode Black.",
   "black.hero.title": "Acesse os melhores modelos de codificação do mundo",
   "black.hero.title": "Acesse os melhores modelos de codificação do mundo",
@@ -451,6 +478,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "Por favor, atualize sua forma de pagamento e tente novamente.",
   "workspace.reload.updatePaymentMethod": "Por favor, atualize sua forma de pagamento e tente novamente.",
   "workspace.reload.retrying": "Tentando novamente...",
   "workspace.reload.retrying": "Tentando novamente...",
   "workspace.reload.retry": "Tentar novamente",
   "workspace.reload.retry": "Tentar novamente",
+  "workspace.reload.error.paymentFailed": "Pagamento falhou.",
 
 
   "workspace.payments.title": "Histórico de Pagamentos",
   "workspace.payments.title": "Histórico de Pagamentos",
   "workspace.payments.subtitle": "Transações de pagamento recentes.",
   "workspace.payments.subtitle": "Transações de pagamento recentes.",
@@ -571,6 +599,10 @@ export const dict = {
   "enterprise.form.send": "Enviar",
   "enterprise.form.send": "Enviar",
   "enterprise.form.sending": "Enviando...",
   "enterprise.form.sending": "Enviando...",
   "enterprise.form.success": "Mensagem enviada, entraremos em contato em breve.",
   "enterprise.form.success": "Mensagem enviada, entraremos em contato em breve.",
+  "enterprise.form.success.submitted": "Formulário enviado com sucesso.",
+  "enterprise.form.error.allFieldsRequired": "Todos os campos são obrigatórios.",
+  "enterprise.form.error.invalidEmailFormat": "Formato de e-mail inválido.",
+  "enterprise.form.error.internalServer": "Erro interno do servidor.",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.q1": "O que é OpenCode Enterprise?",
   "enterprise.faq.q1": "O que é OpenCode Enterprise?",
   "enterprise.faq.a1":
   "enterprise.faq.a1":
@@ -603,6 +635,7 @@ export const dict = {
   "bench.list.table.agent": "Agente",
   "bench.list.table.agent": "Agente",
   "bench.list.table.model": "Modelo",
   "bench.list.table.model": "Modelo",
   "bench.list.table.score": "Pontuação",
   "bench.list.table.score": "Pontuação",
+  "bench.submission.error.allFieldsRequired": "Todos os campos são obrigatórios.",
 
 
   "bench.detail.title": "Benchmark - {{task}}",
   "bench.detail.title": "Benchmark - {{task}}",
   "bench.detail.notFound": "Tarefa não encontrada",
   "bench.detail.notFound": "Tarefa não encontrada",

+ 33 - 0
packages/console/app/src/i18n/da.ts

@@ -15,6 +15,7 @@ export const dict = {
   "nav.home": "Hjem",
   "nav.home": "Hjem",
   "nav.openMenu": "Åbn menu",
   "nav.openMenu": "Åbn menu",
   "nav.getStartedFree": "Kom i gang gratis",
   "nav.getStartedFree": "Kom i gang gratis",
+  "nav.logoAlt": "OpenCode",
 
 
   "nav.context.copyLogo": "Kopier logo som SVG",
   "nav.context.copyLogo": "Kopier logo som SVG",
   "nav.context.copyWordmark": "Kopier wordmark som SVG",
   "nav.context.copyWordmark": "Kopier wordmark som SVG",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "Dokumentation",
   "notFound.docs": "Dokumentation",
   "notFound.github": "GitHub",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "opencode logo light",
+  "notFound.logoDarkAlt": "opencode logo dark",
 
 
   "user.logout": "Log ud",
   "user.logout": "Log ud",
 
 
+  "auth.callback.error.codeMissing": "Ingen autorisationskode fundet.",
+
   "workspace.select": "Vælg workspace",
   "workspace.select": "Vælg workspace",
   "workspace.createNew": "+ Opret nyt workspace",
   "workspace.createNew": "+ Opret nyt workspace",
   "workspace.modal.title": "Opret nyt workspace",
   "workspace.modal.title": "Opret nyt workspace",
@@ -76,6 +81,8 @@ export const dict = {
   "error.reloadAmountMin": "Genopfyldningsbeløb skal være mindst ${{amount}}",
   "error.reloadAmountMin": "Genopfyldningsbeløb skal være mindst ${{amount}}",
   "error.reloadTriggerMin": "Saldogrænse skal være mindst ${{amount}}",
   "error.reloadTriggerMin": "Saldogrænse skal være mindst ${{amount}}",
 
 
+  "app.meta.description": "OpenCode - Den open source kodningsagent.",
+
   "home.title": "OpenCode | Den open source AI-kodningsagent",
   "home.title": "OpenCode | Den open source AI-kodningsagent",
 
 
   "temp.title": "opencode | AI-kodningsagent bygget til terminalen",
   "temp.title": "opencode | AI-kodningsagent bygget til terminalen",
@@ -91,6 +98,8 @@ export const dict = {
   "temp.feature.models.afterLink": ", inklusive lokale modeller",
   "temp.feature.models.afterLink": ", inklusive lokale modeller",
   "temp.screenshot.caption": "opencode TUI med tokyonight-temaet",
   "temp.screenshot.caption": "opencode TUI med tokyonight-temaet",
   "temp.screenshot.alt": "opencode TUI med tokyonight-temaet",
   "temp.screenshot.alt": "opencode TUI med tokyonight-temaet",
+  "temp.logoLightAlt": "opencode logo light",
+  "temp.logoDarkAlt": "opencode logo dark",
 
 
   "home.banner.badge": "Ny",
   "home.banner.badge": "Ny",
   "home.banner.text": "Desktop-app tilgængelig i beta",
   "home.banner.text": "Desktop-app tilgængelig i beta",
@@ -240,6 +249,24 @@ export const dict = {
     "Alle Zen-modeller er hostet i USA. Udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning med",
     "Alle Zen-modeller er hostet i USA. Udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning med",
   "zen.privacy.exceptionsLink": "følgende undtagelser",
   "zen.privacy.exceptionsLink": "følgende undtagelser",
 
 
+  "zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.",
+  "zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke",
+  "zen.api.error.modelFormatNotSupported": "Model {{model}} understøttes ikke for format {{format}}",
+  "zen.api.error.noProviderAvailable": "Ingen udbyder tilgængelig",
+  "zen.api.error.providerNotSupported": "Udbyder {{provider}} understøttes ikke",
+  "zen.api.error.missingApiKey": "Manglende API-nøgle.",
+  "zen.api.error.invalidApiKey": "Ugyldig API-nøgle.",
+  "zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igen om {{retryIn}}.",
+  "zen.api.error.subscriptionQuotaExceededUseFreeModels":
+    "Abonnementskvote overskredet. Du kan fortsætte med at bruge gratis modeller.",
+  "zen.api.error.noPaymentMethod": "Ingen betalingsmetode. Tilføj en betalingsmetode her: {{billingUrl}}",
+  "zen.api.error.insufficientBalance": "Utilstrækkelig saldo. Administrer din fakturering her: {{billingUrl}}",
+  "zen.api.error.workspaceMonthlyLimitReached":
+    "Dit workspace har nået sin månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{billingUrl}}",
+  "zen.api.error.userMonthlyLimitReached":
+    "Du har nået din månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{membersUrl}}",
+  "zen.api.error.modelDisabled": "Modellen er deaktiveret",
+
   "black.meta.title": "OpenCode Black | Få adgang til verdens bedste kodningsmodeller",
   "black.meta.title": "OpenCode Black | Få adgang til verdens bedste kodningsmodeller",
   "black.meta.description": "Få adgang til Claude, GPT, Gemini og mere med OpenCode Black-abonnementer.",
   "black.meta.description": "Få adgang til Claude, GPT, Gemini og mere med OpenCode Black-abonnementer.",
   "black.hero.title": "Få adgang til verdens bedste kodningsmodeller",
   "black.hero.title": "Få adgang til verdens bedste kodningsmodeller",
@@ -449,6 +476,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "Opdater din betalingsmetode, og prøv igen.",
   "workspace.reload.updatePaymentMethod": "Opdater din betalingsmetode, og prøv igen.",
   "workspace.reload.retrying": "Prøver igen...",
   "workspace.reload.retrying": "Prøver igen...",
   "workspace.reload.retry": "Prøv igen",
   "workspace.reload.retry": "Prøv igen",
+  "workspace.reload.error.paymentFailed": "Betaling mislykkedes.",
 
 
   "workspace.payments.title": "Betalingshistorik",
   "workspace.payments.title": "Betalingshistorik",
   "workspace.payments.subtitle": "Seneste betalingstransaktioner.",
   "workspace.payments.subtitle": "Seneste betalingstransaktioner.",
@@ -567,6 +595,10 @@ export const dict = {
   "enterprise.form.send": "Send",
   "enterprise.form.send": "Send",
   "enterprise.form.sending": "Sender...",
   "enterprise.form.sending": "Sender...",
   "enterprise.form.success": "Besked sendt, vi vender tilbage snart.",
   "enterprise.form.success": "Besked sendt, vi vender tilbage snart.",
+  "enterprise.form.success.submitted": "Formular indsendt med succes.",
+  "enterprise.form.error.allFieldsRequired": "Alle felter er påkrævet.",
+  "enterprise.form.error.invalidEmailFormat": "Ugyldigt e-mailformat.",
+  "enterprise.form.error.internalServer": "Intern serverfejl.",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.q1": "Hvad er OpenCode Enterprise?",
   "enterprise.faq.q1": "Hvad er OpenCode Enterprise?",
   "enterprise.faq.a1":
   "enterprise.faq.a1":
@@ -599,6 +631,7 @@ export const dict = {
   "bench.list.table.agent": "Agent",
   "bench.list.table.agent": "Agent",
   "bench.list.table.model": "Model",
   "bench.list.table.model": "Model",
   "bench.list.table.score": "Score",
   "bench.list.table.score": "Score",
+  "bench.submission.error.allFieldsRequired": "Alle felter er påkrævet.",
 
 
   "bench.detail.title": "Benchmark - {{task}}",
   "bench.detail.title": "Benchmark - {{task}}",
   "bench.detail.notFound": "Opgave ikke fundet",
   "bench.detail.notFound": "Opgave ikke fundet",

+ 33 - 0
packages/console/app/src/i18n/de.ts

@@ -15,6 +15,7 @@ export const dict = {
   "nav.home": "Startseite",
   "nav.home": "Startseite",
   "nav.openMenu": "Menü öffnen",
   "nav.openMenu": "Menü öffnen",
   "nav.getStartedFree": "Kostenlos starten",
   "nav.getStartedFree": "Kostenlos starten",
+  "nav.logoAlt": "OpenCode",
 
 
   "nav.context.copyLogo": "Logo als SVG kopieren",
   "nav.context.copyLogo": "Logo als SVG kopieren",
   "nav.context.copyWordmark": "Wortmarke als SVG kopieren",
   "nav.context.copyWordmark": "Wortmarke als SVG kopieren",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "Dokumentation",
   "notFound.docs": "Dokumentation",
   "notFound.github": "GitHub",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "OpenCode Logo hell",
+  "notFound.logoDarkAlt": "OpenCode Logo dunkel",
 
 
   "user.logout": "Abmelden",
   "user.logout": "Abmelden",
 
 
+  "auth.callback.error.codeMissing": "Kein Autorisierungscode gefunden.",
+
   "workspace.select": "Workspace auswählen",
   "workspace.select": "Workspace auswählen",
   "workspace.createNew": "+ Neuen Workspace erstellen",
   "workspace.createNew": "+ Neuen Workspace erstellen",
   "workspace.modal.title": "Neuen Workspace erstellen",
   "workspace.modal.title": "Neuen Workspace erstellen",
@@ -76,6 +81,8 @@ export const dict = {
   "error.reloadAmountMin": "Aufladebetrag muss mindestens ${{amount}} betragen",
   "error.reloadAmountMin": "Aufladebetrag muss mindestens ${{amount}} betragen",
   "error.reloadTriggerMin": "Guthaben-Auslöser muss mindestens ${{amount}} betragen",
   "error.reloadTriggerMin": "Guthaben-Auslöser muss mindestens ${{amount}} betragen",
 
 
+  "app.meta.description": "OpenCode - Der Open-Source Coding-Agent.",
+
   "home.title": "OpenCode | Der Open-Source AI-Coding-Agent",
   "home.title": "OpenCode | Der Open-Source AI-Coding-Agent",
 
 
   "temp.title": "OpenCode | Für das Terminal gebauter AI-Coding-Agent",
   "temp.title": "OpenCode | Für das Terminal gebauter AI-Coding-Agent",
@@ -91,6 +98,8 @@ export const dict = {
   "temp.feature.models.afterLink": ", einschließlich lokaler Modelle",
   "temp.feature.models.afterLink": ", einschließlich lokaler Modelle",
   "temp.screenshot.caption": "OpenCode TUI mit dem Tokyonight-Theme",
   "temp.screenshot.caption": "OpenCode TUI mit dem Tokyonight-Theme",
   "temp.screenshot.alt": "OpenCode TUI mit Tokyonight-Theme",
   "temp.screenshot.alt": "OpenCode TUI mit Tokyonight-Theme",
+  "temp.logoLightAlt": "OpenCode Logo hell",
+  "temp.logoDarkAlt": "OpenCode Logo dunkel",
 
 
   "home.banner.badge": "Neu",
   "home.banner.badge": "Neu",
   "home.banner.text": "Desktop-App in der Beta verfügbar",
   "home.banner.text": "Desktop-App in der Beta verfügbar",
@@ -242,6 +251,24 @@ export const dict = {
     "Alle Zen-Modelle werden in den USA gehostet. Anbieter folgen einer Zero-Retention-Policy und nutzen deine Daten nicht für Modelltraining, mit den",
     "Alle Zen-Modelle werden in den USA gehostet. Anbieter folgen einer Zero-Retention-Policy und nutzen deine Daten nicht für Modelltraining, mit den",
   "zen.privacy.exceptionsLink": "folgenden Ausnahmen",
   "zen.privacy.exceptionsLink": "folgenden Ausnahmen",
 
 
+  "zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.",
+  "zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt",
+  "zen.api.error.modelFormatNotSupported": "Modell {{model}} wird für das Format {{format}} nicht unterstützt",
+  "zen.api.error.noProviderAvailable": "Kein Anbieter verfügbar",
+  "zen.api.error.providerNotSupported": "Anbieter {{provider}} wird nicht unterstützt",
+  "zen.api.error.missingApiKey": "Fehlender API-Key.",
+  "zen.api.error.invalidApiKey": "Ungültiger API-Key.",
+  "zen.api.error.subscriptionQuotaExceeded": "Abonnement-Quote überschritten. Erneuter Versuch in {{retryIn}}.",
+  "zen.api.error.subscriptionQuotaExceededUseFreeModels":
+    "Abonnement-Quote überschritten. Du kannst weiterhin kostenlose Modelle nutzen.",
+  "zen.api.error.noPaymentMethod": "Keine Zahlungsmethode. Füge hier eine Zahlungsmethode hinzu: {{billingUrl}}",
+  "zen.api.error.insufficientBalance": "Unzureichendes Guthaben. Verwalte deine Abrechnung hier: {{billingUrl}}",
+  "zen.api.error.workspaceMonthlyLimitReached":
+    "Dein Workspace hat sein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{billingUrl}}",
+  "zen.api.error.userMonthlyLimitReached":
+    "Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}",
+  "zen.api.error.modelDisabled": "Modell ist deaktiviert",
+
   "black.meta.title": "OpenCode Black | Zugriff auf die weltweit besten Coding-Modelle",
   "black.meta.title": "OpenCode Black | Zugriff auf die weltweit besten Coding-Modelle",
   "black.meta.description": "Erhalte Zugriff auf Claude, GPT, Gemini und mehr mit OpenCode Black Abos.",
   "black.meta.description": "Erhalte Zugriff auf Claude, GPT, Gemini und mehr mit OpenCode Black Abos.",
   "black.hero.title": "Zugriff auf die weltweit besten Coding-Modelle",
   "black.hero.title": "Zugriff auf die weltweit besten Coding-Modelle",
@@ -451,6 +478,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "Bitte aktualisiere deine Zahlungsmethode und versuche es erneut.",
   "workspace.reload.updatePaymentMethod": "Bitte aktualisiere deine Zahlungsmethode und versuche es erneut.",
   "workspace.reload.retrying": "Versuche erneut...",
   "workspace.reload.retrying": "Versuche erneut...",
   "workspace.reload.retry": "Erneut versuchen",
   "workspace.reload.retry": "Erneut versuchen",
+  "workspace.reload.error.paymentFailed": "Zahlung fehlgeschlagen.",
 
 
   "workspace.payments.title": "Zahlungshistorie",
   "workspace.payments.title": "Zahlungshistorie",
   "workspace.payments.subtitle": "Kürzliche Zahlungstransaktionen.",
   "workspace.payments.subtitle": "Kürzliche Zahlungstransaktionen.",
@@ -571,6 +599,10 @@ export const dict = {
   "enterprise.form.send": "Senden",
   "enterprise.form.send": "Senden",
   "enterprise.form.sending": "Sende...",
   "enterprise.form.sending": "Sende...",
   "enterprise.form.success": "Nachricht gesendet, wir melden uns bald.",
   "enterprise.form.success": "Nachricht gesendet, wir melden uns bald.",
+  "enterprise.form.success.submitted": "Formular erfolgreich gesendet.",
+  "enterprise.form.error.allFieldsRequired": "Alle Felder sind erforderlich.",
+  "enterprise.form.error.invalidEmailFormat": "Ungültiges E-Mail-Format.",
+  "enterprise.form.error.internalServer": "Interner Serverfehler.",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.q1": "Was ist OpenCode Enterprise?",
   "enterprise.faq.q1": "Was ist OpenCode Enterprise?",
   "enterprise.faq.a1":
   "enterprise.faq.a1":
@@ -603,6 +635,7 @@ export const dict = {
   "bench.list.table.agent": "Agent",
   "bench.list.table.agent": "Agent",
   "bench.list.table.model": "Modell",
   "bench.list.table.model": "Modell",
   "bench.list.table.score": "Score",
   "bench.list.table.score": "Score",
+  "bench.submission.error.allFieldsRequired": "Alle Felder sind erforderlich.",
 
 
   "bench.detail.title": "Benchmark - {{task}}",
   "bench.detail.title": "Benchmark - {{task}}",
   "bench.detail.notFound": "Task nicht gefunden",
   "bench.detail.notFound": "Task nicht gefunden",

+ 33 - 0
packages/console/app/src/i18n/en.ts

@@ -11,6 +11,7 @@ export const dict = {
   "nav.home": "Home",
   "nav.home": "Home",
   "nav.openMenu": "Open menu",
   "nav.openMenu": "Open menu",
   "nav.getStartedFree": "Get started for free",
   "nav.getStartedFree": "Get started for free",
+  "nav.logoAlt": "OpenCode",
 
 
   "nav.context.copyLogo": "Copy logo as SVG",
   "nav.context.copyLogo": "Copy logo as SVG",
   "nav.context.copyWordmark": "Copy wordmark as SVG",
   "nav.context.copyWordmark": "Copy wordmark as SVG",
@@ -38,9 +39,13 @@ export const dict = {
   "notFound.docs": "Docs",
   "notFound.docs": "Docs",
   "notFound.github": "GitHub",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "opencode logo light",
+  "notFound.logoDarkAlt": "opencode logo dark",
 
 
   "user.logout": "Logout",
   "user.logout": "Logout",
 
 
+  "auth.callback.error.codeMissing": "No authorization code found.",
+
   "workspace.select": "Select workspace",
   "workspace.select": "Select workspace",
   "workspace.createNew": "+ Create New Workspace",
   "workspace.createNew": "+ Create New Workspace",
   "workspace.modal.title": "Create New Workspace",
   "workspace.modal.title": "Create New Workspace",
@@ -72,6 +77,8 @@ export const dict = {
   "error.reloadAmountMin": "Reload amount must be at least ${{amount}}",
   "error.reloadAmountMin": "Reload amount must be at least ${{amount}}",
   "error.reloadTriggerMin": "Balance trigger must be at least ${{amount}}",
   "error.reloadTriggerMin": "Balance trigger must be at least ${{amount}}",
 
 
+  "app.meta.description": "OpenCode - The open source coding agent.",
+
   "home.title": "OpenCode | The open source AI coding agent",
   "home.title": "OpenCode | The open source AI coding agent",
 
 
   "temp.title": "opencode | AI coding agent built for the terminal",
   "temp.title": "opencode | AI coding agent built for the terminal",
@@ -87,6 +94,8 @@ export const dict = {
   "temp.feature.models.afterLink": ", including local models",
   "temp.feature.models.afterLink": ", including local models",
   "temp.screenshot.caption": "opencode TUI with the tokyonight theme",
   "temp.screenshot.caption": "opencode TUI with the tokyonight theme",
   "temp.screenshot.alt": "opencode TUI with tokyonight theme",
   "temp.screenshot.alt": "opencode TUI with tokyonight theme",
+  "temp.logoLightAlt": "opencode logo light",
+  "temp.logoDarkAlt": "opencode logo dark",
 
 
   "home.banner.badge": "New",
   "home.banner.badge": "New",
   "home.banner.text": "Desktop app available in beta",
   "home.banner.text": "Desktop app available in beta",
@@ -234,6 +243,24 @@ export const dict = {
     "All Zen models are hosted in the US. Providers follow a zero-retention policy and do not use your data for model training, with the",
     "All Zen models are hosted in the US. Providers follow a zero-retention policy and do not use your data for model training, with the",
   "zen.privacy.exceptionsLink": "following exceptions",
   "zen.privacy.exceptionsLink": "following exceptions",
 
 
+  "zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.",
+  "zen.api.error.modelNotSupported": "Model {{model}} not supported",
+  "zen.api.error.modelFormatNotSupported": "Model {{model}} not supported for format {{format}}",
+  "zen.api.error.noProviderAvailable": "No provider available",
+  "zen.api.error.providerNotSupported": "Provider {{provider}} not supported",
+  "zen.api.error.missingApiKey": "Missing API key.",
+  "zen.api.error.invalidApiKey": "Invalid API key.",
+  "zen.api.error.subscriptionQuotaExceeded": "Subscription quota exceeded. Retry in {{retryIn}}.",
+  "zen.api.error.subscriptionQuotaExceededUseFreeModels":
+    "Subscription quota exceeded. You can continue using free models.",
+  "zen.api.error.noPaymentMethod": "No payment method. Add a payment method here: {{billingUrl}}",
+  "zen.api.error.insufficientBalance": "Insufficient balance. Manage your billing here: {{billingUrl}}",
+  "zen.api.error.workspaceMonthlyLimitReached":
+    "Your workspace has reached its monthly spending limit of ${{amount}}. Manage your limits here: {{billingUrl}}",
+  "zen.api.error.userMonthlyLimitReached":
+    "You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}",
+  "zen.api.error.modelDisabled": "Model is disabled",
+
   "black.meta.title": "OpenCode Black | Access all the world's best coding models",
   "black.meta.title": "OpenCode Black | Access all the world's best coding models",
   "black.meta.description": "Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans.",
   "black.meta.description": "Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans.",
   "black.hero.title": "Access all the world's best coding models",
   "black.hero.title": "Access all the world's best coding models",
@@ -443,6 +470,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "Please update your payment method and try again.",
   "workspace.reload.updatePaymentMethod": "Please update your payment method and try again.",
   "workspace.reload.retrying": "Retrying...",
   "workspace.reload.retrying": "Retrying...",
   "workspace.reload.retry": "Retry",
   "workspace.reload.retry": "Retry",
+  "workspace.reload.error.paymentFailed": "Payment failed.",
 
 
   "workspace.payments.title": "Payments History",
   "workspace.payments.title": "Payments History",
   "workspace.payments.subtitle": "Recent payment transactions.",
   "workspace.payments.subtitle": "Recent payment transactions.",
@@ -561,6 +589,10 @@ export const dict = {
   "enterprise.form.send": "Send",
   "enterprise.form.send": "Send",
   "enterprise.form.sending": "Sending...",
   "enterprise.form.sending": "Sending...",
   "enterprise.form.success": "Message sent, we'll be in touch soon.",
   "enterprise.form.success": "Message sent, we'll be in touch soon.",
+  "enterprise.form.success.submitted": "Form submitted successfully.",
+  "enterprise.form.error.allFieldsRequired": "All fields are required.",
+  "enterprise.form.error.invalidEmailFormat": "Invalid email format.",
+  "enterprise.form.error.internalServer": "Internal server error.",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.q1": "What is OpenCode Enterprise?",
   "enterprise.faq.q1": "What is OpenCode Enterprise?",
   "enterprise.faq.a1":
   "enterprise.faq.a1":
@@ -593,6 +625,7 @@ export const dict = {
   "bench.list.table.agent": "Agent",
   "bench.list.table.agent": "Agent",
   "bench.list.table.model": "Model",
   "bench.list.table.model": "Model",
   "bench.list.table.score": "Score",
   "bench.list.table.score": "Score",
+  "bench.submission.error.allFieldsRequired": "All fields are required.",
 
 
   "bench.detail.title": "Benchmark - {{task}}",
   "bench.detail.title": "Benchmark - {{task}}",
   "bench.detail.notFound": "Task not found",
   "bench.detail.notFound": "Task not found",

+ 33 - 0
packages/console/app/src/i18n/es.ts

@@ -15,6 +15,7 @@ export const dict = {
   "nav.home": "Inicio",
   "nav.home": "Inicio",
   "nav.openMenu": "Abrir menú",
   "nav.openMenu": "Abrir menú",
   "nav.getStartedFree": "Empezar gratis",
   "nav.getStartedFree": "Empezar gratis",
+  "nav.logoAlt": "OpenCode",
 
 
   "nav.context.copyLogo": "Copiar logo como SVG",
   "nav.context.copyLogo": "Copiar logo como SVG",
   "nav.context.copyWordmark": "Copiar marca como SVG",
   "nav.context.copyWordmark": "Copiar marca como SVG",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "Documentación",
   "notFound.docs": "Documentación",
   "notFound.github": "GitHub",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "opencode logo claro",
+  "notFound.logoDarkAlt": "opencode logo oscuro",
 
 
   "user.logout": "Cerrar sesión",
   "user.logout": "Cerrar sesión",
 
 
+  "auth.callback.error.codeMissing": "No se encontró código de autorización.",
+
   "workspace.select": "Seleccionar espacio de trabajo",
   "workspace.select": "Seleccionar espacio de trabajo",
   "workspace.createNew": "+ Crear nuevo espacio de trabajo",
   "workspace.createNew": "+ Crear nuevo espacio de trabajo",
   "workspace.modal.title": "Crear nuevo espacio de trabajo",
   "workspace.modal.title": "Crear nuevo espacio de trabajo",
@@ -76,6 +81,8 @@ export const dict = {
   "error.reloadAmountMin": "La cantidad de recarga debe ser al menos ${{amount}}",
   "error.reloadAmountMin": "La cantidad de recarga debe ser al menos ${{amount}}",
   "error.reloadTriggerMin": "El disparador de saldo debe ser al menos ${{amount}}",
   "error.reloadTriggerMin": "El disparador de saldo debe ser al menos ${{amount}}",
 
 
+  "app.meta.description": "OpenCode - El agente de codificación de código abierto.",
+
   "home.title": "OpenCode | El agente de codificación IA de código abierto",
   "home.title": "OpenCode | El agente de codificación IA de código abierto",
 
 
   "temp.title": "opencode | Agente de codificación IA creado para la terminal",
   "temp.title": "opencode | Agente de codificación IA creado para la terminal",
@@ -91,6 +98,8 @@ export const dict = {
   "temp.feature.models.afterLink": ", incluyendo modelos locales",
   "temp.feature.models.afterLink": ", incluyendo modelos locales",
   "temp.screenshot.caption": "opencode TUI con el tema tokyonight",
   "temp.screenshot.caption": "opencode TUI con el tema tokyonight",
   "temp.screenshot.alt": "opencode TUI con tema tokyonight",
   "temp.screenshot.alt": "opencode TUI con tema tokyonight",
+  "temp.logoLightAlt": "logo de opencode claro",
+  "temp.logoDarkAlt": "logo de opencode oscuro",
 
 
   "home.banner.badge": "Nuevo",
   "home.banner.badge": "Nuevo",
   "home.banner.text": "Aplicación de escritorio disponible en beta",
   "home.banner.text": "Aplicación de escritorio disponible en beta",
@@ -243,6 +252,24 @@ export const dict = {
     "Todos los modelos Zen están alojados en EE. UU. Los proveedores siguen una política de cero retención y no usan tus datos para entrenamiento de modelos, con las",
     "Todos los modelos Zen están alojados en EE. UU. Los proveedores siguen una política de cero retención y no usan tus datos para entrenamiento de modelos, con las",
   "zen.privacy.exceptionsLink": "siguientes excepciones",
   "zen.privacy.exceptionsLink": "siguientes excepciones",
 
 
+  "zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.",
+  "zen.api.error.modelNotSupported": "Modelo {{model}} no soportado",
+  "zen.api.error.modelFormatNotSupported": "Modelo {{model}} no soportado para el formato {{format}}",
+  "zen.api.error.noProviderAvailable": "Ningún proveedor disponible",
+  "zen.api.error.providerNotSupported": "Proveedor {{provider}} no soportado",
+  "zen.api.error.missingApiKey": "Falta la clave API.",
+  "zen.api.error.invalidApiKey": "Clave API inválida.",
+  "zen.api.error.subscriptionQuotaExceeded": "Cuota de suscripción excedida. Reintenta en {{retryIn}}.",
+  "zen.api.error.subscriptionQuotaExceededUseFreeModels":
+    "Cuota de suscripción excedida. Puedes continuar usando modelos gratuitos.",
+  "zen.api.error.noPaymentMethod": "Sin método de pago. Añade un método de pago aquí: {{billingUrl}}",
+  "zen.api.error.insufficientBalance": "Saldo insuficiente. Gestiona tu facturación aquí: {{billingUrl}}",
+  "zen.api.error.workspaceMonthlyLimitReached":
+    "Tu espacio de trabajo ha alcanzado su límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{billingUrl}}",
+  "zen.api.error.userMonthlyLimitReached":
+    "Has alcanzado tu límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{membersUrl}}",
+  "zen.api.error.modelDisabled": "El modelo está deshabilitado",
+
   "black.meta.title": "OpenCode Black | Accede a los mejores modelos de codificación del mundo",
   "black.meta.title": "OpenCode Black | Accede a los mejores modelos de codificación del mundo",
   "black.meta.description": "Obtén acceso a Claude, GPT, Gemini y más con los planes de suscripción de OpenCode Black.",
   "black.meta.description": "Obtén acceso a Claude, GPT, Gemini y más con los planes de suscripción de OpenCode Black.",
   "black.hero.title": "Accede a los mejores modelos de codificación del mundo",
   "black.hero.title": "Accede a los mejores modelos de codificación del mundo",
@@ -452,6 +479,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "Por favor actualiza tu método de pago e intenta de nuevo.",
   "workspace.reload.updatePaymentMethod": "Por favor actualiza tu método de pago e intenta de nuevo.",
   "workspace.reload.retrying": "Reintentando...",
   "workspace.reload.retrying": "Reintentando...",
   "workspace.reload.retry": "Reintentar",
   "workspace.reload.retry": "Reintentar",
+  "workspace.reload.error.paymentFailed": "El pago falló.",
 
 
   "workspace.payments.title": "Historial de Pagos",
   "workspace.payments.title": "Historial de Pagos",
   "workspace.payments.subtitle": "Transacciones de pago recientes.",
   "workspace.payments.subtitle": "Transacciones de pago recientes.",
@@ -571,6 +599,10 @@ export const dict = {
   "enterprise.form.send": "Enviar",
   "enterprise.form.send": "Enviar",
   "enterprise.form.sending": "Enviando...",
   "enterprise.form.sending": "Enviando...",
   "enterprise.form.success": "Mensaje enviado, estaremos en contacto pronto.",
   "enterprise.form.success": "Mensaje enviado, estaremos en contacto pronto.",
+  "enterprise.form.success.submitted": "Formulario enviado con éxito.",
+  "enterprise.form.error.allFieldsRequired": "Todos los campos son obligatorios.",
+  "enterprise.form.error.invalidEmailFormat": "Formato de correo inválido.",
+  "enterprise.form.error.internalServer": "Error interno del servidor.",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.q1": "¿Qué es OpenCode Enterprise?",
   "enterprise.faq.q1": "¿Qué es OpenCode Enterprise?",
   "enterprise.faq.a1":
   "enterprise.faq.a1":
@@ -603,6 +635,7 @@ export const dict = {
   "bench.list.table.agent": "Agente",
   "bench.list.table.agent": "Agente",
   "bench.list.table.model": "Modelo",
   "bench.list.table.model": "Modelo",
   "bench.list.table.score": "Puntuación",
   "bench.list.table.score": "Puntuación",
+  "bench.submission.error.allFieldsRequired": "Todos los campos son obligatorios.",
 
 
   "bench.detail.title": "Benchmark - {{task}}",
   "bench.detail.title": "Benchmark - {{task}}",
   "bench.detail.notFound": "Tarea no encontrada",
   "bench.detail.notFound": "Tarea no encontrada",

+ 31 - 0
packages/console/app/src/i18n/fr.ts

@@ -3,6 +3,7 @@ import { dict as en } from "./en"
 
 
 export const dict = {
 export const dict = {
   ...en,
   ...en,
+  "app.meta.description": "OpenCode - L'agent de code open source.",
   "nav.github": "GitHub",
   "nav.github": "GitHub",
   "nav.docs": "Documentation",
   "nav.docs": "Documentation",
   "nav.changelog": "Changelog",
   "nav.changelog": "Changelog",
@@ -15,6 +16,7 @@ export const dict = {
   "nav.home": "Accueil",
   "nav.home": "Accueil",
   "nav.openMenu": "Ouvrir le menu",
   "nav.openMenu": "Ouvrir le menu",
   "nav.getStartedFree": "Commencer gratuitement",
   "nav.getStartedFree": "Commencer gratuitement",
+  "nav.logoAlt": "OpenCode",
 
 
   "nav.context.copyLogo": "Copier le logo en SVG",
   "nav.context.copyLogo": "Copier le logo en SVG",
   "nav.context.copyWordmark": "Copier le logotype en SVG",
   "nav.context.copyWordmark": "Copier le logotype en SVG",
@@ -42,6 +44,8 @@ export const dict = {
   "notFound.docs": "Documentation",
   "notFound.docs": "Documentation",
   "notFound.github": "GitHub",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "opencode logo light",
+  "notFound.logoDarkAlt": "opencode logo dark",
 
 
   "user.logout": "Se déconnecter",
   "user.logout": "Se déconnecter",
 
 
@@ -75,6 +79,7 @@ export const dict = {
   "error.modelRequired": "Le modèle est requis",
   "error.modelRequired": "Le modèle est requis",
   "error.reloadAmountMin": "Le montant de recharge doit être d'au moins {{amount}} $",
   "error.reloadAmountMin": "Le montant de recharge doit être d'au moins {{amount}} $",
   "error.reloadTriggerMin": "Le seuil de déclenchement doit être d'au moins {{amount}} $",
   "error.reloadTriggerMin": "Le seuil de déclenchement doit être d'au moins {{amount}} $",
+  "auth.callback.error.codeMissing": "Aucun code d'autorisation trouvé.",
 
 
   "home.title": "OpenCode | L'agent de code IA open source",
   "home.title": "OpenCode | L'agent de code IA open source",
 
 
@@ -91,6 +96,8 @@ export const dict = {
   "temp.feature.models.afterLink": ", y compris les modèles locaux",
   "temp.feature.models.afterLink": ", y compris les modèles locaux",
   "temp.screenshot.caption": "OpenCode TUI avec le thème tokyonight",
   "temp.screenshot.caption": "OpenCode TUI avec le thème tokyonight",
   "temp.screenshot.alt": "OpenCode TUI avec le thème tokyonight",
   "temp.screenshot.alt": "OpenCode TUI avec le thème tokyonight",
+  "temp.logoLightAlt": "opencode logo light",
+  "temp.logoDarkAlt": "opencode logo dark",
 
 
   "home.banner.badge": "Nouveau",
   "home.banner.badge": "Nouveau",
   "home.banner.text": "Application desktop disponible en bêta",
   "home.banner.text": "Application desktop disponible en bêta",
@@ -246,6 +253,24 @@ export const dict = {
     "Tous les modèles Zen sont hébergés aux États-Unis. Les fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour l'entraînement des modèles, avec les",
     "Tous les modèles Zen sont hébergés aux États-Unis. Les fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour l'entraînement des modèles, avec les",
   "zen.privacy.exceptionsLink": "exceptions suivantes",
   "zen.privacy.exceptionsLink": "exceptions suivantes",
 
 
+  "zen.api.error.rateLimitExceeded": "Limite de débit dépassée. Veuillez réessayer plus tard.",
+  "zen.api.error.modelNotSupported": "Modèle {{model}} non pris en charge",
+  "zen.api.error.modelFormatNotSupported": "Modèle {{model}} non pris en charge pour le format {{format}}",
+  "zen.api.error.noProviderAvailable": "Aucun fournisseur disponible",
+  "zen.api.error.providerNotSupported": "Fournisseur {{provider}} non pris en charge",
+  "zen.api.error.missingApiKey": "Clé API manquante.",
+  "zen.api.error.invalidApiKey": "Clé API invalide.",
+  "zen.api.error.subscriptionQuotaExceeded": "Quota d'abonnement dépassé. Réessayez dans {{retryIn}}.",
+  "zen.api.error.subscriptionQuotaExceededUseFreeModels":
+    "Quota d'abonnement dépassé. Vous pouvez continuer à utiliser les modèles gratuits.",
+  "zen.api.error.noPaymentMethod": "Aucune méthode de paiement. Ajoutez une méthode de paiement ici : {{billingUrl}}",
+  "zen.api.error.insufficientBalance": "Solde insuffisant. Gérez votre facturation ici : {{billingUrl}}",
+  "zen.api.error.workspaceMonthlyLimitReached":
+    "Votre espace de travail a atteint sa limite de dépense mensuelle de {{amount}} $. Gérez vos limites ici : {{billingUrl}}",
+  "zen.api.error.userMonthlyLimitReached":
+    "Vous avez atteint votre limite de dépense mensuelle de {{amount}} $. Gérez vos limites ici : {{membersUrl}}",
+  "zen.api.error.modelDisabled": "Le modèle est désactivé",
+
   "black.meta.title": "OpenCode Black | Accédez aux meilleurs modèles de code au monde",
   "black.meta.title": "OpenCode Black | Accédez aux meilleurs modèles de code au monde",
   "black.meta.description": "Accédez à Claude, GPT, Gemini et plus avec les forfaits d'abonnement OpenCode Black.",
   "black.meta.description": "Accédez à Claude, GPT, Gemini et plus avec les forfaits d'abonnement OpenCode Black.",
   "black.hero.title": "Accédez aux meilleurs modèles de code au monde",
   "black.hero.title": "Accédez aux meilleurs modèles de code au monde",
@@ -457,6 +482,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "Veuillez mettre à jour votre méthode de paiement et réessayer.",
   "workspace.reload.updatePaymentMethod": "Veuillez mettre à jour votre méthode de paiement et réessayer.",
   "workspace.reload.retrying": "Nouvelle tentative...",
   "workspace.reload.retrying": "Nouvelle tentative...",
   "workspace.reload.retry": "Réessayer",
   "workspace.reload.retry": "Réessayer",
+  "workspace.reload.error.paymentFailed": "Échec du paiement.",
 
 
   "workspace.payments.title": "Historique des paiements",
   "workspace.payments.title": "Historique des paiements",
   "workspace.payments.subtitle": "Transactions de paiement récentes.",
   "workspace.payments.subtitle": "Transactions de paiement récentes.",
@@ -581,6 +607,10 @@ export const dict = {
   "enterprise.form.send": "Envoyer",
   "enterprise.form.send": "Envoyer",
   "enterprise.form.sending": "Envoi...",
   "enterprise.form.sending": "Envoi...",
   "enterprise.form.success": "Message envoyé, nous vous contacterons bientôt.",
   "enterprise.form.success": "Message envoyé, nous vous contacterons bientôt.",
+  "enterprise.form.success.submitted": "Formulaire soumis avec succès.",
+  "enterprise.form.error.allFieldsRequired": "Tous les champs sont requis.",
+  "enterprise.form.error.invalidEmailFormat": "Format d'e-mail invalide.",
+  "enterprise.form.error.internalServer": "Erreur interne du serveur.",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.q1": "Qu'est-ce que OpenCode Enterprise ?",
   "enterprise.faq.q1": "Qu'est-ce que OpenCode Enterprise ?",
   "enterprise.faq.a1":
   "enterprise.faq.a1":
@@ -640,4 +670,5 @@ export const dict = {
   "bench.detail.table.duration": "Durée",
   "bench.detail.table.duration": "Durée",
   "bench.detail.run.title": "Exécution {{n}}",
   "bench.detail.run.title": "Exécution {{n}}",
   "bench.detail.rawJson": "JSON brut",
   "bench.detail.rawJson": "JSON brut",
+  "bench.submission.error.allFieldsRequired": "Tous les champs sont requis.",
 } satisfies Dict
 } satisfies Dict

+ 33 - 0
packages/console/app/src/i18n/it.ts

@@ -15,6 +15,7 @@ export const dict = {
   "nav.home": "Home",
   "nav.home": "Home",
   "nav.openMenu": "Apri menu",
   "nav.openMenu": "Apri menu",
   "nav.getStartedFree": "Inizia gratis",
   "nav.getStartedFree": "Inizia gratis",
+  "nav.logoAlt": "OpenCode",
 
 
   "nav.context.copyLogo": "Copia il logo come SVG",
   "nav.context.copyLogo": "Copia il logo come SVG",
   "nav.context.copyWordmark": "Copia il wordmark come SVG",
   "nav.context.copyWordmark": "Copia il wordmark come SVG",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "Documentazione",
   "notFound.docs": "Documentazione",
   "notFound.github": "GitHub",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "logo chiaro di opencode",
+  "notFound.logoDarkAlt": "logo scuro di opencode",
 
 
   "user.logout": "Esci",
   "user.logout": "Esci",
 
 
+  "auth.callback.error.codeMissing": "Nessun codice di autorizzazione trovato.",
+
   "workspace.select": "Seleziona workspace",
   "workspace.select": "Seleziona workspace",
   "workspace.createNew": "+ Crea nuovo workspace",
   "workspace.createNew": "+ Crea nuovo workspace",
   "workspace.modal.title": "Crea nuovo workspace",
   "workspace.modal.title": "Crea nuovo workspace",
@@ -76,6 +81,8 @@ export const dict = {
   "error.reloadAmountMin": "L'importo della ricarica deve essere almeno ${{amount}}",
   "error.reloadAmountMin": "L'importo della ricarica deve essere almeno ${{amount}}",
   "error.reloadTriggerMin": "La soglia del saldo deve essere almeno ${{amount}}",
   "error.reloadTriggerMin": "La soglia del saldo deve essere almeno ${{amount}}",
 
 
+  "app.meta.description": "OpenCode - L'agente di programmazione open source.",
+
   "home.title": "OpenCode | L'agente di coding IA open source",
   "home.title": "OpenCode | L'agente di coding IA open source",
 
 
   "temp.title": "opencode | Agente di coding IA costruito per il terminale",
   "temp.title": "opencode | Agente di coding IA costruito per il terminale",
@@ -91,6 +98,8 @@ export const dict = {
   "temp.feature.models.afterLink": ", inclusi modelli locali",
   "temp.feature.models.afterLink": ", inclusi modelli locali",
   "temp.screenshot.caption": "OpenCode TUI con il tema tokyonight",
   "temp.screenshot.caption": "OpenCode TUI con il tema tokyonight",
   "temp.screenshot.alt": "OpenCode TUI con tema tokyonight",
   "temp.screenshot.alt": "OpenCode TUI con tema tokyonight",
+  "temp.logoLightAlt": "logo chiaro di opencode",
+  "temp.logoDarkAlt": "logo scuro di opencode",
 
 
   "home.banner.badge": "Nuovo",
   "home.banner.badge": "Nuovo",
   "home.banner.text": "App desktop disponibile in beta",
   "home.banner.text": "App desktop disponibile in beta",
@@ -240,6 +249,24 @@ export const dict = {
     "Tutti i modelli Zen sono ospitati negli Stati Uniti. I provider seguono una policy di zero-retention e non usano i tuoi dati per l'addestramento dei modelli, con le",
     "Tutti i modelli Zen sono ospitati negli Stati Uniti. I provider seguono una policy di zero-retention e non usano i tuoi dati per l'addestramento dei modelli, con le",
   "zen.privacy.exceptionsLink": "seguenti eccezioni",
   "zen.privacy.exceptionsLink": "seguenti eccezioni",
 
 
+  "zen.api.error.rateLimitExceeded": "Limite di richieste superato. Riprova più tardi.",
+  "zen.api.error.modelNotSupported": "Modello {{model}} non supportato",
+  "zen.api.error.modelFormatNotSupported": "Modello {{model}} non supportato per il formato {{format}}",
+  "zen.api.error.noProviderAvailable": "Nessun provider disponibile",
+  "zen.api.error.providerNotSupported": "Provider {{provider}} non supportato",
+  "zen.api.error.missingApiKey": "Chiave API mancante.",
+  "zen.api.error.invalidApiKey": "Chiave API non valida.",
+  "zen.api.error.subscriptionQuotaExceeded": "Quota dell'abbonamento superata. Riprova tra {{retryIn}}.",
+  "zen.api.error.subscriptionQuotaExceededUseFreeModels":
+    "Quota dell'abbonamento superata. Puoi continuare a utilizzare modelli gratuiti.",
+  "zen.api.error.noPaymentMethod": "Nessun metodo di pagamento. Aggiungi un metodo di pagamento qui: {{billingUrl}}",
+  "zen.api.error.insufficientBalance": "Saldo insufficiente. Gestisci la tua fatturazione qui: {{billingUrl}}",
+  "zen.api.error.workspaceMonthlyLimitReached":
+    "La tua area di lavoro ha raggiunto il limite di spesa mensile di ${{amount}}. Gestisci i tuoi limiti qui: {{billingUrl}}",
+  "zen.api.error.userMonthlyLimitReached":
+    "Hai raggiunto il tuo limite di spesa mensile di ${{amount}}. Gestisci i tuoi limiti qui: {{membersUrl}}",
+  "zen.api.error.modelDisabled": "Il modello è disabilitato",
+
   "black.meta.title": "OpenCode Black | Accedi ai migliori modelli di coding al mondo",
   "black.meta.title": "OpenCode Black | Accedi ai migliori modelli di coding al mondo",
   "black.meta.description":
   "black.meta.description":
     "Ottieni l'accesso a Claude, GPT, Gemini e altri con i piani di abbonamento OpenCode Black.",
     "Ottieni l'accesso a Claude, GPT, Gemini e altri con i piani di abbonamento OpenCode Black.",
@@ -451,6 +478,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "Aggiorna il tuo metodo di pagamento e riprova.",
   "workspace.reload.updatePaymentMethod": "Aggiorna il tuo metodo di pagamento e riprova.",
   "workspace.reload.retrying": "Riprovo...",
   "workspace.reload.retrying": "Riprovo...",
   "workspace.reload.retry": "Riprova",
   "workspace.reload.retry": "Riprova",
+  "workspace.reload.error.paymentFailed": "Pagamento fallito.",
 
 
   "workspace.payments.title": "Cronologia Pagamenti",
   "workspace.payments.title": "Cronologia Pagamenti",
   "workspace.payments.subtitle": "Transazioni di pagamento recenti.",
   "workspace.payments.subtitle": "Transazioni di pagamento recenti.",
@@ -569,6 +597,10 @@ export const dict = {
   "enterprise.form.send": "Invia",
   "enterprise.form.send": "Invia",
   "enterprise.form.sending": "Invio...",
   "enterprise.form.sending": "Invio...",
   "enterprise.form.success": "Messaggio inviato, ti contatteremo presto.",
   "enterprise.form.success": "Messaggio inviato, ti contatteremo presto.",
+  "enterprise.form.success.submitted": "Modulo inviato con successo.",
+  "enterprise.form.error.allFieldsRequired": "Tutti i campi sono obbligatori.",
+  "enterprise.form.error.invalidEmailFormat": "Formato email non valido.",
+  "enterprise.form.error.internalServer": "Errore interno del server.",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.q1": "Cos'è OpenCode Enterprise?",
   "enterprise.faq.q1": "Cos'è OpenCode Enterprise?",
   "enterprise.faq.a1":
   "enterprise.faq.a1":
@@ -601,6 +633,7 @@ export const dict = {
   "bench.list.table.agent": "Agente",
   "bench.list.table.agent": "Agente",
   "bench.list.table.model": "Modello",
   "bench.list.table.model": "Modello",
   "bench.list.table.score": "Punteggio",
   "bench.list.table.score": "Punteggio",
+  "bench.submission.error.allFieldsRequired": "Tutti i campi sono obbligatori.",
 
 
   "bench.detail.title": "Benchmark - {{task}}",
   "bench.detail.title": "Benchmark - {{task}}",
   "bench.detail.notFound": "Task non trovato",
   "bench.detail.notFound": "Task non trovato",

+ 34 - 0
packages/console/app/src/i18n/ja.ts

@@ -15,6 +15,7 @@ export const dict = {
   "nav.home": "ホーム",
   "nav.home": "ホーム",
   "nav.openMenu": "メニューを開く",
   "nav.openMenu": "メニューを開く",
   "nav.getStartedFree": "無料ではじめる",
   "nav.getStartedFree": "無料ではじめる",
+  "nav.logoAlt": "OpenCode",
 
 
   "nav.context.copyLogo": "ロゴをSVGでコピー",
   "nav.context.copyLogo": "ロゴをSVGでコピー",
   "nav.context.copyWordmark": "ワードマークをSVGでコピー",
   "nav.context.copyWordmark": "ワードマークをSVGでコピー",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "ドキュメント",
   "notFound.docs": "ドキュメント",
   "notFound.github": "GitHub",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "opencodeのロゴ(ライト)",
+  "notFound.logoDarkAlt": "opencodeのロゴ(ダーク)",
 
 
   "user.logout": "ログアウト",
   "user.logout": "ログアウト",
 
 
+  "auth.callback.error.codeMissing": "認証コードが見つかりません。",
+
   "workspace.select": "ワークスペースを選択",
   "workspace.select": "ワークスペースを選択",
   "workspace.createNew": "+ 新しいワークスペースを作成",
   "workspace.createNew": "+ 新しいワークスペースを作成",
   "workspace.modal.title": "新しいワークスペースを作成",
   "workspace.modal.title": "新しいワークスペースを作成",
@@ -76,6 +81,8 @@ export const dict = {
   "error.reloadAmountMin": "リロード額は少なくとも ${{amount}} である必要があります",
   "error.reloadAmountMin": "リロード額は少なくとも ${{amount}} である必要があります",
   "error.reloadTriggerMin": "残高トリガーは少なくとも ${{amount}} である必要があります",
   "error.reloadTriggerMin": "残高トリガーは少なくとも ${{amount}} である必要があります",
 
 
+  "app.meta.description": "OpenCode - オープンソースのコーディングエージェント。",
+
   "home.title": "OpenCode | オープンソースのAIコーディングエージェント",
   "home.title": "OpenCode | オープンソースのAIコーディングエージェント",
 
 
   "temp.title": "OpenCode | ターミナル向けに構築されたAIコーディングエージェント",
   "temp.title": "OpenCode | ターミナル向けに構築されたAIコーディングエージェント",
@@ -91,6 +98,8 @@ export const dict = {
   "temp.feature.models.afterLink": "を通じて75以上のLLMプロバイダーをサポート",
   "temp.feature.models.afterLink": "を通じて75以上のLLMプロバイダーをサポート",
   "temp.screenshot.caption": "tokyonight テーマを使用した OpenCode TUI",
   "temp.screenshot.caption": "tokyonight テーマを使用した OpenCode TUI",
   "temp.screenshot.alt": "tokyonight テーマの OpenCode TUI",
   "temp.screenshot.alt": "tokyonight テーマの OpenCode TUI",
+  "temp.logoLightAlt": "opencodeのロゴ(ライト)",
+  "temp.logoDarkAlt": "opencodeのロゴ(ダーク)",
 
 
   "home.banner.badge": "新着",
   "home.banner.badge": "新着",
   "home.banner.text": "デスクトップアプリのベータ版が利用可能",
   "home.banner.text": "デスクトップアプリのベータ版が利用可能",
@@ -239,6 +248,25 @@ export const dict = {
     "すべてのZenモデルは米国でホストされています。プロバイダーはゼロ保持ポリシーに従い、モデルのトレーニングにデータを使用しません(",
     "すべてのZenモデルは米国でホストされています。プロバイダーはゼロ保持ポリシーに従い、モデルのトレーニングにデータを使用しません(",
   "zen.privacy.exceptionsLink": "以下の例外",
   "zen.privacy.exceptionsLink": "以下の例外",
 
 
+  "zen.api.error.rateLimitExceeded": "レート制限を超えました。後でもう一度お試しください。",
+  "zen.api.error.modelNotSupported": "モデル {{model}} はサポートされていません",
+  "zen.api.error.modelFormatNotSupported": "フォーマット {{format}} ではモデル {{model}} はサポートされていません",
+  "zen.api.error.noProviderAvailable": "利用可能なプロバイダーがありません",
+  "zen.api.error.providerNotSupported": "プロバイダー {{provider}} はサポートされていません",
+  "zen.api.error.missingApiKey": "APIキーがありません。",
+  "zen.api.error.invalidApiKey": "無効なAPIキーです。",
+  "zen.api.error.subscriptionQuotaExceeded":
+    "サブスクリプションの制限を超えました。{{retryIn}} 後に再試行してください。",
+  "zen.api.error.subscriptionQuotaExceededUseFreeModels":
+    "サブスクリプションの制限を超えました。無料モデルは引き続きご利用いただけます。",
+  "zen.api.error.noPaymentMethod": "お支払い方法がありません。こちらからお支払い方法を追加してください: {{billingUrl}}",
+  "zen.api.error.insufficientBalance": "残高が不足しています。こちらから請求を管理してください: {{billingUrl}}",
+  "zen.api.error.workspaceMonthlyLimitReached":
+    "ワークスペースが月額の利用上限 ${{amount}} に達しました。こちらから上限を管理してください: {{billingUrl}}",
+  "zen.api.error.userMonthlyLimitReached":
+    "月額の利用上限 ${{amount}} に達しました。こちらから上限を管理してください: {{membersUrl}}",
+  "zen.api.error.modelDisabled": "モデルが無効です",
+
   "black.meta.title": "OpenCode Black | 世界最高峰のコーディングモデルすべてにアクセス",
   "black.meta.title": "OpenCode Black | 世界最高峰のコーディングモデルすべてにアクセス",
   "black.meta.description": "OpenCode Black サブスクリプションプランで、Claude、GPT、Gemini などにアクセス。",
   "black.meta.description": "OpenCode Black サブスクリプションプランで、Claude、GPT、Gemini などにアクセス。",
   "black.hero.title": "世界最高峰のコーディングモデルすべてにアクセス",
   "black.hero.title": "世界最高峰のコーディングモデルすべてにアクセス",
@@ -448,6 +476,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "支払い方法を更新して、もう一度お試しください。",
   "workspace.reload.updatePaymentMethod": "支払い方法を更新して、もう一度お試しください。",
   "workspace.reload.retrying": "再試行中...",
   "workspace.reload.retrying": "再試行中...",
   "workspace.reload.retry": "再試行",
   "workspace.reload.retry": "再試行",
+  "workspace.reload.error.paymentFailed": "支払いに失敗しました。",
 
 
   "workspace.payments.title": "支払い履歴",
   "workspace.payments.title": "支払い履歴",
   "workspace.payments.subtitle": "最近の支払い取引。",
   "workspace.payments.subtitle": "最近の支払い取引。",
@@ -568,6 +597,10 @@ export const dict = {
   "enterprise.form.send": "送信",
   "enterprise.form.send": "送信",
   "enterprise.form.sending": "送信中...",
   "enterprise.form.sending": "送信中...",
   "enterprise.form.success": "送信しました。まもなくご連絡いたします。",
   "enterprise.form.success": "送信しました。まもなくご連絡いたします。",
+  "enterprise.form.success.submitted": "フォームが正常に送信されました。",
+  "enterprise.form.error.allFieldsRequired": "すべての項目は必須です。",
+  "enterprise.form.error.invalidEmailFormat": "無効なメール形式です。",
+  "enterprise.form.error.internalServer": "内部サーバーエラー。",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.q1": "OpenCode Enterpriseとは?",
   "enterprise.faq.q1": "OpenCode Enterpriseとは?",
   "enterprise.faq.a1":
   "enterprise.faq.a1":
@@ -600,6 +633,7 @@ export const dict = {
   "bench.list.table.agent": "エージェント",
   "bench.list.table.agent": "エージェント",
   "bench.list.table.model": "モデル",
   "bench.list.table.model": "モデル",
   "bench.list.table.score": "スコア",
   "bench.list.table.score": "スコア",
+  "bench.submission.error.allFieldsRequired": "すべての項目は必須です。",
 
 
   "bench.detail.title": "ベンチマーク - {{task}}",
   "bench.detail.title": "ベンチマーク - {{task}}",
   "bench.detail.notFound": "タスクが見つかりません",
   "bench.detail.notFound": "タスクが見つかりません",

+ 33 - 0
packages/console/app/src/i18n/ko.ts

@@ -15,6 +15,7 @@ export const dict = {
   "nav.home": "홈",
   "nav.home": "홈",
   "nav.openMenu": "메뉴 열기",
   "nav.openMenu": "메뉴 열기",
   "nav.getStartedFree": "무료로 시작하기",
   "nav.getStartedFree": "무료로 시작하기",
+  "nav.logoAlt": "OpenCode",
 
 
   "nav.context.copyLogo": "로고를 SVG로 복사",
   "nav.context.copyLogo": "로고를 SVG로 복사",
   "nav.context.copyWordmark": "워드마크를 SVG로 복사",
   "nav.context.copyWordmark": "워드마크를 SVG로 복사",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "문서",
   "notFound.docs": "문서",
   "notFound.github": "GitHub",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "opencode 밝은 로고",
+  "notFound.logoDarkAlt": "opencode 어두운 로고",
 
 
   "user.logout": "로그아웃",
   "user.logout": "로그아웃",
 
 
+  "auth.callback.error.codeMissing": "인증 코드를 찾을 수 없습니다.",
+
   "workspace.select": "워크스페이스 선택",
   "workspace.select": "워크스페이스 선택",
   "workspace.createNew": "+ 새 워크스페이스 만들기",
   "workspace.createNew": "+ 새 워크스페이스 만들기",
   "workspace.modal.title": "새 워크스페이스 만들기",
   "workspace.modal.title": "새 워크스페이스 만들기",
@@ -76,6 +81,8 @@ export const dict = {
   "error.reloadAmountMin": "충전 금액은 최소 ${{amount}}이어야 합니다",
   "error.reloadAmountMin": "충전 금액은 최소 ${{amount}}이어야 합니다",
   "error.reloadTriggerMin": "잔액 트리거는 최소 ${{amount}}이어야 합니다",
   "error.reloadTriggerMin": "잔액 트리거는 최소 ${{amount}}이어야 합니다",
 
 
+  "app.meta.description": "OpenCode - 오픈 소스 코딩 에이전트.",
+
   "home.title": "OpenCode | 오픈 소스 AI 코딩 에이전트",
   "home.title": "OpenCode | 오픈 소스 AI 코딩 에이전트",
 
 
   "temp.title": "OpenCode | 터미널을 위해 만들어진 AI 코딩 에이전트",
   "temp.title": "OpenCode | 터미널을 위해 만들어진 AI 코딩 에이전트",
@@ -91,6 +98,8 @@ export const dict = {
   "temp.feature.models.afterLink": "를 통해 75개 이상의 LLM 제공자 지원",
   "temp.feature.models.afterLink": "를 통해 75개 이상의 LLM 제공자 지원",
   "temp.screenshot.caption": "tokyonight 테마가 적용된 OpenCode TUI",
   "temp.screenshot.caption": "tokyonight 테마가 적용된 OpenCode TUI",
   "temp.screenshot.alt": "tokyonight 테마가 적용된 OpenCode TUI",
   "temp.screenshot.alt": "tokyonight 테마가 적용된 OpenCode TUI",
+  "temp.logoLightAlt": "opencode 밝은 로고",
+  "temp.logoDarkAlt": "opencode 어두운 로고",
 
 
   "home.banner.badge": "신규",
   "home.banner.badge": "신규",
   "home.banner.text": "데스크톱 앱 베타 버전 출시",
   "home.banner.text": "데스크톱 앱 베타 버전 출시",
@@ -236,6 +245,24 @@ export const dict = {
     "모든 Zen 모델은 미국에서 호스팅됩니다. 제공자들은 데이터 보존 금지 정책을 따르며 모델 학습에 데이터를 사용하지 않습니다. 단,",
     "모든 Zen 모델은 미국에서 호스팅됩니다. 제공자들은 데이터 보존 금지 정책을 따르며 모델 학습에 데이터를 사용하지 않습니다. 단,",
   "zen.privacy.exceptionsLink": "다음 예외",
   "zen.privacy.exceptionsLink": "다음 예외",
 
 
+  "zen.api.error.rateLimitExceeded": "속도 제한을 초과했습니다. 나중에 다시 시도해 주세요.",
+  "zen.api.error.modelNotSupported": "{{model}} 모델은 지원되지 않습니다",
+  "zen.api.error.modelFormatNotSupported": "{{model}} 모델은 {{format}} 형식에 대해 지원되지 않습니다",
+  "zen.api.error.noProviderAvailable": "사용 가능한 제공자가 없습니다",
+  "zen.api.error.providerNotSupported": "{{provider}} 제공자는 지원되지 않습니다",
+  "zen.api.error.missingApiKey": "API 키가 누락되었습니다.",
+  "zen.api.error.invalidApiKey": "유효하지 않은 API 키입니다.",
+  "zen.api.error.subscriptionQuotaExceeded": "구독 할당량을 초과했습니다. {{retryIn}} 후 다시 시도해 주세요.",
+  "zen.api.error.subscriptionQuotaExceededUseFreeModels":
+    "구독 할당량을 초과했습니다. 무료 모델은 계속 사용할 수 있습니다.",
+  "zen.api.error.noPaymentMethod": "결제 수단이 없습니다. 결제 수단을 추가하세요: {{billingUrl}}",
+  "zen.api.error.insufficientBalance": "잔액이 부족합니다. 결제 관리를 여기서 하세요: {{billingUrl}}",
+  "zen.api.error.workspaceMonthlyLimitReached":
+    "워크스페이스의 월간 지출 한도인 ${{amount}}에 도달했습니다. 한도 관리를 여기서 하세요: {{billingUrl}}",
+  "zen.api.error.userMonthlyLimitReached":
+    "월간 지출 한도인 ${{amount}}에 도달했습니다. 한도 관리를 여기서 하세요: {{membersUrl}}",
+  "zen.api.error.modelDisabled": "모델이 비활성화되었습니다",
+
   "black.meta.title": "OpenCode Black | 세계 최고의 코딩 모델에 액세스하세요",
   "black.meta.title": "OpenCode Black | 세계 최고의 코딩 모델에 액세스하세요",
   "black.meta.description": "OpenCode Black 구독 플랜으로 Claude, GPT, Gemini 등에 액세스하세요.",
   "black.meta.description": "OpenCode Black 구독 플랜으로 Claude, GPT, Gemini 등에 액세스하세요.",
   "black.hero.title": "세계 최고의 코딩 모델에 액세스하세요",
   "black.hero.title": "세계 최고의 코딩 모델에 액세스하세요",
@@ -445,6 +472,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "결제 수단을 업데이트하고 다시 시도해 주세요.",
   "workspace.reload.updatePaymentMethod": "결제 수단을 업데이트하고 다시 시도해 주세요.",
   "workspace.reload.retrying": "재시도 중...",
   "workspace.reload.retrying": "재시도 중...",
   "workspace.reload.retry": "재시도",
   "workspace.reload.retry": "재시도",
+  "workspace.reload.error.paymentFailed": "결제에 실패했습니다.",
 
 
   "workspace.payments.title": "결제 내역",
   "workspace.payments.title": "결제 내역",
   "workspace.payments.subtitle": "최근 결제 거래 내역입니다.",
   "workspace.payments.subtitle": "최근 결제 거래 내역입니다.",
@@ -562,6 +590,10 @@ export const dict = {
   "enterprise.form.send": "전송",
   "enterprise.form.send": "전송",
   "enterprise.form.sending": "전송 중...",
   "enterprise.form.sending": "전송 중...",
   "enterprise.form.success": "메시지가 전송되었습니다. 곧 연락드리겠습니다.",
   "enterprise.form.success": "메시지가 전송되었습니다. 곧 연락드리겠습니다.",
+  "enterprise.form.success.submitted": "양식이 성공적으로 제출되었습니다.",
+  "enterprise.form.error.allFieldsRequired": "모든 필드는 필수 항목입니다.",
+  "enterprise.form.error.invalidEmailFormat": "유효하지 않은 이메일 형식입니다.",
+  "enterprise.form.error.internalServer": "내부 서버 오류입니다.",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.q1": "OpenCode 엔터프라이즈란 무엇인가요?",
   "enterprise.faq.q1": "OpenCode 엔터프라이즈란 무엇인가요?",
   "enterprise.faq.a1":
   "enterprise.faq.a1":
@@ -594,6 +626,7 @@ export const dict = {
   "bench.list.table.agent": "에이전트",
   "bench.list.table.agent": "에이전트",
   "bench.list.table.model": "모델",
   "bench.list.table.model": "모델",
   "bench.list.table.score": "점수",
   "bench.list.table.score": "점수",
+  "bench.submission.error.allFieldsRequired": "모든 필드는 필수 항목입니다.",
 
 
   "bench.detail.title": "벤치마크 - {{task}}",
   "bench.detail.title": "벤치마크 - {{task}}",
   "bench.detail.notFound": "태스크를 찾을 수 없음",
   "bench.detail.notFound": "태스크를 찾을 수 없음",

+ 33 - 0
packages/console/app/src/i18n/no.ts

@@ -15,6 +15,7 @@ export const dict = {
   "nav.home": "Hjem",
   "nav.home": "Hjem",
   "nav.openMenu": "Åpne meny",
   "nav.openMenu": "Åpne meny",
   "nav.getStartedFree": "Kom i gang gratis",
   "nav.getStartedFree": "Kom i gang gratis",
+  "nav.logoAlt": "OpenCode",
 
 
   "nav.context.copyLogo": "Kopier logo som SVG",
   "nav.context.copyLogo": "Kopier logo som SVG",
   "nav.context.copyWordmark": "Kopier wordmark som SVG",
   "nav.context.copyWordmark": "Kopier wordmark som SVG",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "Dokumentasjon",
   "notFound.docs": "Dokumentasjon",
   "notFound.github": "GitHub",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "opencode logo lys",
+  "notFound.logoDarkAlt": "opencode logo mørk",
 
 
   "user.logout": "Logg ut",
   "user.logout": "Logg ut",
 
 
+  "auth.callback.error.codeMissing": "Ingen autorisasjonskode funnet.",
+
   "workspace.select": "Velg arbeidsområde",
   "workspace.select": "Velg arbeidsområde",
   "workspace.createNew": "+ Opprett nytt arbeidsområde",
   "workspace.createNew": "+ Opprett nytt arbeidsområde",
   "workspace.modal.title": "Opprett nytt arbeidsområde",
   "workspace.modal.title": "Opprett nytt arbeidsområde",
@@ -76,6 +81,8 @@ export const dict = {
   "error.reloadAmountMin": "Påfyllingsbeløp må være minst ${{amount}}",
   "error.reloadAmountMin": "Påfyllingsbeløp må være minst ${{amount}}",
   "error.reloadTriggerMin": "Saldo-trigger må være minst ${{amount}}",
   "error.reloadTriggerMin": "Saldo-trigger må være minst ${{amount}}",
 
 
+  "app.meta.description": "OpenCode - Den åpne kildekode kodingsagenten.",
+
   "home.title": "OpenCode | Den åpne kildekode AI-kodingsagenten",
   "home.title": "OpenCode | Den åpne kildekode AI-kodingsagenten",
 
 
   "temp.title": "opencode | AI-kodingsagent bygget for terminalen",
   "temp.title": "opencode | AI-kodingsagent bygget for terminalen",
@@ -91,6 +98,8 @@ export const dict = {
   "temp.feature.models.afterLink": ", inkludert lokale modeller",
   "temp.feature.models.afterLink": ", inkludert lokale modeller",
   "temp.screenshot.caption": "opencode TUI med tokyonight-tema",
   "temp.screenshot.caption": "opencode TUI med tokyonight-tema",
   "temp.screenshot.alt": "opencode TUI med tokyonight-tema",
   "temp.screenshot.alt": "opencode TUI med tokyonight-tema",
+  "temp.logoLightAlt": "opencode logo lys",
+  "temp.logoDarkAlt": "opencode logo mørk",
 
 
   "home.banner.badge": "Ny",
   "home.banner.badge": "Ny",
   "home.banner.text": "Desktop-app tilgjengelig i beta",
   "home.banner.text": "Desktop-app tilgjengelig i beta",
@@ -240,6 +249,24 @@ export const dict = {
     "Alle Zen-modeller hostes i USA. Leverandører følger en policy om null oppbevaring og bruker ikke dataene dine til modelltrening, med",
     "Alle Zen-modeller hostes i USA. Leverandører følger en policy om null oppbevaring og bruker ikke dataene dine til modelltrening, med",
   "zen.privacy.exceptionsLink": "følgende unntak",
   "zen.privacy.exceptionsLink": "følgende unntak",
 
 
+  "zen.api.error.rateLimitExceeded": "Rate limit overskredet. Vennligst prøv igjen senere.",
+  "zen.api.error.modelNotSupported": "Modell {{model}} støttes ikke",
+  "zen.api.error.modelFormatNotSupported": "Modell {{model}} støttes ikke for format {{format}}",
+  "zen.api.error.noProviderAvailable": "Ingen leverandør tilgjengelig",
+  "zen.api.error.providerNotSupported": "Leverandør {{provider}} støttes ikke",
+  "zen.api.error.missingApiKey": "Mangler API-nøkkel.",
+  "zen.api.error.invalidApiKey": "Ugyldig API-nøkkel.",
+  "zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igjen om {{retryIn}}.",
+  "zen.api.error.subscriptionQuotaExceededUseFreeModels":
+    "Abonnementskvote overskredet. Du kan fortsette å bruke gratis modeller.",
+  "zen.api.error.noPaymentMethod": "Ingen betalingsmetode. Legg til en betalingsmetode her: {{billingUrl}}",
+  "zen.api.error.insufficientBalance": "Utilstrekkelig saldo. Administrer faktureringen din her: {{billingUrl}}",
+  "zen.api.error.workspaceMonthlyLimitReached":
+    "Arbeidsområdet ditt har nådd sin månedlige utgiftsgrense på ${{amount}}. Administrer grensene dine her: {{billingUrl}}",
+  "zen.api.error.userMonthlyLimitReached":
+    "Du har nådd din månedlige utgiftsgrense på ${{amount}}. Administrer grensene dine her: {{membersUrl}}",
+  "zen.api.error.modelDisabled": "Modellen er deaktivert",
+
   "black.meta.title": "OpenCode Black | Få tilgang til verdens beste kodemodeller",
   "black.meta.title": "OpenCode Black | Få tilgang til verdens beste kodemodeller",
   "black.meta.description": "Få tilgang til Claude, GPT, Gemini og mer med OpenCode Black-abonnementer.",
   "black.meta.description": "Få tilgang til Claude, GPT, Gemini og mer med OpenCode Black-abonnementer.",
   "black.hero.title": "Få tilgang til verdens beste kodemodeller",
   "black.hero.title": "Få tilgang til verdens beste kodemodeller",
@@ -449,6 +476,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "Vennligst oppdater betalingsmetoden din og prøv på nytt.",
   "workspace.reload.updatePaymentMethod": "Vennligst oppdater betalingsmetoden din og prøv på nytt.",
   "workspace.reload.retrying": "Prøver på nytt...",
   "workspace.reload.retrying": "Prøver på nytt...",
   "workspace.reload.retry": "Prøv på nytt",
   "workspace.reload.retry": "Prøv på nytt",
+  "workspace.reload.error.paymentFailed": "Betaling mislyktes.",
 
 
   "workspace.payments.title": "Betalingshistorikk",
   "workspace.payments.title": "Betalingshistorikk",
   "workspace.payments.subtitle": "Nylige betalingstransaksjoner.",
   "workspace.payments.subtitle": "Nylige betalingstransaksjoner.",
@@ -567,6 +595,10 @@ export const dict = {
   "enterprise.form.send": "Send",
   "enterprise.form.send": "Send",
   "enterprise.form.sending": "Sender...",
   "enterprise.form.sending": "Sender...",
   "enterprise.form.success": "Melding sendt, vi tar kontakt snart.",
   "enterprise.form.success": "Melding sendt, vi tar kontakt snart.",
+  "enterprise.form.success.submitted": "Skjemaet ble sendt inn.",
+  "enterprise.form.error.allFieldsRequired": "Alle felt er obligatoriske.",
+  "enterprise.form.error.invalidEmailFormat": "Ugyldig e-postformat.",
+  "enterprise.form.error.internalServer": "Intern serverfeil.",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.q1": "Hva er OpenCode Enterprise?",
   "enterprise.faq.q1": "Hva er OpenCode Enterprise?",
   "enterprise.faq.a1":
   "enterprise.faq.a1":
@@ -599,6 +631,7 @@ export const dict = {
   "bench.list.table.agent": "Agent",
   "bench.list.table.agent": "Agent",
   "bench.list.table.model": "Modell",
   "bench.list.table.model": "Modell",
   "bench.list.table.score": "Poengsum",
   "bench.list.table.score": "Poengsum",
+  "bench.submission.error.allFieldsRequired": "Alle felt er obligatoriske.",
 
 
   "bench.detail.title": "Benchmark - {{task}}",
   "bench.detail.title": "Benchmark - {{task}}",
   "bench.detail.notFound": "Oppgave ikke funnet",
   "bench.detail.notFound": "Oppgave ikke funnet",

+ 33 - 0
packages/console/app/src/i18n/pl.ts

@@ -14,6 +14,7 @@ export const dict = {
   "nav.home": "Strona główna",
   "nav.home": "Strona główna",
   "nav.openMenu": "Otwórz menu",
   "nav.openMenu": "Otwórz menu",
   "nav.getStartedFree": "Zacznij za darmo",
   "nav.getStartedFree": "Zacznij za darmo",
+  "nav.logoAlt": "OpenCode",
 
 
   "nav.context.copyLogo": "Skopiuj logo jako SVG",
   "nav.context.copyLogo": "Skopiuj logo jako SVG",
   "nav.context.copyWordmark": "Skopiuj logotyp jako SVG",
   "nav.context.copyWordmark": "Skopiuj logotyp jako SVG",
@@ -41,9 +42,13 @@ export const dict = {
   "notFound.docs": "Dokumentacja",
   "notFound.docs": "Dokumentacja",
   "notFound.github": "GitHub",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "jasne logo opencode",
+  "notFound.logoDarkAlt": "ciemne logo opencode",
 
 
   "user.logout": "Wyloguj się",
   "user.logout": "Wyloguj się",
 
 
+  "auth.callback.error.codeMissing": "Nie znaleziono kodu autoryzacji.",
+
   "workspace.select": "Wybierz obszar roboczy",
   "workspace.select": "Wybierz obszar roboczy",
   "workspace.createNew": "+ Utwórz nowy obszar roboczy",
   "workspace.createNew": "+ Utwórz nowy obszar roboczy",
   "workspace.modal.title": "Utwórz nowy obszar roboczy",
   "workspace.modal.title": "Utwórz nowy obszar roboczy",
@@ -75,6 +80,8 @@ export const dict = {
   "error.reloadAmountMin": "Kwota doładowania musi wynosić co najmniej ${{amount}}",
   "error.reloadAmountMin": "Kwota doładowania musi wynosić co najmniej ${{amount}}",
   "error.reloadTriggerMin": "Próg salda musi wynosić co najmniej ${{amount}}",
   "error.reloadTriggerMin": "Próg salda musi wynosić co najmniej ${{amount}}",
 
 
+  "app.meta.description": "OpenCode - Otwartoźródłowy agent programistyczny.",
+
   "home.title": "OpenCode | Open source'owy agent AI do kodowania",
   "home.title": "OpenCode | Open source'owy agent AI do kodowania",
 
 
   "temp.title": "opencode | Agent AI do kodowania zbudowany dla terminala",
   "temp.title": "opencode | Agent AI do kodowania zbudowany dla terminala",
@@ -90,6 +97,8 @@ export const dict = {
   "temp.feature.models.afterLink": ", w tym modele lokalne",
   "temp.feature.models.afterLink": ", w tym modele lokalne",
   "temp.screenshot.caption": "OpenCode TUI z motywem tokyonight",
   "temp.screenshot.caption": "OpenCode TUI z motywem tokyonight",
   "temp.screenshot.alt": "OpenCode TUI z motywem tokyonight",
   "temp.screenshot.alt": "OpenCode TUI z motywem tokyonight",
+  "temp.logoLightAlt": "jasne logo opencode",
+  "temp.logoDarkAlt": "ciemne logo opencode",
 
 
   "home.banner.badge": "Nowość",
   "home.banner.badge": "Nowość",
   "home.banner.text": "Aplikacja desktopowa dostępna w wersji beta",
   "home.banner.text": "Aplikacja desktopowa dostępna w wersji beta",
@@ -241,6 +250,24 @@ export const dict = {
     "Wszystkie modele Zen są hostowane w USA. Dostawcy stosują politykę zerowej retencji i nie wykorzystują Twoich danych do trenowania modeli, z",
     "Wszystkie modele Zen są hostowane w USA. Dostawcy stosują politykę zerowej retencji i nie wykorzystują Twoich danych do trenowania modeli, z",
   "zen.privacy.exceptionsLink": "następującymi wyjątkami",
   "zen.privacy.exceptionsLink": "następującymi wyjątkami",
 
 
+  "zen.api.error.rateLimitExceeded": "Przekroczono limit zapytań. Spróbuj ponownie później.",
+  "zen.api.error.modelNotSupported": "Model {{model}} nie jest obsługiwany",
+  "zen.api.error.modelFormatNotSupported": "Model {{model}} nie jest obsługiwany dla formatu {{format}}",
+  "zen.api.error.noProviderAvailable": "Brak dostępnego dostawcy",
+  "zen.api.error.providerNotSupported": "Dostawca {{provider}} nie jest obsługiwany",
+  "zen.api.error.missingApiKey": "Brak klucza API.",
+  "zen.api.error.invalidApiKey": "Nieprawidłowy klucz API.",
+  "zen.api.error.subscriptionQuotaExceeded": "Przekroczono limit subskrypcji. Spróbuj ponownie za {{retryIn}}.",
+  "zen.api.error.subscriptionQuotaExceededUseFreeModels":
+    "Przekroczono limit subskrypcji. Możesz kontynuować korzystanie z darmowych modeli.",
+  "zen.api.error.noPaymentMethod": "Brak metody płatności. Dodaj metodę płatności tutaj: {{billingUrl}}",
+  "zen.api.error.insufficientBalance": "Niewystarczające saldo. Zarządzaj swoimi płatnościami tutaj: {{billingUrl}}",
+  "zen.api.error.workspaceMonthlyLimitReached":
+    "Twoja przestrzeń robocza osiągnęła miesięczny limit wydatków w wysokości ${{amount}}. Zarządzaj swoimi limitami tutaj: {{billingUrl}}",
+  "zen.api.error.userMonthlyLimitReached":
+    "Osiągnąłeś swój miesięczny limit wydatków w wysokości ${{amount}}. Zarządzaj swoimi limitami tutaj: {{membersUrl}}",
+  "zen.api.error.modelDisabled": "Model jest wyłączony",
+
   "black.meta.title": "OpenCode Black | Dostęp do najlepszych na świecie modeli kodujących",
   "black.meta.title": "OpenCode Black | Dostęp do najlepszych na świecie modeli kodujących",
   "black.meta.description": "Uzyskaj dostęp do Claude, GPT, Gemini i innych dzięki planom subskrypcji OpenCode Black.",
   "black.meta.description": "Uzyskaj dostęp do Claude, GPT, Gemini i innych dzięki planom subskrypcji OpenCode Black.",
   "black.hero.title": "Dostęp do najlepszych na świecie modeli kodujących",
   "black.hero.title": "Dostęp do najlepszych na świecie modeli kodujących",
@@ -450,6 +477,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "Zaktualizuj metodę płatności i spróbuj ponownie.",
   "workspace.reload.updatePaymentMethod": "Zaktualizuj metodę płatności i spróbuj ponownie.",
   "workspace.reload.retrying": "Ponawianie...",
   "workspace.reload.retrying": "Ponawianie...",
   "workspace.reload.retry": "Spróbuj ponownie",
   "workspace.reload.retry": "Spróbuj ponownie",
+  "workspace.reload.error.paymentFailed": "Płatność nie powiodła się.",
 
 
   "workspace.payments.title": "Historia płatności",
   "workspace.payments.title": "Historia płatności",
   "workspace.payments.subtitle": "Ostatnie transakcje płatnicze.",
   "workspace.payments.subtitle": "Ostatnie transakcje płatnicze.",
@@ -570,6 +598,10 @@ export const dict = {
   "enterprise.form.send": "Wyślij",
   "enterprise.form.send": "Wyślij",
   "enterprise.form.sending": "Wysyłanie...",
   "enterprise.form.sending": "Wysyłanie...",
   "enterprise.form.success": "Wiadomość wysłana, skontaktujemy się wkrótce.",
   "enterprise.form.success": "Wiadomość wysłana, skontaktujemy się wkrótce.",
+  "enterprise.form.success.submitted": "Formularz został pomyślnie wysłany.",
+  "enterprise.form.error.allFieldsRequired": "Wszystkie pola są wymagane.",
+  "enterprise.form.error.invalidEmailFormat": "Nieprawidłowy format adresu e-mail.",
+  "enterprise.form.error.internalServer": "Wewnętrzny błąd serwera.",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.q1": "Czym jest OpenCode Enterprise?",
   "enterprise.faq.q1": "Czym jest OpenCode Enterprise?",
   "enterprise.faq.a1":
   "enterprise.faq.a1":
@@ -602,6 +634,7 @@ export const dict = {
   "bench.list.table.agent": "Agent",
   "bench.list.table.agent": "Agent",
   "bench.list.table.model": "Model",
   "bench.list.table.model": "Model",
   "bench.list.table.score": "Wynik",
   "bench.list.table.score": "Wynik",
+  "bench.submission.error.allFieldsRequired": "Wszystkie pola są wymagane.",
 
 
   "bench.detail.title": "Benchmark - {{task}}",
   "bench.detail.title": "Benchmark - {{task}}",
   "bench.detail.notFound": "Nie znaleziono zadania",
   "bench.detail.notFound": "Nie znaleziono zadania",

+ 33 - 0
packages/console/app/src/i18n/ru.ts

@@ -15,6 +15,7 @@ export const dict = {
   "nav.home": "Главная",
   "nav.home": "Главная",
   "nav.openMenu": "Открыть меню",
   "nav.openMenu": "Открыть меню",
   "nav.getStartedFree": "Начать бесплатно",
   "nav.getStartedFree": "Начать бесплатно",
+  "nav.logoAlt": "OpenCode",
 
 
   "nav.context.copyLogo": "Скопировать логотип как SVG",
   "nav.context.copyLogo": "Скопировать логотип как SVG",
   "nav.context.copyWordmark": "Скопировать название как SVG",
   "nav.context.copyWordmark": "Скопировать название как SVG",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "Документация",
   "notFound.docs": "Документация",
   "notFound.github": "GitHub",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "светлый логотип opencode",
+  "notFound.logoDarkAlt": "темный логотип opencode",
 
 
   "user.logout": "Выйти",
   "user.logout": "Выйти",
 
 
+  "auth.callback.error.codeMissing": "Код авторизации не найден.",
+
   "workspace.select": "Выбрать рабочее пространство",
   "workspace.select": "Выбрать рабочее пространство",
   "workspace.createNew": "+ Создать рабочее пространство",
   "workspace.createNew": "+ Создать рабочее пространство",
   "workspace.modal.title": "Создать рабочее пространство",
   "workspace.modal.title": "Создать рабочее пространство",
@@ -76,6 +81,8 @@ export const dict = {
   "error.reloadAmountMin": "Сумма пополнения должна быть не менее ${{amount}}",
   "error.reloadAmountMin": "Сумма пополнения должна быть не менее ${{amount}}",
   "error.reloadTriggerMin": "Порог баланса должен быть не менее ${{amount}}",
   "error.reloadTriggerMin": "Порог баланса должен быть не менее ${{amount}}",
 
 
+  "app.meta.description": "OpenCode - AI-агент с открытым кодом для программирования.",
+
   "home.title": "OpenCode | AI-агент с открытым кодом для программирования",
   "home.title": "OpenCode | AI-агент с открытым кодом для программирования",
 
 
   "temp.title": "opencode | AI-агент для программирования в терминале",
   "temp.title": "opencode | AI-агент для программирования в терминале",
@@ -91,6 +98,8 @@ export const dict = {
   "temp.feature.models.afterLink": ", включая локальные модели",
   "temp.feature.models.afterLink": ", включая локальные модели",
   "temp.screenshot.caption": "OpenCode TUI с темой tokyonight",
   "temp.screenshot.caption": "OpenCode TUI с темой tokyonight",
   "temp.screenshot.alt": "OpenCode TUI с темой tokyonight",
   "temp.screenshot.alt": "OpenCode TUI с темой tokyonight",
+  "temp.logoLightAlt": "светлый логотип opencode",
+  "temp.logoDarkAlt": "темный логотип opencode",
 
 
   "home.banner.badge": "Новое",
   "home.banner.badge": "Новое",
   "home.banner.text": "Доступно десктопное приложение (бета)",
   "home.banner.text": "Доступно десктопное приложение (бета)",
@@ -244,6 +253,24 @@ export const dict = {
     "Все модели Zen размещены в США. Провайдеры следуют политике нулевого хранения и не используют ваши данные для обучения моделей, за",
     "Все модели Zen размещены в США. Провайдеры следуют политике нулевого хранения и не используют ваши данные для обучения моделей, за",
   "zen.privacy.exceptionsLink": "следующими исключениями",
   "zen.privacy.exceptionsLink": "следующими исключениями",
 
 
+  "zen.api.error.rateLimitExceeded": "Превышен лимит запросов. Пожалуйста, попробуйте позже.",
+  "zen.api.error.modelNotSupported": "Модель {{model}} не поддерживается",
+  "zen.api.error.modelFormatNotSupported": "Модель {{model}} не поддерживается для формата {{format}}",
+  "zen.api.error.noProviderAvailable": "Нет доступных провайдеров",
+  "zen.api.error.providerNotSupported": "Провайдер {{provider}} не поддерживается",
+  "zen.api.error.missingApiKey": "Отсутствует API ключ.",
+  "zen.api.error.invalidApiKey": "Неверный API ключ.",
+  "zen.api.error.subscriptionQuotaExceeded": "Квота подписки превышена. Повторите попытку через {{retryIn}}.",
+  "zen.api.error.subscriptionQuotaExceededUseFreeModels":
+    "Квота подписки превышена. Вы можете продолжить использовать бесплатные модели.",
+  "zen.api.error.noPaymentMethod": "Нет способа оплаты. Добавьте способ оплаты здесь: {{billingUrl}}",
+  "zen.api.error.insufficientBalance": "Недостаточно средств. Управляйте оплатой здесь: {{billingUrl}}",
+  "zen.api.error.workspaceMonthlyLimitReached":
+    "Ваше рабочее пространство достигло ежемесячного лимита расходов в ${{amount}}. Управляйте лимитами здесь: {{billingUrl}}",
+  "zen.api.error.userMonthlyLimitReached":
+    "Вы достигли ежемесячного лимита расходов в ${{amount}}. Управляйте лимитами здесь: {{membersUrl}}",
+  "zen.api.error.modelDisabled": "Модель отключена",
+
   "black.meta.title": "OpenCode Black | Доступ к лучшим моделям для кодинга в мире",
   "black.meta.title": "OpenCode Black | Доступ к лучшим моделям для кодинга в мире",
   "black.meta.description": "Получите доступ к Claude, GPT, Gemini и другим моделям с подпиской OpenCode Black.",
   "black.meta.description": "Получите доступ к Claude, GPT, Gemini и другим моделям с подпиской OpenCode Black.",
   "black.hero.title": "Доступ к лучшим моделям для кодинга в мире",
   "black.hero.title": "Доступ к лучшим моделям для кодинга в мире",
@@ -455,6 +482,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "Пожалуйста, обновите способ оплаты и попробуйте снова.",
   "workspace.reload.updatePaymentMethod": "Пожалуйста, обновите способ оплаты и попробуйте снова.",
   "workspace.reload.retrying": "Повторная попытка...",
   "workspace.reload.retrying": "Повторная попытка...",
   "workspace.reload.retry": "Повторить",
   "workspace.reload.retry": "Повторить",
+  "workspace.reload.error.paymentFailed": "Ошибка оплаты.",
 
 
   "workspace.payments.title": "История платежей",
   "workspace.payments.title": "История платежей",
   "workspace.payments.subtitle": "Недавние транзакции.",
   "workspace.payments.subtitle": "Недавние транзакции.",
@@ -574,6 +602,10 @@ export const dict = {
   "enterprise.form.send": "Отправить",
   "enterprise.form.send": "Отправить",
   "enterprise.form.sending": "Отправка...",
   "enterprise.form.sending": "Отправка...",
   "enterprise.form.success": "Сообщение отправлено, мы скоро свяжемся с вами.",
   "enterprise.form.success": "Сообщение отправлено, мы скоро свяжемся с вами.",
+  "enterprise.form.success.submitted": "Форма успешно отправлена.",
+  "enterprise.form.error.allFieldsRequired": "Все поля обязательны.",
+  "enterprise.form.error.invalidEmailFormat": "Неверный формат email.",
+  "enterprise.form.error.internalServer": "Внутренняя ошибка сервера.",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.title": "FAQ",
   "enterprise.faq.q1": "Что такое OpenCode Enterprise?",
   "enterprise.faq.q1": "Что такое OpenCode Enterprise?",
   "enterprise.faq.a1":
   "enterprise.faq.a1":
@@ -606,6 +638,7 @@ export const dict = {
   "bench.list.table.agent": "Агент",
   "bench.list.table.agent": "Агент",
   "bench.list.table.model": "Модель",
   "bench.list.table.model": "Модель",
   "bench.list.table.score": "Оценка",
   "bench.list.table.score": "Оценка",
+  "bench.submission.error.allFieldsRequired": "Все поля обязательны.",
 
 
   "bench.detail.title": "Бенчмарк - {{task}}",
   "bench.detail.title": "Бенчмарк - {{task}}",
   "bench.detail.notFound": "Задача не найдена",
   "bench.detail.notFound": "Задача не найдена",

+ 33 - 0
packages/console/app/src/i18n/th.ts

@@ -15,6 +15,7 @@ export const dict = {
   "nav.home": "หน้าหลัก",
   "nav.home": "หน้าหลัก",
   "nav.openMenu": "เปิดเมนู",
   "nav.openMenu": "เปิดเมนู",
   "nav.getStartedFree": "เริ่มต้นฟรี",
   "nav.getStartedFree": "เริ่มต้นฟรี",
+  "nav.logoAlt": "OpenCode",
 
 
   "nav.context.copyLogo": "คัดลอกโลโก้เป็น SVG",
   "nav.context.copyLogo": "คัดลอกโลโก้เป็น SVG",
   "nav.context.copyWordmark": "คัดลอกตัวอักษรแบรนด์เป็น SVG",
   "nav.context.copyWordmark": "คัดลอกตัวอักษรแบรนด์เป็น SVG",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "เอกสาร",
   "notFound.docs": "เอกสาร",
   "notFound.github": "GitHub",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "โลโก้ opencode แบบสว่าง",
+  "notFound.logoDarkAlt": "โลโก้ opencode แบบมืด",
 
 
   "user.logout": "ออกจากระบบ",
   "user.logout": "ออกจากระบบ",
 
 
+  "auth.callback.error.codeMissing": "ไม่พบ authorization code",
+
   "workspace.select": "เลือก Workspace",
   "workspace.select": "เลือก Workspace",
   "workspace.createNew": "+ สร้าง Workspace ใหม่",
   "workspace.createNew": "+ สร้าง Workspace ใหม่",
   "workspace.modal.title": "สร้าง Workspace ใหม่",
   "workspace.modal.title": "สร้าง Workspace ใหม่",
@@ -76,6 +81,8 @@ export const dict = {
   "error.reloadAmountMin": "จำนวนเงินที่โหลดซ้ำต้องมีอย่างน้อย ${{amount}}",
   "error.reloadAmountMin": "จำนวนเงินที่โหลดซ้ำต้องมีอย่างน้อย ${{amount}}",
   "error.reloadTriggerMin": "ยอดคงเหลือที่กระตุ้นต้องมีอย่างน้อย ${{amount}}",
   "error.reloadTriggerMin": "ยอดคงเหลือที่กระตุ้นต้องมีอย่างน้อย ${{amount}}",
 
 
+  "app.meta.description": "OpenCode - เอเจนต์เขียนโค้ดแบบโอเพนซอร์ส",
+
   "home.title": "OpenCode | เอเจนต์เขียนโค้ดด้วย AI แบบโอเพนซอร์ส",
   "home.title": "OpenCode | เอเจนต์เขียนโค้ดด้วย AI แบบโอเพนซอร์ส",
 
 
   "temp.title": "OpenCode | เอเจนต์เขียนโค้ด AI ที่สร้างมาเพื่อเทอร์มินัล",
   "temp.title": "OpenCode | เอเจนต์เขียนโค้ด AI ที่สร้างมาเพื่อเทอร์มินัล",
@@ -91,6 +98,8 @@ export const dict = {
   "temp.feature.models.afterLink": "รวมถึงโมเดล Local",
   "temp.feature.models.afterLink": "รวมถึงโมเดล Local",
   "temp.screenshot.caption": "OpenCode TUI พร้อมธีม tokyonight",
   "temp.screenshot.caption": "OpenCode TUI พร้อมธีม tokyonight",
   "temp.screenshot.alt": "OpenCode TUI พร้อมธีม tokyonight",
   "temp.screenshot.alt": "OpenCode TUI พร้อมธีม tokyonight",
+  "temp.logoLightAlt": "โลโก้ opencode แบบสว่าง",
+  "temp.logoDarkAlt": "โลโก้ opencode แบบมืด",
 
 
   "home.banner.badge": "ใหม่",
   "home.banner.badge": "ใหม่",
   "home.banner.text": "แอปเดสก์ท็อปพร้อมใช้งานในเวอร์ชันเบต้า",
   "home.banner.text": "แอปเดสก์ท็อปพร้อมใช้งานในเวอร์ชันเบต้า",
@@ -239,6 +248,24 @@ export const dict = {
     "โมเดล Zen ทั้งหมดโฮสต์ในสหรัฐอเมริกา ผู้ให้บริการปฏิบัติตามนโยบายไม่เก็บรักษาข้อมูล (zero-retention policy) และไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล โดยมี",
     "โมเดล Zen ทั้งหมดโฮสต์ในสหรัฐอเมริกา ผู้ให้บริการปฏิบัติตามนโยบายไม่เก็บรักษาข้อมูล (zero-retention policy) และไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล โดยมี",
   "zen.privacy.exceptionsLink": "ข้อยกเว้นดังนี้",
   "zen.privacy.exceptionsLink": "ข้อยกเว้นดังนี้",
 
 
+  "zen.api.error.rateLimitExceeded": "เกินขีดจำกัดอัตราการใช้งาน กรุณาลองใหม่ในภายหลัง",
+  "zen.api.error.modelNotSupported": "ไม่รองรับโมเดล {{model}}",
+  "zen.api.error.modelFormatNotSupported": "ไม่รองรับโมเดล {{model}} สำหรับรูปแบบ {{format}}",
+  "zen.api.error.noProviderAvailable": "ไม่มีผู้ให้บริการที่พร้อมใช้งาน",
+  "zen.api.error.providerNotSupported": "ไม่รองรับผู้ให้บริการ {{provider}}",
+  "zen.api.error.missingApiKey": "ไม่มี API key",
+  "zen.api.error.invalidApiKey": "API key ไม่ถูกต้อง",
+  "zen.api.error.subscriptionQuotaExceeded": "โควต้าการสมัครสมาชิกเกินขีดจำกัด ลองใหม่ในอีก {{retryIn}}",
+  "zen.api.error.subscriptionQuotaExceededUseFreeModels":
+    "โควต้าการสมัครสมาชิกเกินขีดจำกัด คุณสามารถดำเนินการต่อโดยใช้โมเดลฟรี",
+  "zen.api.error.noPaymentMethod": "ไม่มีวิธีการชำระเงิน เพิ่มวิธีการชำระเงินที่นี่: {{billingUrl}}",
+  "zen.api.error.insufficientBalance": "ยอดเงินคงเหลือไม่เพียงพอ จัดการการเรียกเก็บเงินของคุณที่นี่: {{billingUrl}}",
+  "zen.api.error.workspaceMonthlyLimitReached":
+    "Workspace ของคุณถึงขีดจำกัดการใช้จ่ายรายเดือนที่ ${{amount}} แล้ว จัดการขีดจำกัดของคุณที่นี่: {{billingUrl}}",
+  "zen.api.error.userMonthlyLimitReached":
+    "คุณถึงขีดจำกัดการใช้จ่ายรายเดือนที่ ${{amount}} แล้ว จัดการขีดจำกัดของคุณที่นี่: {{membersUrl}}",
+  "zen.api.error.modelDisabled": "โมเดลถูกปิดใช้งาน",
+
   "black.meta.title": "OpenCode Black | เข้าถึงโมเดลเขียนโค้ดที่ดีที่สุดในโลก",
   "black.meta.title": "OpenCode Black | เข้าถึงโมเดลเขียนโค้ดที่ดีที่สุดในโลก",
   "black.meta.description": "เข้าถึง Claude, GPT, Gemini และอื่นๆ ด้วยแผนสมาชิก OpenCode Black",
   "black.meta.description": "เข้าถึง Claude, GPT, Gemini และอื่นๆ ด้วยแผนสมาชิก OpenCode Black",
   "black.hero.title": "เข้าถึงโมเดลเขียนโค้ดที่ดีที่สุดในโลก",
   "black.hero.title": "เข้าถึงโมเดลเขียนโค้ดที่ดีที่สุดในโลก",
@@ -448,6 +475,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "โปรดอัปเดตวิธีการชำระเงินของคุณแล้วลองอีกครั้ง",
   "workspace.reload.updatePaymentMethod": "โปรดอัปเดตวิธีการชำระเงินของคุณแล้วลองอีกครั้ง",
   "workspace.reload.retrying": "กำลังลองอีกครั้ง...",
   "workspace.reload.retrying": "กำลังลองอีกครั้ง...",
   "workspace.reload.retry": "ลองอีกครั้ง",
   "workspace.reload.retry": "ลองอีกครั้ง",
+  "workspace.reload.error.paymentFailed": "การชำระเงินล้มเหลว",
 
 
   "workspace.payments.title": "ประวัติการชำระเงิน",
   "workspace.payments.title": "ประวัติการชำระเงิน",
   "workspace.payments.subtitle": "รายการธุรกรรมการชำระเงินล่าสุด",
   "workspace.payments.subtitle": "รายการธุรกรรมการชำระเงินล่าสุด",
@@ -566,6 +594,10 @@ export const dict = {
   "enterprise.form.send": "ส่ง",
   "enterprise.form.send": "ส่ง",
   "enterprise.form.sending": "กำลังส่ง...",
   "enterprise.form.sending": "กำลังส่ง...",
   "enterprise.form.success": "ส่งข้อความแล้ว เราจะติดต่อกลับเร็วๆ นี้",
   "enterprise.form.success": "ส่งข้อความแล้ว เราจะติดต่อกลับเร็วๆ นี้",
+  "enterprise.form.success.submitted": "ส่งแบบฟอร์มสำเร็จแล้ว",
+  "enterprise.form.error.allFieldsRequired": "จำเป็นต้องกรอกทุกช่อง",
+  "enterprise.form.error.invalidEmailFormat": "รูปแบบอีเมลไม่ถูกต้อง",
+  "enterprise.form.error.internalServer": "เกิดข้อผิดพลาดภายในเซิร์ฟเวอร์",
   "enterprise.faq.title": "คำถามที่พบบ่อย",
   "enterprise.faq.title": "คำถามที่พบบ่อย",
   "enterprise.faq.q1": "OpenCode Enterprise คืออะไร?",
   "enterprise.faq.q1": "OpenCode Enterprise คืออะไร?",
   "enterprise.faq.a1":
   "enterprise.faq.a1":
@@ -598,6 +630,7 @@ export const dict = {
   "bench.list.table.agent": "เอเจนต์",
   "bench.list.table.agent": "เอเจนต์",
   "bench.list.table.model": "โมเดล",
   "bench.list.table.model": "โมเดล",
   "bench.list.table.score": "คะแนน",
   "bench.list.table.score": "คะแนน",
+  "bench.submission.error.allFieldsRequired": "จำเป็นต้องกรอกทุกช่อง",
 
 
   "bench.detail.title": "Benchmark - {{task}}",
   "bench.detail.title": "Benchmark - {{task}}",
   "bench.detail.notFound": "ไม่พบงาน",
   "bench.detail.notFound": "ไม่พบงาน",

+ 39 - 6
packages/console/app/src/i18n/tr.ts

@@ -15,6 +15,7 @@ export const dict = {
   "nav.home": "Ana sayfa",
   "nav.home": "Ana sayfa",
   "nav.openMenu": "Menüyü aç",
   "nav.openMenu": "Menüyü aç",
   "nav.getStartedFree": "Ücretsiz başla",
   "nav.getStartedFree": "Ücretsiz başla",
+  "nav.logoAlt": "OpenCode",
 
 
   "nav.context.copyLogo": "Logoyu SVG olarak kopyala",
   "nav.context.copyLogo": "Logoyu SVG olarak kopyala",
   "nav.context.copyWordmark": "Wordmark'ı SVG olarak kopyala",
   "nav.context.copyWordmark": "Wordmark'ı SVG olarak kopyala",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "Dokümantasyon",
   "notFound.docs": "Dokümantasyon",
   "notFound.github": "GitHub",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "opencode açık logo",
+  "notFound.logoDarkAlt": "opencode koyu logo",
 
 
   "user.logout": "Çıkış",
   "user.logout": "Çıkış",
 
 
+  "auth.callback.error.codeMissing": "Yetkilendirme kodu bulunamadı.",
+
   "workspace.select": "Çalışma alanı seç",
   "workspace.select": "Çalışma alanı seç",
   "workspace.createNew": "+ Yeni çalışma alanı oluştur",
   "workspace.createNew": "+ Yeni çalışma alanı oluştur",
   "workspace.modal.title": "Yeni çalışma alanı oluştur",
   "workspace.modal.title": "Yeni çalışma alanı oluştur",
@@ -76,6 +81,8 @@ export const dict = {
   "error.reloadAmountMin": "Yükleme tutarı en az ${{amount}} olmalıdır",
   "error.reloadAmountMin": "Yükleme tutarı en az ${{amount}} olmalıdır",
   "error.reloadTriggerMin": "Bakiye tetikleyicisi en az ${{amount}} olmalıdır",
   "error.reloadTriggerMin": "Bakiye tetikleyicisi en az ${{amount}} olmalıdır",
 
 
+  "app.meta.description": "OpenCode - Açık kaynaklı kodlama ajanı.",
+
   "home.title": "OpenCode | Açık kaynaklı yapay zeka kodlama ajanı",
   "home.title": "OpenCode | Açık kaynaklı yapay zeka kodlama ajanı",
 
 
   "temp.title": "opencode | Terminal için geliştirilmiş yapay zeka kodlama ajanı",
   "temp.title": "opencode | Terminal için geliştirilmiş yapay zeka kodlama ajanı",
@@ -91,9 +98,11 @@ export const dict = {
   "temp.feature.models.afterLink": " üzerinden destekler",
   "temp.feature.models.afterLink": " üzerinden destekler",
   "temp.screenshot.caption": "opencode TUI ve tokyonight teması",
   "temp.screenshot.caption": "opencode TUI ve tokyonight teması",
   "temp.screenshot.alt": "tokyonight temalı opencode TUI",
   "temp.screenshot.alt": "tokyonight temalı opencode TUI",
+  "temp.logoLightAlt": "opencode açık logo",
+  "temp.logoDarkAlt": "opencode koyu logo",
 
 
   "home.banner.badge": "Yeni",
   "home.banner.badge": "Yeni",
-  "home.banner.text": "Masaüstü uygulaması beta olarak kullanılabilir",
+  "home.banner.text": "Masaüstü uygulaması beta olarak mevcut",
   "home.banner.platforms": "macOS, Windows ve Linux'ta",
   "home.banner.platforms": "macOS, Windows ve Linux'ta",
   "home.banner.downloadNow": "Şimdi indir",
   "home.banner.downloadNow": "Şimdi indir",
   "home.banner.downloadBetaNow": "Masaüstü betayı şimdi indir",
   "home.banner.downloadBetaNow": "Masaüstü betayı şimdi indir",
@@ -130,7 +139,7 @@ export const dict = {
   "home.growth.contributors": "Katılımcılar",
   "home.growth.contributors": "Katılımcılar",
   "home.growth.monthlyDevs": "Aylık Geliştiriciler",
   "home.growth.monthlyDevs": "Aylık Geliştiriciler",
 
 
-  "home.privacy.title": "Önce gizlilik için tasarlandı",
+  "home.privacy.title": "Gizlilik öncelikli tasarlandı",
   "home.privacy.body":
   "home.privacy.body":
     "OpenCode kodunuzu veya bağlam verilerinizi saklamaz; bu sayede gizliliğe duyarlı ortamlarda çalışabilir.",
     "OpenCode kodunuzu veya bağlam verilerinizi saklamaz; bu sayede gizliliğe duyarlı ortamlarda çalışabilir.",
   "home.privacy.learnMore": "Hakkında daha fazla bilgi:",
   "home.privacy.learnMore": "Hakkında daha fazla bilgi:",
@@ -148,12 +157,12 @@ export const dict = {
   "home.faq.a3.p2.afterZen": " hesabı oluşturabilirsiniz.",
   "home.faq.a3.p2.afterZen": " hesabı oluşturabilirsiniz.",
   "home.faq.a3.p3": "Zen'i öneriyoruz, ancak OpenCode OpenAI, Anthropic, xAI gibi popüler sağlayıcılarla da çalışır.",
   "home.faq.a3.p3": "Zen'i öneriyoruz, ancak OpenCode OpenAI, Anthropic, xAI gibi popüler sağlayıcılarla da çalışır.",
   "home.faq.a3.p4.beforeLocal": "Hatta",
   "home.faq.a3.p4.beforeLocal": "Hatta",
-  "home.faq.a3.p4.localLink": "yerel modellerinizi",
+  "home.faq.a3.p4.localLink": "yerel modellerinizi bağlayabilirsiniz",
   "home.faq.q4": "Mevcut AI aboneliklerimi OpenCode ile kullanabilir miyim?",
   "home.faq.q4": "Mevcut AI aboneliklerimi OpenCode ile kullanabilir miyim?",
   "home.faq.a4.p1":
   "home.faq.a4.p1":
     "Evet. OpenCode tüm büyük sağlayıcıların aboneliklerini destekler. Claude Pro/Max, ChatGPT Plus/Pro veya GitHub Copilot kullanabilirsiniz.",
     "Evet. OpenCode tüm büyük sağlayıcıların aboneliklerini destekler. Claude Pro/Max, ChatGPT Plus/Pro veya GitHub Copilot kullanabilirsiniz.",
   "home.faq.q5": "OpenCode'u sadece terminalde mi kullanabilirim?",
   "home.faq.q5": "OpenCode'u sadece terminalde mi kullanabilirim?",
-  "home.faq.a5.beforeDesktop": "Artık hayır! OpenCode şimdi",
+  "home.faq.a5.beforeDesktop": "Artık hayır! OpenCode artık sizin bu cihazlarınıza",
   "home.faq.a5.desktop": "masaüstü",
   "home.faq.a5.desktop": "masaüstü",
   "home.faq.a5.and": "ve",
   "home.faq.a5.and": "ve",
   "home.faq.a5.web": "web",
   "home.faq.a5.web": "web",
@@ -169,10 +178,10 @@ export const dict = {
   "home.faq.a7.p2.shareLink": "paylaşım sayfaları",
   "home.faq.a7.p2.shareLink": "paylaşım sayfaları",
   "home.faq.q8": "OpenCode açık kaynak mı?",
   "home.faq.q8": "OpenCode açık kaynak mı?",
   "home.faq.a8.p1": "Evet, OpenCode tamamen açık kaynaktır. Kaynak kodu",
   "home.faq.a8.p1": "Evet, OpenCode tamamen açık kaynaktır. Kaynak kodu",
-  "home.faq.a8.p2": "altında",
+  "home.faq.a8.p2": "'da",
   "home.faq.a8.mitLicense": "MIT Lisansı",
   "home.faq.a8.mitLicense": "MIT Lisansı",
   "home.faq.a8.p3":
   "home.faq.a8.p3":
-    ", yani herkes kullanabilir, değiştirebilir veya geliştirmeye katkıda bulunabilir. Topluluktan herkes issue açabilir, pull request gönderebilir ve işlevselliği genişletebilir.",
+    "altında herkese açıktır, yani herkes kullanabilir, değiştirebilir veya geliştirmeye katkıda bulunabilir. Topluluktan herkes issue açabilir, pull request gönderebilir ve işlevselliği genişletebilir.",
 
 
   "home.zenCta.title": "Kodlama ajanları için güvenilir, optimize modeller",
   "home.zenCta.title": "Kodlama ajanları için güvenilir, optimize modeller",
   "home.zenCta.body":
   "home.zenCta.body":
@@ -242,6 +251,24 @@ export const dict = {
     "Tüm Zen modelleri ABD'de barındırılmaktadır. Sağlayıcılar sıfır saklama politikası izler ve verilerinizi model eğitimi için kullanmaz; şu",
     "Tüm Zen modelleri ABD'de barındırılmaktadır. Sağlayıcılar sıfır saklama politikası izler ve verilerinizi model eğitimi için kullanmaz; şu",
   "zen.privacy.exceptionsLink": "aşağıdaki istisnalar",
   "zen.privacy.exceptionsLink": "aşağıdaki istisnalar",
 
 
+  "zen.api.error.rateLimitExceeded": "İstek limiti aşıldı. Lütfen daha sonra tekrar deneyin.",
+  "zen.api.error.modelNotSupported": "{{model}} modeli desteklenmiyor",
+  "zen.api.error.modelFormatNotSupported": "{{model}} modeli {{format}} formatı için desteklenmiyor",
+  "zen.api.error.noProviderAvailable": "Kullanılabilir sağlayıcı yok",
+  "zen.api.error.providerNotSupported": "{{provider}} sağlayıcısı desteklenmiyor",
+  "zen.api.error.missingApiKey": "API anahtarı eksik.",
+  "zen.api.error.invalidApiKey": "Geçersiz API anahtarı.",
+  "zen.api.error.subscriptionQuotaExceeded": "Abonelik kotası aşıldı. {{retryIn}} içinde tekrar deneyin.",
+  "zen.api.error.subscriptionQuotaExceededUseFreeModels":
+    "Abonelik kotası aşıldı. Ücretsiz modelleri kullanmaya devam edebilirsiniz.",
+  "zen.api.error.noPaymentMethod": "Ödeme yöntemi bulunamadı. Buradan bir ödeme yöntemi ekleyin: {{billingUrl}}",
+  "zen.api.error.insufficientBalance": "Yetersiz bakiye. Faturalandırmanızı buradan yönetin: {{billingUrl}}",
+  "zen.api.error.workspaceMonthlyLimitReached":
+    "Çalışma alanınız aylık ${{amount}} harcama limitine ulaştı. Limitlerinizi buradan yönetin: {{billingUrl}}",
+  "zen.api.error.userMonthlyLimitReached":
+    "Aylık ${{amount}} harcama limitinize ulaştınız. Limitlerinizi buradan yönetin: {{membersUrl}}",
+  "zen.api.error.modelDisabled": "Model devre dışı",
+
   "black.meta.title": "OpenCode Black | Dünyanın en iyi kodlama modellerine erişin",
   "black.meta.title": "OpenCode Black | Dünyanın en iyi kodlama modellerine erişin",
   "black.meta.description": "OpenCode Black abonelik planlarıyla Claude, GPT, Gemini ve daha fazlasına erişin.",
   "black.meta.description": "OpenCode Black abonelik planlarıyla Claude, GPT, Gemini ve daha fazlasına erişin.",
   "black.hero.title": "Dünyanın en iyi kodlama modellerine erişin",
   "black.hero.title": "Dünyanın en iyi kodlama modellerine erişin",
@@ -451,6 +478,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "Lütfen ödeme yönteminizi güncelleyin ve tekrar deneyin.",
   "workspace.reload.updatePaymentMethod": "Lütfen ödeme yönteminizi güncelleyin ve tekrar deneyin.",
   "workspace.reload.retrying": "Yeniden deneniyor...",
   "workspace.reload.retrying": "Yeniden deneniyor...",
   "workspace.reload.retry": "Yeniden dene",
   "workspace.reload.retry": "Yeniden dene",
+  "workspace.reload.error.paymentFailed": "Ödeme başarısız.",
 
 
   "workspace.payments.title": "Ödeme Geçmişi",
   "workspace.payments.title": "Ödeme Geçmişi",
   "workspace.payments.subtitle": "Son ödeme işlemleri.",
   "workspace.payments.subtitle": "Son ödeme işlemleri.",
@@ -571,6 +599,10 @@ export const dict = {
   "enterprise.form.send": "Gönder",
   "enterprise.form.send": "Gönder",
   "enterprise.form.sending": "Gönderiliyor...",
   "enterprise.form.sending": "Gönderiliyor...",
   "enterprise.form.success": "Mesaj gönderildi, yakında size dönüş yapacağız.",
   "enterprise.form.success": "Mesaj gönderildi, yakında size dönüş yapacağız.",
+  "enterprise.form.success.submitted": "Form başarıyla gönderildi.",
+  "enterprise.form.error.allFieldsRequired": "Tüm alanlar gereklidir.",
+  "enterprise.form.error.invalidEmailFormat": "Geçersiz e-posta formatı.",
+  "enterprise.form.error.internalServer": "İç sunucu hatası.",
   "enterprise.faq.title": "SSS",
   "enterprise.faq.title": "SSS",
   "enterprise.faq.q1": "OpenCode Enterprise nedir?",
   "enterprise.faq.q1": "OpenCode Enterprise nedir?",
   "enterprise.faq.a1":
   "enterprise.faq.a1":
@@ -603,6 +635,7 @@ export const dict = {
   "bench.list.table.agent": "Ajan",
   "bench.list.table.agent": "Ajan",
   "bench.list.table.model": "Model",
   "bench.list.table.model": "Model",
   "bench.list.table.score": "Puan",
   "bench.list.table.score": "Puan",
+  "bench.submission.error.allFieldsRequired": "Tüm alanlar gereklidir.",
 
 
   "bench.detail.title": "Benchmark - {{task}}",
   "bench.detail.title": "Benchmark - {{task}}",
   "bench.detail.notFound": "Görev bulunamadı",
   "bench.detail.notFound": "Görev bulunamadı",

+ 31 - 0
packages/console/app/src/i18n/zh.ts

@@ -15,6 +15,7 @@ export const dict = {
   "nav.home": "首页",
   "nav.home": "首页",
   "nav.openMenu": "打开菜单",
   "nav.openMenu": "打开菜单",
   "nav.getStartedFree": "免费开始",
   "nav.getStartedFree": "免费开始",
+  "nav.logoAlt": "OpenCode",
 
 
   "nav.context.copyLogo": "复制 Logo (SVG)",
   "nav.context.copyLogo": "复制 Logo (SVG)",
   "nav.context.copyWordmark": "复制商标 (SVG)",
   "nav.context.copyWordmark": "复制商标 (SVG)",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "文档",
   "notFound.docs": "文档",
   "notFound.github": "GitHub",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "opencode logo 亮色",
+  "notFound.logoDarkAlt": "opencode logo 暗色",
 
 
   "user.logout": "退出登录",
   "user.logout": "退出登录",
 
 
+  "auth.callback.error.codeMissing": "未找到授权码。",
+
   "workspace.select": "选择工作区",
   "workspace.select": "选择工作区",
   "workspace.createNew": "+ 新建工作区",
   "workspace.createNew": "+ 新建工作区",
   "workspace.modal.title": "新建工作区",
   "workspace.modal.title": "新建工作区",
@@ -76,6 +81,8 @@ export const dict = {
   "error.reloadAmountMin": "充值金额必须至少为 ${{amount}}",
   "error.reloadAmountMin": "充值金额必须至少为 ${{amount}}",
   "error.reloadTriggerMin": "余额触发阈值必须至少为 ${{amount}}",
   "error.reloadTriggerMin": "余额触发阈值必须至少为 ${{amount}}",
 
 
+  "app.meta.description": "OpenCode - 开源编程代理。",
+
   "home.title": "OpenCode | 开源 AI 编程代理",
   "home.title": "OpenCode | 开源 AI 编程代理",
 
 
   "temp.title": "OpenCode | 专为终端打造的 AI 编程代理",
   "temp.title": "OpenCode | 专为终端打造的 AI 编程代理",
@@ -91,6 +98,8 @@ export const dict = {
   "temp.feature.models.afterLink": ",包括本地模型",
   "temp.feature.models.afterLink": ",包括本地模型",
   "temp.screenshot.caption": "使用 Tokyonight 主题的 OpenCode TUI",
   "temp.screenshot.caption": "使用 Tokyonight 主题的 OpenCode TUI",
   "temp.screenshot.alt": "使用 Tokyonight 主题的 OpenCode TUI",
   "temp.screenshot.alt": "使用 Tokyonight 主题的 OpenCode TUI",
+  "temp.logoLightAlt": "opencode logo 亮色",
+  "temp.logoDarkAlt": "opencode logo 暗色",
 
 
   "home.banner.badge": "新",
   "home.banner.badge": "新",
   "home.banner.text": "桌面应用 Beta 版现已推出",
   "home.banner.text": "桌面应用 Beta 版现已推出",
@@ -229,6 +238,22 @@ export const dict = {
   "zen.privacy.beforeExceptions": "所有 Zen 模型均托管在美国。提供商遵循零留存政策,不使用您的数据进行模型训练,",
   "zen.privacy.beforeExceptions": "所有 Zen 模型均托管在美国。提供商遵循零留存政策,不使用您的数据进行模型训练,",
   "zen.privacy.exceptionsLink": "以下例外情况除外",
   "zen.privacy.exceptionsLink": "以下例外情况除外",
 
 
+  "zen.api.error.rateLimitExceeded": "超出速率限制。请稍后重试。",
+  "zen.api.error.modelNotSupported": "不支持模型 {{model}}",
+  "zen.api.error.modelFormatNotSupported": "格式 {{format}} 不支持模型 {{model}}",
+  "zen.api.error.noProviderAvailable": "没有可用的提供商",
+  "zen.api.error.providerNotSupported": "不支持提供商 {{provider}}",
+  "zen.api.error.missingApiKey": "缺少 API 密钥。",
+  "zen.api.error.invalidApiKey": "无效的 API 密钥。",
+  "zen.api.error.subscriptionQuotaExceeded": "超出订阅配额。请在 {{retryIn}} 后重试。",
+  "zen.api.error.subscriptionQuotaExceededUseFreeModels": "超出订阅配额。您可以继续使用免费模型。",
+  "zen.api.error.noPaymentMethod": "没有付款方式。请在此处添加付款方式:{{billingUrl}}",
+  "zen.api.error.insufficientBalance": "余额不足。请在此处管理您的计费:{{billingUrl}}",
+  "zen.api.error.workspaceMonthlyLimitReached":
+    "您的工作区已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{billingUrl}}",
+  "zen.api.error.userMonthlyLimitReached": "您已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{membersUrl}}",
+  "zen.api.error.modelDisabled": "模型已禁用",
+
   "black.meta.title": "OpenCode Black | 访问全球顶尖编程模型",
   "black.meta.title": "OpenCode Black | 访问全球顶尖编程模型",
   "black.meta.description": "通过 OpenCode Black 订阅计划使用 Claude, GPT, Gemini 等模型。",
   "black.meta.description": "通过 OpenCode Black 订阅计划使用 Claude, GPT, Gemini 等模型。",
   "black.hero.title": "访问全球顶尖编程模型",
   "black.hero.title": "访问全球顶尖编程模型",
@@ -436,6 +461,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "请更新您的付款方式并重试。",
   "workspace.reload.updatePaymentMethod": "请更新您的付款方式并重试。",
   "workspace.reload.retrying": "正在重试...",
   "workspace.reload.retrying": "正在重试...",
   "workspace.reload.retry": "重试",
   "workspace.reload.retry": "重试",
+  "workspace.reload.error.paymentFailed": "支付失败。",
 
 
   "workspace.payments.title": "支付历史",
   "workspace.payments.title": "支付历史",
   "workspace.payments.subtitle": "近期支付交易。",
   "workspace.payments.subtitle": "近期支付交易。",
@@ -552,6 +578,10 @@ export const dict = {
   "enterprise.form.send": "发送",
   "enterprise.form.send": "发送",
   "enterprise.form.sending": "正在发送...",
   "enterprise.form.sending": "正在发送...",
   "enterprise.form.success": "消息已发送,我们会尽快与您联系。",
   "enterprise.form.success": "消息已发送,我们会尽快与您联系。",
+  "enterprise.form.success.submitted": "表单提交成功。",
+  "enterprise.form.error.allFieldsRequired": "所有字段均为必填项。",
+  "enterprise.form.error.invalidEmailFormat": "邮箱格式无效。",
+  "enterprise.form.error.internalServer": "内部服务器错误。",
   "enterprise.faq.title": "常见问题",
   "enterprise.faq.title": "常见问题",
   "enterprise.faq.q1": "什么是 OpenCode 企业版?",
   "enterprise.faq.q1": "什么是 OpenCode 企业版?",
   "enterprise.faq.a1":
   "enterprise.faq.a1":
@@ -584,6 +614,7 @@ export const dict = {
   "bench.list.table.agent": "代理",
   "bench.list.table.agent": "代理",
   "bench.list.table.model": "模型",
   "bench.list.table.model": "模型",
   "bench.list.table.score": "分数",
   "bench.list.table.score": "分数",
+  "bench.submission.error.allFieldsRequired": "所有字段均为必填项。",
 
 
   "bench.detail.title": "基准测试 - {{task}}",
   "bench.detail.title": "基准测试 - {{task}}",
   "bench.detail.notFound": "未找到任务",
   "bench.detail.notFound": "未找到任务",

+ 31 - 0
packages/console/app/src/i18n/zht.ts

@@ -15,6 +15,7 @@ export const dict = {
   "nav.home": "首頁",
   "nav.home": "首頁",
   "nav.openMenu": "開啟選單",
   "nav.openMenu": "開啟選單",
   "nav.getStartedFree": "免費開始使用",
   "nav.getStartedFree": "免費開始使用",
+  "nav.logoAlt": "OpenCode",
 
 
   "nav.context.copyLogo": "複製標誌(SVG)",
   "nav.context.copyLogo": "複製標誌(SVG)",
   "nav.context.copyWordmark": "複製字標(SVG)",
   "nav.context.copyWordmark": "複製字標(SVG)",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "文件",
   "notFound.docs": "文件",
   "notFound.github": "GitHub",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "opencode 淺色標誌",
+  "notFound.logoDarkAlt": "opencode 深色標誌",
 
 
   "user.logout": "登出",
   "user.logout": "登出",
 
 
+  "auth.callback.error.codeMissing": "找不到授權碼。",
+
   "workspace.select": "選取工作區",
   "workspace.select": "選取工作區",
   "workspace.createNew": "+ 建立新工作區",
   "workspace.createNew": "+ 建立新工作區",
   "workspace.modal.title": "建立新工作區",
   "workspace.modal.title": "建立新工作區",
@@ -76,6 +81,8 @@ export const dict = {
   "error.reloadAmountMin": "儲值金額必須至少為 ${{amount}}",
   "error.reloadAmountMin": "儲值金額必須至少為 ${{amount}}",
   "error.reloadTriggerMin": "餘額觸發門檻必須至少為 ${{amount}}",
   "error.reloadTriggerMin": "餘額觸發門檻必須至少為 ${{amount}}",
 
 
+  "app.meta.description": "OpenCode - 開源編碼代理。",
+
   "home.title": "OpenCode | 開源 AI 編碼代理",
   "home.title": "OpenCode | 開源 AI 編碼代理",
 
 
   "temp.title": "OpenCode | 專為終端打造的 AI 編碼代理",
   "temp.title": "OpenCode | 專為終端打造的 AI 編碼代理",
@@ -91,6 +98,8 @@ export const dict = {
   "temp.feature.models.afterLink": "支援 75+ 家 LLM 供應商,包括本地模型",
   "temp.feature.models.afterLink": "支援 75+ 家 LLM 供應商,包括本地模型",
   "temp.screenshot.caption": "使用 tokyonight 主題的 OpenCode TUI",
   "temp.screenshot.caption": "使用 tokyonight 主題的 OpenCode TUI",
   "temp.screenshot.alt": "使用 tokyonight 主題的 OpenCode TUI",
   "temp.screenshot.alt": "使用 tokyonight 主題的 OpenCode TUI",
+  "temp.logoLightAlt": "opencode 淺色標誌",
+  "temp.logoDarkAlt": "opencode 深色標誌",
 
 
   "home.banner.badge": "新",
   "home.banner.badge": "新",
   "home.banner.text": "桌面應用已推出 Beta",
   "home.banner.text": "桌面應用已推出 Beta",
@@ -229,6 +238,22 @@ export const dict = {
   "zen.privacy.beforeExceptions": "所有 Zen 模型均在美國託管。供應商遵循零留存政策,不會將你的資料用於模型訓練,並且有",
   "zen.privacy.beforeExceptions": "所有 Zen 模型均在美國託管。供應商遵循零留存政策,不會將你的資料用於模型訓練,並且有",
   "zen.privacy.exceptionsLink": "以下例外情況",
   "zen.privacy.exceptionsLink": "以下例外情況",
 
 
+  "zen.api.error.rateLimitExceeded": "超出頻率限制。請稍後再試。",
+  "zen.api.error.modelNotSupported": "不支援模型 {{model}}",
+  "zen.api.error.modelFormatNotSupported": "模型 {{model}} 不支援格式 {{format}}",
+  "zen.api.error.noProviderAvailable": "無可用的供應商",
+  "zen.api.error.providerNotSupported": "不支援供應商 {{provider}}",
+  "zen.api.error.missingApiKey": "缺少 API 金鑰。",
+  "zen.api.error.invalidApiKey": "無效的 API 金鑰。",
+  "zen.api.error.subscriptionQuotaExceeded": "超出訂閱配額。請在 {{retryIn}} 後重試。",
+  "zen.api.error.subscriptionQuotaExceededUseFreeModels": "超出訂閱配額。你可以繼續使用免費模型。",
+  "zen.api.error.noPaymentMethod": "無付款方式。請在此處新增付款方式:{{billingUrl}}",
+  "zen.api.error.insufficientBalance": "餘額不足。請在此處管理你的帳務:{{billingUrl}}",
+  "zen.api.error.workspaceMonthlyLimitReached":
+    "你的工作區已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{billingUrl}}",
+  "zen.api.error.userMonthlyLimitReached": "你已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{membersUrl}}",
+  "zen.api.error.modelDisabled": "模型已停用",
+
   "black.meta.title": "OpenCode Black | 存取全球最佳編碼模型",
   "black.meta.title": "OpenCode Black | 存取全球最佳編碼模型",
   "black.meta.description": "透過 OpenCode Black 訂閱方案存取 Claude、GPT、Gemini 等模型。",
   "black.meta.description": "透過 OpenCode Black 訂閱方案存取 Claude、GPT、Gemini 等模型。",
   "black.hero.title": "存取全球最佳編碼模型",
   "black.hero.title": "存取全球最佳編碼模型",
@@ -436,6 +461,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "請更新你的付款方式並重試。",
   "workspace.reload.updatePaymentMethod": "請更新你的付款方式並重試。",
   "workspace.reload.retrying": "重試中...",
   "workspace.reload.retrying": "重試中...",
   "workspace.reload.retry": "重試",
   "workspace.reload.retry": "重試",
+  "workspace.reload.error.paymentFailed": "付款失敗。",
 
 
   "workspace.payments.title": "付款紀錄",
   "workspace.payments.title": "付款紀錄",
   "workspace.payments.subtitle": "最近的付款交易。",
   "workspace.payments.subtitle": "最近的付款交易。",
@@ -551,6 +577,10 @@ export const dict = {
   "enterprise.form.send": "傳送",
   "enterprise.form.send": "傳送",
   "enterprise.form.sending": "傳送中...",
   "enterprise.form.sending": "傳送中...",
   "enterprise.form.success": "訊息已傳送,我們會盡快與你聯絡。",
   "enterprise.form.success": "訊息已傳送,我們會盡快與你聯絡。",
+  "enterprise.form.success.submitted": "表單已成功送出。",
+  "enterprise.form.error.allFieldsRequired": "所有欄位均為必填。",
+  "enterprise.form.error.invalidEmailFormat": "無效的電子郵件格式。",
+  "enterprise.form.error.internalServer": "內部伺服器錯誤。",
   "enterprise.faq.title": "常見問題",
   "enterprise.faq.title": "常見問題",
   "enterprise.faq.q1": "什麼是 OpenCode Enterprise?",
   "enterprise.faq.q1": "什麼是 OpenCode Enterprise?",
   "enterprise.faq.a1":
   "enterprise.faq.a1":
@@ -583,6 +613,7 @@ export const dict = {
   "bench.list.table.agent": "代理",
   "bench.list.table.agent": "代理",
   "bench.list.table.model": "模型",
   "bench.list.table.model": "模型",
   "bench.list.table.score": "分數",
   "bench.list.table.score": "分數",
+  "bench.submission.error.allFieldsRequired": "所有欄位均為必填。",
 
 
   "bench.detail.title": "評測 - {{task}}",
   "bench.detail.title": "評測 - {{task}}",
   "bench.detail.notFound": "找不到任務",
   "bench.detail.notFound": "找不到任務",

+ 3 - 0
packages/console/app/src/lib/form-error.ts

@@ -48,6 +48,9 @@ const map = {
   "Provider is required": "error.providerRequired",
   "Provider is required": "error.providerRequired",
   "API key is required": "error.apiKeyRequired",
   "API key is required": "error.apiKeyRequired",
   "Model is required": "error.modelRequired",
   "Model is required": "error.modelRequired",
+  "workspace.reload.error.paymentFailed": "workspace.reload.error.paymentFailed",
+  "Payment failed": "workspace.reload.error.paymentFailed",
+  "Payment failed.": "workspace.reload.error.paymentFailed",
 } as const satisfies Record<string, Key>
 } as const satisfies Record<string, Key>
 
 
 export function formErrorReloadAmountMin(amount: number) {
 export function formErrorReloadAmountMin(amount: number) {

+ 2 - 2
packages/console/app/src/routes/[...404].tsx

@@ -16,8 +16,8 @@ export default function NotFound() {
       <div data-component="content">
       <div data-component="content">
         <section data-component="top">
         <section data-component="top">
           <a href={language.route("/")} data-slot="logo-link">
           <a href={language.route("/")} data-slot="logo-link">
-            <img data-slot="logo light" src={logoLight} alt="opencode logo light" />
-            <img data-slot="logo dark" src={logoDark} alt="opencode logo dark" />
+            <img data-slot="logo light" src={logoLight} alt={i18n.t("notFound.logoLightAlt")} />
+            <img data-slot="logo dark" src={logoDark} alt={i18n.t("notFound.logoDarkAlt")} />
           </a>
           </a>
           <h1 data-slot="title">{i18n.t("notFound.heading")}</h1>
           <h1 data-slot="title">{i18n.t("notFound.heading")}</h1>
         </section>
         </section>

+ 7 - 4
packages/console/app/src/routes/api/enterprise.ts

@@ -1,5 +1,7 @@
 import type { APIEvent } from "@solidjs/start/server"
 import type { APIEvent } from "@solidjs/start/server"
 import { AWS } from "@opencode-ai/console-core/aws.js"
 import { AWS } from "@opencode-ai/console-core/aws.js"
+import { i18n } from "~/i18n"
+import { localeFromRequest } from "~/lib/language"
 
 
 interface EnterpriseFormData {
 interface EnterpriseFormData {
   name: string
   name: string
@@ -9,18 +11,19 @@ interface EnterpriseFormData {
 }
 }
 
 
 export async function POST(event: APIEvent) {
 export async function POST(event: APIEvent) {
+  const dict = i18n(localeFromRequest(event.request))
   try {
   try {
     const body = (await event.request.json()) as EnterpriseFormData
     const body = (await event.request.json()) as EnterpriseFormData
 
 
     // Validate required fields
     // Validate required fields
     if (!body.name || !body.role || !body.email || !body.message) {
     if (!body.name || !body.role || !body.email || !body.message) {
-      return Response.json({ error: "All fields are required" }, { status: 400 })
+      return Response.json({ error: dict["enterprise.form.error.allFieldsRequired"] }, { status: 400 })
     }
     }
 
 
     // Validate email format
     // Validate email format
     const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
     const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
     if (!emailRegex.test(body.email)) {
     if (!emailRegex.test(body.email)) {
-      return Response.json({ error: "Invalid email format" }, { status: 400 })
+      return Response.json({ error: dict["enterprise.form.error.invalidEmailFormat"] }, { status: 400 })
     }
     }
 
 
     // Create email content
     // Create email content
@@ -39,9 +42,9 @@ ${body.email}`.trim()
       replyTo: body.email,
       replyTo: body.email,
     })
     })
 
 
-    return Response.json({ success: true, message: "Form submitted successfully" }, { status: 200 })
+    return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
   } catch (error) {
   } catch (error) {
     console.error("Error processing enterprise form:", error)
     console.error("Error processing enterprise form:", error)
-    return Response.json({ error: "Internal server error" }, { status: 500 })
+    return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
   }
   }
 }
 }

+ 3 - 1
packages/console/app/src/routes/auth/[...callback].ts

@@ -2,15 +2,17 @@ import { redirect } from "@solidjs/router"
 import type { APIEvent } from "@solidjs/start/server"
 import type { APIEvent } from "@solidjs/start/server"
 import { AuthClient } from "~/context/auth"
 import { AuthClient } from "~/context/auth"
 import { useAuthSession } from "~/context/auth"
 import { useAuthSession } from "~/context/auth"
+import { i18n } from "~/i18n"
 import { localeFromRequest, route } from "~/lib/language"
 import { localeFromRequest, route } from "~/lib/language"
 
 
 export async function GET(input: APIEvent) {
 export async function GET(input: APIEvent) {
   const url = new URL(input.request.url)
   const url = new URL(input.request.url)
   const locale = localeFromRequest(input.request)
   const locale = localeFromRequest(input.request)
+  const dict = i18n(locale)
 
 
   try {
   try {
     const code = url.searchParams.get("code")
     const code = url.searchParams.get("code")
-    if (!code) throw new Error("No code found")
+    if (!code) throw new Error(dict["auth.callback.error.codeMissing"])
     const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`)
     const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`)
     if (result.err) throw new Error(result.err.message)
     if (result.err) throw new Error(result.err.message)
     const decoded = AuthClient.decode(result.tokens.access, {} as any)
     const decoded = AuthClient.decode(result.tokens.access, {} as any)

+ 4 - 1
packages/console/app/src/routes/bench/submission.ts

@@ -2,6 +2,8 @@ import type { APIEvent } from "@solidjs/start/server"
 import { Database } from "@opencode-ai/console-core/drizzle/index.js"
 import { Database } from "@opencode-ai/console-core/drizzle/index.js"
 import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
 import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
 import { Identifier } from "@opencode-ai/console-core/identifier.js"
 import { Identifier } from "@opencode-ai/console-core/identifier.js"
+import { i18n } from "~/i18n"
+import { localeFromRequest } from "~/lib/language"
 
 
 interface SubmissionBody {
 interface SubmissionBody {
   model: string
   model: string
@@ -10,10 +12,11 @@ interface SubmissionBody {
 }
 }
 
 
 export async function POST(event: APIEvent) {
 export async function POST(event: APIEvent) {
+  const dict = i18n(localeFromRequest(event.request))
   const body = (await event.request.json()) as SubmissionBody
   const body = (await event.request.json()) as SubmissionBody
 
 
   if (!body.model || !body.agent || !body.result) {
   if (!body.model || !body.agent || !body.result) {
-    return Response.json({ error: "All fields are required" }, { status: 400 })
+    return Response.json({ error: dict["bench.submission.error.allFieldsRequired"] }, { status: 400 })
   }
   }
 
 
   await Database.use((tx) =>
   await Database.use((tx) =>

+ 9 - 8
packages/console/app/src/routes/brand/index.tsx

@@ -33,6 +33,7 @@ const brandAssets = "/opencode-brand-assets.zip"
 
 
 export default function Brand() {
 export default function Brand() {
   const i18n = useI18n()
   const i18n = useI18n()
+  const alt = i18n.t("brand.meta.description")
   const downloadFile = async (url: string, filename: string) => {
   const downloadFile = async (url: string, filename: string) => {
     try {
     try {
       const response = await fetch(url)
       const response = await fetch(url)
@@ -88,7 +89,7 @@ export default function Brand() {
 
 
             <div data-component="brand-grid">
             <div data-component="brand-grid">
               <div>
               <div>
-                <img src={previewLogoLight} alt="OpenCode brand guidelines" />
+                <img src={previewLogoLight} alt={alt} />
                 <div data-component="actions">
                 <div data-component="actions">
                   <button onClick={() => downloadFile(logoLightPng, "opencode-logo-light.png")}>
                   <button onClick={() => downloadFile(logoLightPng, "opencode-logo-light.png")}>
                     PNG
                     PNG
@@ -115,7 +116,7 @@ export default function Brand() {
                 </div>
                 </div>
               </div>
               </div>
               <div>
               <div>
-                <img src={previewLogoDark} alt="OpenCode brand guidelines" />
+                <img src={previewLogoDark} alt={alt} />
                 <div data-component="actions">
                 <div data-component="actions">
                   <button onClick={() => downloadFile(logoDarkPng, "opencode-logo-dark.png")}>
                   <button onClick={() => downloadFile(logoDarkPng, "opencode-logo-dark.png")}>
                     PNG
                     PNG
@@ -142,7 +143,7 @@ export default function Brand() {
                 </div>
                 </div>
               </div>
               </div>
               <div>
               <div>
-                <img src={previewLogoLightSquare} alt="OpenCode brand guidelines" />
+                <img src={previewLogoLightSquare} alt={alt} />
                 <div data-component="actions">
                 <div data-component="actions">
                   <button onClick={() => downloadFile(logoLightSquarePng, "opencode-logo-light-square.png")}>
                   <button onClick={() => downloadFile(logoLightSquarePng, "opencode-logo-light-square.png")}>
                     PNG
                     PNG
@@ -169,7 +170,7 @@ export default function Brand() {
                 </div>
                 </div>
               </div>
               </div>
               <div>
               <div>
-                <img src={previewLogoDarkSquare} alt="OpenCode brand guidelines" />
+                <img src={previewLogoDarkSquare} alt={alt} />
                 <div data-component="actions">
                 <div data-component="actions">
                   <button onClick={() => downloadFile(logoDarkSquarePng, "opencode-logo-dark-square.png")}>
                   <button onClick={() => downloadFile(logoDarkSquarePng, "opencode-logo-dark-square.png")}>
                     PNG
                     PNG
@@ -196,7 +197,7 @@ export default function Brand() {
                 </div>
                 </div>
               </div>
               </div>
               <div>
               <div>
-                <img src={previewWordmarkLight} alt="OpenCode brand guidelines" />
+                <img src={previewWordmarkLight} alt={alt} />
                 <div data-component="actions">
                 <div data-component="actions">
                   <button onClick={() => downloadFile(wordmarkLightPng, "opencode-wordmark-light.png")}>
                   <button onClick={() => downloadFile(wordmarkLightPng, "opencode-wordmark-light.png")}>
                     PNG
                     PNG
@@ -223,7 +224,7 @@ export default function Brand() {
                 </div>
                 </div>
               </div>
               </div>
               <div>
               <div>
-                <img src={previewWordmarkDark} alt="OpenCode brand guidelines" />
+                <img src={previewWordmarkDark} alt={alt} />
                 <div data-component="actions">
                 <div data-component="actions">
                   <button onClick={() => downloadFile(wordmarkDarkPng, "opencode-wordmark-dark.png")}>
                   <button onClick={() => downloadFile(wordmarkDarkPng, "opencode-wordmark-dark.png")}>
                     PNG
                     PNG
@@ -250,7 +251,7 @@ export default function Brand() {
                 </div>
                 </div>
               </div>
               </div>
               <div>
               <div>
-                <img src={previewWordmarkSimpleLight} alt="OpenCode brand guidelines" />
+                <img src={previewWordmarkSimpleLight} alt={alt} />
                 <div data-component="actions">
                 <div data-component="actions">
                   <button onClick={() => downloadFile(wordmarkSimpleLightPng, "opencode-wordmark-simple-light.png")}>
                   <button onClick={() => downloadFile(wordmarkSimpleLightPng, "opencode-wordmark-simple-light.png")}>
                     PNG
                     PNG
@@ -277,7 +278,7 @@ export default function Brand() {
                 </div>
                 </div>
               </div>
               </div>
               <div>
               <div>
-                <img src={previewWordmarkSimpleDark} alt="OpenCode brand guidelines" />
+                <img src={previewWordmarkSimpleDark} alt={alt} />
                 <div data-component="actions">
                 <div data-component="actions">
                   <button onClick={() => downloadFile(wordmarkSimpleDarkPng, "opencode-wordmark-simple-dark.png")}>
                   <button onClick={() => downloadFile(wordmarkSimpleDarkPng, "opencode-wordmark-simple-dark.png")}>
                     PNG
                     PNG

+ 1 - 1
packages/console/app/src/routes/download/[channel]/[platform].ts

@@ -19,7 +19,7 @@ const downloadNames: Record<string, string> = {
 
 
 export async function GET({ params: { platform, channel } }: APIEvent) {
 export async function GET({ params: { platform, channel } }: APIEvent) {
   const assetName = assetNames[platform]
   const assetName = assetNames[platform]
-  if (!assetName) return new Response("Not Found", { status: 404 })
+  if (!assetName) return new Response(null, { status: 404 })
 
 
   const resp = await fetch(
   const resp = await fetch(
     `https://github.com/anomalyco/${channel === "stable" ? "opencode" : "opencode-beta"}/releases/latest/download/${assetName}`,
     `https://github.com/anomalyco/${channel === "stable" ? "opencode" : "opencode-beta"}/releases/latest/download/${assetName}`,

+ 1 - 1
packages/console/app/src/routes/stripe/webhook.ts

@@ -306,7 +306,7 @@ export async function POST(input: APIEvent) {
               .update(BillingTable)
               .update(BillingTable)
               .set({
               .set({
                 reload: false,
                 reload: false,
-                reloadError: errorMessage ?? "Payment failed.",
+                reloadError: errorMessage ?? "workspace.reload.error.paymentFailed",
                 timeReloadError: sql`now()`,
                 timeReloadError: sql`now()`,
               })
               })
               .where(eq(BillingTable.workspaceID, Actor.workspace())),
               .where(eq(BillingTable.workspaceID, Actor.workspace())),

+ 2 - 2
packages/console/app/src/routes/temp.tsx

@@ -47,8 +47,8 @@ export default function Home() {
 
 
       <div data-component="content">
       <div data-component="content">
         <section data-component="top">
         <section data-component="top">
-          <img data-slot="logo light" src={logoLight} alt="opencode logo light" />
-          <img data-slot="logo dark" src={logoDark} alt="opencode logo dark" />
+          <img data-slot="logo light" src={logoLight} alt={i18n.t("temp.logoLightAlt")} />
+          <img data-slot="logo dark" src={logoDark} alt={i18n.t("temp.logoDarkAlt")} />
           <h1 data-slot="title">{i18n.t("temp.hero.title")}</h1>
           <h1 data-slot="title">{i18n.t("temp.hero.title")}</h1>
           <div data-slot="login">
           <div data-slot="login">
             <a href="/auth">{i18n.t("temp.zen")}</a>
             <a href="/auth">{i18n.t("temp.zen")}</a>

+ 2 - 1
packages/console/app/src/routes/workspace/[id]/billing/lite-section.tsx

@@ -12,6 +12,7 @@ import { queryBillingInfo } from "../../common"
 import styles from "./lite-section.module.css"
 import styles from "./lite-section.module.css"
 import { useI18n } from "~/context/i18n"
 import { useI18n } from "~/context/i18n"
 import { useLanguage } from "~/context/language"
 import { useLanguage } from "~/context/language"
+import { formError } from "~/lib/form-error"
 
 
 const queryLiteSubscription = query(async (workspaceID: string) => {
 const queryLiteSubscription = query(async (workspaceID: string) => {
   "use server"
   "use server"
@@ -114,7 +115,7 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
 const setLiteUseBalance = action(async (form: FormData) => {
 const setLiteUseBalance = action(async (form: FormData) => {
   "use server"
   "use server"
   const workspaceID = form.get("workspaceID")?.toString()
   const workspaceID = form.get("workspaceID")?.toString()
-  if (!workspaceID) return { error: "Workspace ID is required" }
+  if (!workspaceID) return { error: formError.workspaceRequired }
   const useBalance = form.get("useBalance")?.toString() === "true"
   const useBalance = form.get("useBalance")?.toString() === "true"
 
 
   return json(
   return json(

+ 2 - 1
packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx

@@ -202,7 +202,8 @@ export function ReloadSection() {
                 minute: "2-digit",
                 minute: "2-digit",
                 second: "2-digit",
                 second: "2-digit",
               })}
               })}
-              . {i18n.t("workspace.reload.reason")} {billingInfo()?.reloadError?.replace(/\.$/, "")}.{" "}
+              . {i18n.t("workspace.reload.reason")}{" "}
+              {localizeError(i18n.t, billingInfo()?.reloadError ?? undefined).replace(/\.$/, "")}.{" "}
               {i18n.t("workspace.reload.updatePaymentMethod")}
               {i18n.t("workspace.reload.updatePaymentMethod")}
             </p>
             </p>
             <form action={reload} method="post" data-slot="create-form">
             <form action={reload} method="post" data-slot="create-form">

+ 58 - 40
packages/console/app/src/routes/zen/util/handler.ts

@@ -35,6 +35,8 @@ import { createTrialLimiter } from "./trialLimiter"
 import { createStickyTracker } from "./stickyProviderTracker"
 import { createStickyTracker } from "./stickyProviderTracker"
 import { LiteData } from "@opencode-ai/console-core/lite.js"
 import { LiteData } from "@opencode-ai/console-core/lite.js"
 import { Resource } from "@opencode-ai/console-resource"
 import { Resource } from "@opencode-ai/console-resource"
+import { i18n, type Key } from "~/i18n"
+import { localeFromRequest } from "~/lib/language"
 
 
 type ZenData = Awaited<ReturnType<typeof ZenData.list>>
 type ZenData = Awaited<ReturnType<typeof ZenData.list>>
 type RetryOptions = {
 type RetryOptions = {
@@ -43,6 +45,15 @@ type RetryOptions = {
 }
 }
 type BillingSource = "anonymous" | "free" | "byok" | "subscription" | "lite" | "balance"
 type BillingSource = "anonymous" | "free" | "byok" | "subscription" | "lite" | "balance"
 
 
+function resolve(text: string, params?: Record<string, string | number>) {
+  if (!params) return text
+  return text.replace(/\{\{(\w+)\}\}/g, (raw, key) => {
+    const value = params[key]
+    if (value === undefined || value === null) return raw
+    return String(value)
+  })
+}
+
 export async function handler(
 export async function handler(
   input: APIEvent,
   input: APIEvent,
   opts: {
   opts: {
@@ -60,6 +71,8 @@ export async function handler(
 
 
   const MAX_FAILOVER_RETRIES = 3
   const MAX_FAILOVER_RETRIES = 3
   const MAX_429_RETRIES = 3
   const MAX_429_RETRIES = 3
+  const dict = i18n(localeFromRequest(input.request))
+  const t = (key: Key, params?: Record<string, string | number>) => resolve(dict[key], params)
   const ADMIN_WORKSPACES = [
   const ADMIN_WORKSPACES = [
     "wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
     "wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
     "wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
     "wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
@@ -84,9 +97,9 @@ export async function handler(
     const zenData = ZenData.list(opts.modelList)
     const zenData = ZenData.list(opts.modelList)
     const modelInfo = validateModel(zenData, model)
     const modelInfo = validateModel(zenData, model)
     const dataDumper = createDataDumper(sessionId, requestId, projectId)
     const dataDumper = createDataDumper(sessionId, requestId, projectId)
-    const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient)
-    const isTrial = await trialLimiter?.isTrial()
-    const rateLimiter = createRateLimiter(modelInfo.rateLimit, ip, input.request.headers)
+    const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip)
+    const trialProvider = await trialLimiter?.check()
+    const rateLimiter = createRateLimiter(modelInfo.allowAnonymous, ip, input.request)
     await rateLimiter?.check()
     await rateLimiter?.check()
     const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
     const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
     const stickyProvider = await stickyTracker?.get()
     const stickyProvider = await stickyTracker?.get()
@@ -101,7 +114,7 @@ export async function handler(
         authInfo,
         authInfo,
         modelInfo,
         modelInfo,
         sessionId,
         sessionId,
-        isTrial ?? false,
+        trialProvider,
         retry,
         retry,
         stickyProvider,
         stickyProvider,
       )
       )
@@ -131,9 +144,6 @@ export async function handler(
           Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
           Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
             headers.set(k, headers.get(v)!)
             headers.set(k, headers.get(v)!)
           })
           })
-          Object.entries(providerInfo.headers ?? {}).forEach(([k, v]) => {
-            headers.set(k, v)
-          })
           headers.delete("host")
           headers.delete("host")
           headers.delete("content-length")
           headers.delete("content-length")
           headers.delete("x-opencode-request")
           headers.delete("x-opencode-request")
@@ -282,18 +292,13 @@ export async function handler(
                 part = part.trim()
                 part = part.trim()
                 usageParser.parse(part)
                 usageParser.parse(part)
 
 
-                if (providerInfo.responseModifier) {
-                  for (const [k, v] of Object.entries(providerInfo.responseModifier)) {
-                    part = part.replace(k, v)
-                  }
-                  c.enqueue(encoder.encode(part + "\n\n"))
-                } else if (providerInfo.format !== opts.format) {
+                if (providerInfo.format !== opts.format) {
                   part = streamConverter(part)
                   part = streamConverter(part)
                   c.enqueue(encoder.encode(part + "\n\n"))
                   c.enqueue(encoder.encode(part + "\n\n"))
                 }
                 }
               }
               }
 
 
-              if (!providerInfo.responseModifier && providerInfo.format === opts.format) {
+              if (providerInfo.format === opts.format) {
                 c.enqueue(value)
                 c.enqueue(value)
               }
               }
 
 
@@ -359,14 +364,20 @@ export async function handler(
   }
   }
 
 
   function validateModel(zenData: ZenData, reqModel: string) {
   function validateModel(zenData: ZenData, reqModel: string) {
-    if (!(reqModel in zenData.models)) throw new ModelError(`Model ${reqModel} not supported`)
+    if (!(reqModel in zenData.models)) throw new ModelError(t("zen.api.error.modelNotSupported", { model: reqModel }))
 
 
     const modelId = reqModel as keyof typeof zenData.models
     const modelId = reqModel as keyof typeof zenData.models
     const modelData = Array.isArray(zenData.models[modelId])
     const modelData = Array.isArray(zenData.models[modelId])
       ? zenData.models[modelId].find((model) => opts.format === model.formatFilter)
       ? zenData.models[modelId].find((model) => opts.format === model.formatFilter)
       : zenData.models[modelId]
       : zenData.models[modelId]
 
 
-    if (!modelData) throw new ModelError(`Model ${reqModel} not supported for format ${opts.format}`)
+    if (!modelData)
+      throw new ModelError(
+        t("zen.api.error.modelFormatNotSupported", {
+          model: reqModel,
+          format: opts.format,
+        }),
+      )
 
 
     logger.metric({ model: modelId })
     logger.metric({ model: modelId })
 
 
@@ -379,7 +390,7 @@ export async function handler(
     authInfo: AuthInfo,
     authInfo: AuthInfo,
     modelInfo: ModelInfo,
     modelInfo: ModelInfo,
     sessionId: string,
     sessionId: string,
-    isTrial: boolean,
+    trialProvider: string | undefined,
     retry: RetryOptions,
     retry: RetryOptions,
     stickyProvider: string | undefined,
     stickyProvider: string | undefined,
   ) {
   ) {
@@ -388,8 +399,8 @@ export async function handler(
         return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
         return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
       }
       }
 
 
-      if (isTrial) {
-        return modelInfo.providers.find((provider) => provider.id === modelInfo.trial!.provider)
+      if (trialProvider) {
+        return modelInfo.providers.find((provider) => provider.id === trialProvider)
       }
       }
 
 
       if (stickyProvider) {
       if (stickyProvider) {
@@ -418,8 +429,9 @@ export async function handler(
       return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
       return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
     })()
     })()
 
 
-    if (!modelProvider) throw new ModelError("No provider available")
-    if (!(modelProvider.id in zenData.providers)) throw new ModelError(`Provider ${modelProvider.id} not supported`)
+    if (!modelProvider) throw new ModelError(t("zen.api.error.noProviderAvailable"))
+    if (!(modelProvider.id in zenData.providers))
+      throw new ModelError(t("zen.api.error.providerNotSupported", { provider: modelProvider.id }))
 
 
     return {
     return {
       ...modelProvider,
       ...modelProvider,
@@ -439,7 +451,7 @@ export async function handler(
     const apiKey = opts.parseApiKey(input.request.headers)
     const apiKey = opts.parseApiKey(input.request.headers)
     if (!apiKey || apiKey === "public") {
     if (!apiKey || apiKey === "public") {
       if (modelInfo.allowAnonymous) return
       if (modelInfo.allowAnonymous) return
-      throw new AuthError("Missing API key.")
+      throw new AuthError(t("zen.api.error.missingApiKey"))
     }
     }
 
 
     const data = await Database.use((tx) =>
     const data = await Database.use((tx) =>
@@ -520,13 +532,13 @@ export async function handler(
         .then((rows) => rows[0]),
         .then((rows) => rows[0]),
     )
     )
 
 
-    if (!data) throw new AuthError("Invalid API key.")
+    if (!data) throw new AuthError(t("zen.api.error.invalidApiKey"))
     if (
     if (
       modelInfo.id.startsWith("alpha-") &&
       modelInfo.id.startsWith("alpha-") &&
       Resource.App.stage === "production" &&
       Resource.App.stage === "production" &&
       !ADMIN_WORKSPACES.includes(data.workspaceID)
       !ADMIN_WORKSPACES.includes(data.workspaceID)
     )
     )
-      throw new AuthError(`Model ${modelInfo.id} not supported`)
+      throw new AuthError(t("zen.api.error.modelNotSupported", { model: modelInfo.id }))
 
 
     logger.metric({
     logger.metric({
       api_key: data.apiKey,
       api_key: data.apiKey,
@@ -590,7 +602,9 @@ export async function handler(
           })
           })
           if (result.status === "rate-limited")
           if (result.status === "rate-limited")
             throw new SubscriptionUsageLimitError(
             throw new SubscriptionUsageLimitError(
-              `Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
+              t("zen.api.error.subscriptionQuotaExceeded", {
+                retryIn: formatRetryTime(result.resetInSec),
+              }),
               result.resetInSec,
               result.resetInSec,
             )
             )
         }
         }
@@ -606,7 +620,9 @@ export async function handler(
           })
           })
           if (result.status === "rate-limited")
           if (result.status === "rate-limited")
             throw new SubscriptionUsageLimitError(
             throw new SubscriptionUsageLimitError(
-              `Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
+              t("zen.api.error.subscriptionQuotaExceeded", {
+                retryIn: formatRetryTime(result.resetInSec),
+              }),
               result.resetInSec,
               result.resetInSec,
             )
             )
         }
         }
@@ -632,7 +648,7 @@ export async function handler(
           })
           })
           if (result.status === "rate-limited")
           if (result.status === "rate-limited")
             throw new SubscriptionUsageLimitError(
             throw new SubscriptionUsageLimitError(
-              `Subscription quota exceeded. You can continue using free models.`,
+              t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
               result.resetInSec,
               result.resetInSec,
             )
             )
         }
         }
@@ -647,7 +663,7 @@ export async function handler(
           })
           })
           if (result.status === "rate-limited")
           if (result.status === "rate-limited")
             throw new SubscriptionUsageLimitError(
             throw new SubscriptionUsageLimitError(
-              `Subscription quota exceeded. You can continue using free models.`,
+              t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
               result.resetInSec,
               result.resetInSec,
             )
             )
         }
         }
@@ -662,7 +678,7 @@ export async function handler(
           })
           })
           if (result.status === "rate-limited")
           if (result.status === "rate-limited")
             throw new SubscriptionUsageLimitError(
             throw new SubscriptionUsageLimitError(
-              `Subscription quota exceeded. You can continue using free models.`,
+              t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
               result.resetInSec,
               result.resetInSec,
             )
             )
         }
         }
@@ -675,14 +691,10 @@ export async function handler(
 
 
     // Validate pay as you go billing
     // Validate pay as you go billing
     const billing = authInfo.billing
     const billing = authInfo.billing
-    if (!billing.paymentMethodID)
-      throw new CreditsError(
-        `No payment method. Add a payment method here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
-      )
-    if (billing.balance <= 0)
-      throw new CreditsError(
-        `Insufficient balance. Manage your billing here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
-      )
+    const billingUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/billing`
+    const membersUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/members`
+    if (!billing.paymentMethodID) throw new CreditsError(t("zen.api.error.noPaymentMethod", { billingUrl }))
+    if (billing.balance <= 0) throw new CreditsError(t("zen.api.error.insufficientBalance", { billingUrl }))
 
 
     const now = new Date()
     const now = new Date()
     const currentYear = now.getUTCFullYear()
     const currentYear = now.getUTCFullYear()
@@ -696,7 +708,10 @@ export async function handler(
       currentMonth === billing.timeMonthlyUsageUpdated.getUTCMonth()
       currentMonth === billing.timeMonthlyUsageUpdated.getUTCMonth()
     )
     )
       throw new MonthlyLimitError(
       throw new MonthlyLimitError(
-        `Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
+        t("zen.api.error.workspaceMonthlyLimitReached", {
+          amount: billing.monthlyLimit,
+          billingUrl,
+        }),
       )
       )
 
 
     if (
     if (
@@ -708,7 +723,10 @@ export async function handler(
       currentMonth === authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
       currentMonth === authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
     )
     )
       throw new UserLimitError(
       throw new UserLimitError(
-        `You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
+        t("zen.api.error.userMonthlyLimitReached", {
+          amount: authInfo.user.monthlyLimit,
+          membersUrl,
+        }),
       )
       )
 
 
     return "balance"
     return "balance"
@@ -716,7 +734,7 @@ export async function handler(
 
 
   function validateModelSettings(authInfo: AuthInfo) {
   function validateModelSettings(authInfo: AuthInfo) {
     if (!authInfo) return
     if (!authInfo) return
-    if (authInfo.isDisabled) throw new ModelError("Model is disabled")
+    if (authInfo.isDisabled) throw new ModelError(t("zen.api.error.modelDisabled"))
   }
   }
 
 
   function updateProviderKey(authInfo: AuthInfo, providerInfo: ProviderInfo) {
   function updateProviderKey(authInfo: AuthInfo, providerInfo: ProviderInfo) {

+ 1 - 1
packages/console/app/src/routes/zen/util/provider/anthropic.ts

@@ -43,7 +43,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
       ...(isBedrock
       ...(isBedrock
         ? {
         ? {
             anthropic_version: "bedrock-2023-05-31",
             anthropic_version: "bedrock-2023-05-31",
-            anthropic_beta: supports1m ? "context-1m-2025-08-07" : undefined,
+            anthropic_beta: supports1m ? ["context-1m-2025-08-07"] : undefined,
             model: undefined,
             model: undefined,
             stream: undefined,
             stream: undefined,
           }
           }

+ 13 - 42
packages/console/app/src/routes/zen/util/rateLimiter.ts

@@ -2,26 +2,28 @@ import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizz
 import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
 import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
 import { FreeUsageLimitError } from "./error"
 import { FreeUsageLimitError } from "./error"
 import { logger } from "./logger"
 import { logger } from "./logger"
-import { ZenData } from "@opencode-ai/console-core/model.js"
+import { i18n } from "~/i18n"
+import { localeFromRequest } from "~/lib/language"
+import { Subscription } from "@opencode-ai/console-core/subscription.js"
 
 
-export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: string, headers: Headers) {
-  if (!limit) return
+export function createRateLimiter(allowAnonymous: boolean | undefined, rawIp: string, request: Request) {
+  if (!allowAnonymous) return
+  const dict = i18n(localeFromRequest(request))
 
 
-  const limitValue = limit.checkHeader && !headers.get(limit.checkHeader) ? limit.fallbackValue! : limit.value
+  const limits = Subscription.getFreeLimits()
+  const limitValue =
+    limits.checkHeader && !request.headers.get(limits.checkHeader) ? limits.fallbackValue : limits.dailyRequests
 
 
   const ip = !rawIp.length ? "unknown" : rawIp
   const ip = !rawIp.length ? "unknown" : rawIp
   const now = Date.now()
   const now = Date.now()
-  const intervals =
-    limit.period === "day"
-      ? [buildYYYYMMDD(now)]
-      : [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)]
+  const interval = buildYYYYMMDD(now)
 
 
   return {
   return {
     track: async () => {
     track: async () => {
       await Database.use((tx) =>
       await Database.use((tx) =>
         tx
         tx
           .insert(IpRateLimitTable)
           .insert(IpRateLimitTable)
-          .values({ ip, interval: intervals[0], count: 1 })
+          .values({ ip, interval, count: 1 })
           .onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
           .onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
       )
       )
     },
     },
@@ -30,15 +32,12 @@ export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: s
         tx
         tx
           .select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
           .select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
           .from(IpRateLimitTable)
           .from(IpRateLimitTable)
-          .where(and(eq(IpRateLimitTable.ip, ip), inArray(IpRateLimitTable.interval, intervals))),
+          .where(and(eq(IpRateLimitTable.ip, ip), inArray(IpRateLimitTable.interval, [interval]))),
       )
       )
       const total = rows.reduce((sum, r) => sum + r.count, 0)
       const total = rows.reduce((sum, r) => sum + r.count, 0)
       logger.debug(`rate limit total: ${total}`)
       logger.debug(`rate limit total: ${total}`)
       if (total >= limitValue)
       if (total >= limitValue)
-        throw new FreeUsageLimitError(
-          `Rate limit exceeded. Please try again later.`,
-          limit.period === "day" ? getRetryAfterDay(now) : getRetryAfterHour(rows, intervals, limitValue, now),
-        )
+        throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], getRetryAfterDay(now))
     },
     },
   }
   }
 }
 }
@@ -47,37 +46,9 @@ export function getRetryAfterDay(now: number) {
   return Math.ceil((86_400_000 - (now % 86_400_000)) / 1000)
   return Math.ceil((86_400_000 - (now % 86_400_000)) / 1000)
 }
 }
 
 
-export function getRetryAfterHour(
-  rows: { interval: string; count: number }[],
-  intervals: string[],
-  limit: number,
-  now: number,
-) {
-  const counts = new Map(rows.map((r) => [r.interval, r.count]))
-  // intervals are ordered newest to oldest: [current, -1h, -2h]
-  // simulate dropping oldest intervals one at a time
-  let running = intervals.reduce((sum, i) => sum + (counts.get(i) ?? 0), 0)
-  for (let i = intervals.length - 1; i >= 0; i--) {
-    running -= counts.get(intervals[i]) ?? 0
-    if (running < limit) {
-      // interval at index i rolls out of the window (intervals.length - i) hours from the current hour start
-      const hours = intervals.length - i
-      return Math.ceil((hours * 3_600_000 - (now % 3_600_000)) / 1000)
-    }
-  }
-  return Math.ceil((3_600_000 - (now % 3_600_000)) / 1000)
-}
-
 function buildYYYYMMDD(timestamp: number) {
 function buildYYYYMMDD(timestamp: number) {
   return new Date(timestamp)
   return new Date(timestamp)
     .toISOString()
     .toISOString()
     .replace(/[^0-9]/g, "")
     .replace(/[^0-9]/g, "")
     .substring(0, 8)
     .substring(0, 8)
 }
 }
-
-function buildYYYYMMDDHH(timestamp: number) {
-  return new Date(timestamp)
-    .toISOString()
-    .replace(/[^0-9]/g, "")
-    .substring(0, 10)
-}

+ 6 - 9
packages/console/app/src/routes/zen/util/trialLimiter.ts

@@ -1,21 +1,18 @@
 import { Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
 import { Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
 import { IpTable } from "@opencode-ai/console-core/schema/ip.sql.js"
 import { IpTable } from "@opencode-ai/console-core/schema/ip.sql.js"
 import { UsageInfo } from "./provider/provider"
 import { UsageInfo } from "./provider/provider"
-import { ZenData } from "@opencode-ai/console-core/model.js"
+import { Subscription } from "@opencode-ai/console-core/subscription.js"
 
 
-export function createTrialLimiter(trial: ZenData.Trial | undefined, ip: string, client: string) {
-  if (!trial) return
+export function createTrialLimiter(trialProvider: string | undefined, ip: string) {
+  if (!trialProvider) return
   if (!ip) return
   if (!ip) return
 
 
-  const limit =
-    trial.limits.find((limit) => limit.client === client)?.limit ??
-    trial.limits.find((limit) => limit.client === undefined)?.limit
-  if (!limit) return
+  const limit = Subscription.getFreeLimits().promoTokens
 
 
   let _isTrial: boolean
   let _isTrial: boolean
 
 
   return {
   return {
-    isTrial: async () => {
+    check: async () => {
       const data = await Database.use((tx) =>
       const data = await Database.use((tx) =>
         tx
         tx
           .select({
           .select({
@@ -27,7 +24,7 @@ export function createTrialLimiter(trial: ZenData.Trial | undefined, ip: string,
       )
       )
 
 
       _isTrial = (data?.usage ?? 0) < limit
       _isTrial = (data?.usage ?? 0) < limit
-      return _isTrial
+      return _isTrial ? trialProvider : undefined
     },
     },
     track: async (usageInfo: UsageInfo) => {
     track: async (usageInfo: UsageInfo) => {
       if (!_isTrial) return
       if (!_isTrial) return

+ 1 - 74
packages/console/app/test/rateLimiter.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, test } from "bun:test"
 import { describe, expect, test } from "bun:test"
-import { getRetryAfterDay, getRetryAfterHour } from "../src/routes/zen/util/rateLimiter"
+import { getRetryAfterDay } from "../src/routes/zen/util/rateLimiter"
 
 
 describe("getRetryAfterDay", () => {
 describe("getRetryAfterDay", () => {
   test("returns full day at midnight UTC", () => {
   test("returns full day at midnight UTC", () => {
@@ -17,76 +17,3 @@ describe("getRetryAfterDay", () => {
     expect(getRetryAfterDay(almost)).toBe(1)
     expect(getRetryAfterDay(almost)).toBe(1)
   })
   })
 })
 })
-
-describe("getRetryAfterHour", () => {
-  // 14:30:00 UTC — 30 minutes into the current hour
-  const now = Date.UTC(2026, 0, 15, 14, 30, 0, 0)
-  const intervals = ["2026011514", "2026011513", "2026011512"]
-
-  test("waits 3 hours when all usage is in current hour", () => {
-    const rows = [{ interval: "2026011514", count: 10 }]
-    // only current hour has usage — it won't leave the window for 3 hours from hour start
-    // 3 * 3600 - 1800 = 9000s
-    expect(getRetryAfterHour(rows, intervals, 10, now)).toBe(9000)
-  })
-
-  test("waits 1 hour when dropping oldest interval is sufficient", () => {
-    const rows = [
-      { interval: "2026011514", count: 2 },
-      { interval: "2026011512", count: 10 },
-    ]
-    // total=12, drop oldest (-2h, count=10) -> 2 < 10
-    // hours = 3 - 2 = 1 -> 1 * 3600 - 1800 = 1800s
-    expect(getRetryAfterHour(rows, intervals, 10, now)).toBe(1800)
-  })
-
-  test("waits 2 hours when usage spans oldest two intervals", () => {
-    const rows = [
-      { interval: "2026011513", count: 8 },
-      { interval: "2026011512", count: 5 },
-    ]
-    // total=13, drop -2h (5) -> 8, 8 >= 8, drop -1h (8) -> 0 < 8
-    // hours = 3 - 1 = 2 -> 2 * 3600 - 1800 = 5400s
-    expect(getRetryAfterHour(rows, intervals, 8, now)).toBe(5400)
-  })
-
-  test("waits 1 hour when oldest interval alone pushes over limit", () => {
-    const rows = [
-      { interval: "2026011514", count: 1 },
-      { interval: "2026011513", count: 1 },
-      { interval: "2026011512", count: 10 },
-    ]
-    // total=12, drop -2h (10) -> 2 < 10
-    // hours = 3 - 2 = 1 -> 1800s
-    expect(getRetryAfterHour(rows, intervals, 10, now)).toBe(1800)
-  })
-
-  test("waits 2 hours when middle interval keeps total over limit", () => {
-    const rows = [
-      { interval: "2026011514", count: 4 },
-      { interval: "2026011513", count: 4 },
-      { interval: "2026011512", count: 4 },
-    ]
-    // total=12, drop -2h (4) -> 8, 8 >= 5, drop -1h (4) -> 4 < 5
-    // hours = 3 - 1 = 2 -> 5400s
-    expect(getRetryAfterHour(rows, intervals, 5, now)).toBe(5400)
-  })
-
-  test("rounds up to nearest second", () => {
-    const offset = Date.UTC(2026, 0, 15, 14, 30, 0, 500)
-    const rows = [
-      { interval: "2026011514", count: 2 },
-      { interval: "2026011512", count: 10 },
-    ]
-    // hours=1 -> 3_600_000 - 1_800_500 = 1_799_500ms -> ceil(1799.5) = 1800
-    expect(getRetryAfterHour(rows, intervals, 10, offset)).toBe(1800)
-  })
-
-  test("fallback returns time until next hour when rows are empty", () => {
-    // edge case: rows empty but function called (shouldn't happen in practice)
-    // loop drops all zeros, running stays 0 which is < any positive limit on first iteration
-    const rows: { interval: string; count: number }[] = []
-    // drop -2h (0) -> 0 < 1 -> hours = 3 - 2 = 1 -> 1800s
-    expect(getRetryAfterHour(rows, intervals, 1, now)).toBe(1800)
-  })
-})

+ 4 - 7
packages/console/core/package.json

@@ -1,7 +1,7 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/console-core",
   "name": "@opencode-ai/console-core",
-  "version": "1.2.15",
+  "version": "1.2.16",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",
   "license": "MIT",
   "license": "MIT",
@@ -34,12 +34,9 @@
     "promote-models-to-prod": "script/promote-models.ts production",
     "promote-models-to-prod": "script/promote-models.ts production",
     "pull-models-from-dev": "script/pull-models.ts dev",
     "pull-models-from-dev": "script/pull-models.ts dev",
     "pull-models-from-prod": "script/pull-models.ts production",
     "pull-models-from-prod": "script/pull-models.ts production",
-    "update-black": "script/update-black.ts",
-    "promote-black-to-dev": "script/promote-black.ts dev",
-    "promote-black-to-prod": "script/promote-black.ts production",
-    "update-lite": "script/update-lite.ts",
-    "promote-lite-to-dev": "script/promote-lite.ts dev",
-    "promote-lite-to-prod": "script/promote-lite.ts production",
+    "update-limits": "script/update-limits.ts",
+    "promote-limits-to-dev": "script/promote-limits.ts dev",
+    "promote-limits-to-prod": "script/promote-limits.ts production",
     "typecheck": "tsgo --noEmit"
     "typecheck": "tsgo --noEmit"
   },
   },
   "devDependencies": {
   "devDependencies": {

+ 312 - 0
packages/console/core/script/black-stats.ts

@@ -0,0 +1,312 @@
+import { Database, and, eq, inArray, isNotNull, sql } from "../src/drizzle/index.js"
+import { BillingTable, BlackPlans, SubscriptionTable, UsageTable } from "../src/schema/billing.sql.js"
+
+if (process.argv.length < 3) {
+  console.error("Usage: bun black-stats.ts <plan>")
+  process.exit(1)
+}
+const plan = process.argv[2] as (typeof BlackPlans)[number]
+if (!BlackPlans.includes(plan)) {
+  console.error("Usage: bun black-stats.ts <plan>")
+  process.exit(1)
+}
+const cutoff = new Date(Date.UTC(2026, 1, 0, 23, 59, 59, 999))
+
+// get workspaces
+const workspaces = await Database.use((tx) =>
+  tx
+    .select({ workspaceID: BillingTable.workspaceID })
+    .from(BillingTable)
+    .where(
+      and(isNotNull(BillingTable.subscriptionID), sql`JSON_UNQUOTE(JSON_EXTRACT(subscription, '$.plan')) = ${plan}`),
+    ),
+)
+if (workspaces.length === 0) throw new Error(`No active Black ${plan} subscriptions found`)
+
+const week = sql<number>`YEARWEEK(${UsageTable.timeCreated}, 3)`
+const workspaceIDs = workspaces.map((row) => row.workspaceID)
+// Get subscription spend
+const spend = await Database.use((tx) =>
+  tx
+    .select({
+      workspaceID: UsageTable.workspaceID,
+      week,
+      amount: sql<number>`COALESCE(SUM(${UsageTable.cost}), 0)`,
+    })
+    .from(UsageTable)
+    .where(
+      and(inArray(UsageTable.workspaceID, workspaceIDs), sql`JSON_UNQUOTE(JSON_EXTRACT(enrichment, '$.plan')) = 'sub'`),
+    )
+    .groupBy(UsageTable.workspaceID, week),
+)
+
+// Get pay per use spend
+const ppu = await Database.use((tx) =>
+  tx
+    .select({
+      workspaceID: UsageTable.workspaceID,
+      week,
+      amount: sql<number>`COALESCE(SUM(${UsageTable.cost}), 0)`,
+    })
+    .from(UsageTable)
+    .where(
+      and(
+        inArray(UsageTable.workspaceID, workspaceIDs),
+        sql`(${UsageTable.enrichment} IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(enrichment, '$.plan')) != 'sub')`,
+      ),
+    )
+    .groupBy(UsageTable.workspaceID, week),
+)
+
+const models = await Database.use((tx) =>
+  tx
+    .select({
+      workspaceID: UsageTable.workspaceID,
+      model: UsageTable.model,
+      amount: sql<number>`COALESCE(SUM(${UsageTable.cost}), 0)`,
+    })
+    .from(UsageTable)
+    .where(
+      and(inArray(UsageTable.workspaceID, workspaceIDs), sql`JSON_UNQUOTE(JSON_EXTRACT(enrichment, '$.plan')) = 'sub'`),
+    )
+    .groupBy(UsageTable.workspaceID, UsageTable.model),
+)
+
+const tokens = await Database.use((tx) =>
+  tx
+    .select({
+      workspaceID: UsageTable.workspaceID,
+      week,
+      input: sql<number>`COALESCE(SUM(${UsageTable.inputTokens}), 0)`,
+      cacheRead: sql<number>`COALESCE(SUM(${UsageTable.cacheReadTokens}), 0)`,
+      output: sql<number>`COALESCE(SUM(${UsageTable.outputTokens}), 0) + COALESCE(SUM(${UsageTable.reasoningTokens}), 0)`,
+    })
+    .from(UsageTable)
+    .where(
+      and(inArray(UsageTable.workspaceID, workspaceIDs), sql`JSON_UNQUOTE(JSON_EXTRACT(enrichment, '$.plan')) = 'sub'`),
+    )
+    .groupBy(UsageTable.workspaceID, week),
+)
+
+const allWeeks = [...spend, ...ppu].map((row) => row.week)
+const weeks = [...new Set(allWeeks)].sort((a, b) => a - b)
+const spendMap = new Map<string, Map<number, number>>()
+const totals = new Map<string, number>()
+const ppuMap = new Map<string, Map<number, number>>()
+const ppuTotals = new Map<string, number>()
+const modelMap = new Map<string, { model: string; amount: number }[]>()
+const tokenMap = new Map<string, Map<number, { input: number; cacheRead: number; output: number }>>()
+
+for (const row of spend) {
+  const workspace = spendMap.get(row.workspaceID) ?? new Map<number, number>()
+  const total = totals.get(row.workspaceID) ?? 0
+  const amount = toNumber(row.amount)
+  workspace.set(row.week, amount)
+  totals.set(row.workspaceID, total + amount)
+  spendMap.set(row.workspaceID, workspace)
+}
+
+for (const row of ppu) {
+  const workspace = ppuMap.get(row.workspaceID) ?? new Map<number, number>()
+  const total = ppuTotals.get(row.workspaceID) ?? 0
+  const amount = toNumber(row.amount)
+  workspace.set(row.week, amount)
+  ppuTotals.set(row.workspaceID, total + amount)
+  ppuMap.set(row.workspaceID, workspace)
+}
+
+for (const row of models) {
+  const current = modelMap.get(row.workspaceID) ?? []
+  current.push({ model: row.model, amount: toNumber(row.amount) })
+  modelMap.set(row.workspaceID, current)
+}
+
+for (const row of tokens) {
+  const workspace = tokenMap.get(row.workspaceID) ?? new Map()
+  workspace.set(row.week, {
+    input: toNumber(row.input),
+    cacheRead: toNumber(row.cacheRead),
+    output: toNumber(row.output),
+  })
+  tokenMap.set(row.workspaceID, workspace)
+}
+
+const users = await Database.use((tx) =>
+  tx
+    .select({
+      workspaceID: SubscriptionTable.workspaceID,
+      subscribed: SubscriptionTable.timeCreated,
+      subscription: BillingTable.subscription,
+    })
+    .from(SubscriptionTable)
+    .innerJoin(BillingTable, eq(SubscriptionTable.workspaceID, BillingTable.workspaceID))
+    .where(
+      and(inArray(SubscriptionTable.workspaceID, workspaceIDs), sql`${SubscriptionTable.timeCreated} <= ${cutoff}`),
+    ),
+)
+
+const counts = new Map<string, number>()
+for (const user of users) {
+  const current = counts.get(user.workspaceID) ?? 0
+  counts.set(user.workspaceID, current + 1)
+}
+
+const rows = users
+  .map((user) => {
+    const workspace = spendMap.get(user.workspaceID) ?? new Map<number, number>()
+    const ppuWorkspace = ppuMap.get(user.workspaceID) ?? new Map<number, number>()
+    const count = counts.get(user.workspaceID) ?? 1
+    const amount = (totals.get(user.workspaceID) ?? 0) / count
+    const ppuAmount = (ppuTotals.get(user.workspaceID) ?? 0) / count
+    const monthStart = user.subscribed ? startOfMonth(user.subscribed) : null
+    const modelRows = (modelMap.get(user.workspaceID) ?? []).sort((a, b) => b.amount - a.amount).slice(0, 3)
+    const modelTotal = totals.get(user.workspaceID) ?? 0
+    const modelCells = modelRows.map((row) => ({
+      model: row.model,
+      percent: modelTotal > 0 ? `${((row.amount / modelTotal) * 100).toFixed(1)}%` : "0.0%",
+    }))
+    const modelData = [0, 1, 2].map((index) => modelCells[index] ?? { model: "-", percent: "-" })
+    const weekly = Object.fromEntries(
+      weeks.map((item) => {
+        const value = (workspace.get(item) ?? 0) / count
+        const beforeMonth = monthStart ? isoWeekStart(item) < monthStart : false
+        return [formatWeek(item), beforeMonth ? "-" : formatMicroCents(value)]
+      }),
+    )
+    const ppuWeekly = Object.fromEntries(
+      weeks.map((item) => {
+        const value = (ppuWorkspace.get(item) ?? 0) / count
+        const beforeMonth = monthStart ? isoWeekStart(item) < monthStart : false
+        return [formatWeek(item), beforeMonth ? "-" : formatMicroCents(value)]
+      }),
+    )
+    const tokenWorkspace = tokenMap.get(user.workspaceID) ?? new Map()
+    const weeklyTokens = Object.fromEntries(
+      weeks.map((item) => {
+        const t = tokenWorkspace.get(item) ?? { input: 0, cacheRead: 0, output: 0 }
+        const beforeMonth = monthStart ? isoWeekStart(item) < monthStart : false
+        return [
+          formatWeek(item),
+          beforeMonth
+            ? { input: "-", cacheRead: "-", output: "-" }
+            : {
+                input: Math.round(t.input / count),
+                cacheRead: Math.round(t.cacheRead / count),
+                output: Math.round(t.output / count),
+              },
+        ]
+      }),
+    )
+    return {
+      workspaceID: user.workspaceID,
+      useBalance: user.subscription?.useBalance ?? false,
+      subscribed: formatDate(user.subscribed),
+      subscribedAt: user.subscribed?.getTime() ?? 0,
+      amount,
+      ppuAmount,
+      models: modelData,
+      weekly,
+      ppuWeekly,
+      weeklyTokens,
+    }
+  })
+  .sort((a, b) => a.subscribedAt - b.subscribedAt)
+
+console.log(`Black ${plan} subscribers: ${rows.length}`)
+const header = [
+  "workspaceID",
+  "subscribed",
+  "useCredit",
+  "subTotal",
+  "ppuTotal",
+  "model1",
+  "model1%",
+  "model2",
+  "model2%",
+  "model3",
+  "model3%",
+  ...weeks.flatMap((item) => [
+    formatWeek(item) + " sub",
+    formatWeek(item) + " ppu",
+    formatWeek(item) + " input",
+    formatWeek(item) + " cache",
+    formatWeek(item) + " output",
+  ]),
+]
+const lines = [header.map(csvCell).join(",")]
+for (const row of rows) {
+  const model1 = row.models[0]
+  const model2 = row.models[1]
+  const model3 = row.models[2]
+  const cells = [
+    row.workspaceID,
+    row.subscribed ?? "",
+    row.useBalance ? "yes" : "no",
+    formatMicroCents(row.amount),
+    formatMicroCents(row.ppuAmount),
+    model1.model,
+    model1.percent,
+    model2.model,
+    model2.percent,
+    model3.model,
+    model3.percent,
+    ...weeks.flatMap((item) => {
+      const t = row.weeklyTokens[formatWeek(item)] ?? { input: "-", cacheRead: "-", output: "-" }
+      return [
+        row.weekly[formatWeek(item)] ?? "",
+        row.ppuWeekly[formatWeek(item)] ?? "",
+        String(t.input),
+        String(t.cacheRead),
+        String(t.output),
+      ]
+    }),
+  ]
+  lines.push(cells.map(csvCell).join(","))
+}
+const output = `${lines.join("\n")}\n`
+const file = Bun.file(`black-stats-${plan}.csv`)
+await file.write(output)
+console.log(`Wrote ${lines.length - 1} rows to ${file.name}`)
+const total = rows.reduce((sum, row) => sum + row.amount, 0)
+const average = rows.length === 0 ? 0 : total / rows.length
+console.log(`Average spending per user: ${formatMicroCents(average)}`)
+
+function formatMicroCents(value: number) {
+  return `$${(value / 100000000).toFixed(2)}`
+}
+
+function formatDate(value: Date | null | undefined) {
+  if (!value) return null
+  return value.toISOString().split("T")[0]
+}
+
+function formatWeek(value: number) {
+  return formatDate(isoWeekStart(value)) ?? ""
+}
+
+function startOfMonth(value: Date) {
+  return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), 1))
+}
+
+function isoWeekStart(value: number) {
+  const year = Math.floor(value / 100)
+  const weekNumber = value % 100
+  const jan4 = new Date(Date.UTC(year, 0, 4))
+  const day = jan4.getUTCDay() || 7
+  const weekStart = new Date(Date.UTC(year, 0, 4 - (day - 1)))
+  weekStart.setUTCDate(weekStart.getUTCDate() + (weekNumber - 1) * 7)
+  return weekStart
+}
+
+function toNumber(value: unknown) {
+  if (typeof value === "number") return value
+  if (typeof value === "bigint") return Number(value)
+  if (typeof value === "string") return Number(value)
+  return 0
+}
+
+function csvCell(value: string | number) {
+  const text = String(value)
+  if (!/[",\n]/.test(text)) return text
+  return `"${text.replace(/"/g, '""')}"`
+}

+ 0 - 22
packages/console/core/script/promote-black.ts

@@ -1,22 +0,0 @@
-#!/usr/bin/env bun
-
-import { $ } from "bun"
-import path from "path"
-import { BlackData } from "../src/black"
-
-const stage = process.argv[2]
-if (!stage) throw new Error("Stage is required")
-
-const root = path.resolve(process.cwd(), "..", "..", "..")
-
-// read the secret
-const ret = await $`bun sst secret list`.cwd(root).text()
-const lines = ret.split("\n")
-const value = lines.find((line) => line.startsWith("ZEN_BLACK_LIMITS"))?.split("=")[1]
-if (!value) throw new Error("ZEN_BLACK_LIMITS not found")
-
-// validate value
-BlackData.validate(JSON.parse(value))
-
-// update the secret
-await $`bun sst secret set ZEN_BLACK_LIMITS ${value} --stage ${stage}`

+ 5 - 5
packages/console/core/script/promote-lite.ts → packages/console/core/script/promote-limits.ts

@@ -2,7 +2,7 @@
 
 
 import { $ } from "bun"
 import { $ } from "bun"
 import path from "path"
 import path from "path"
-import { LiteData } from "../src/lite"
+import { Subscription } from "../src/subscription"
 
 
 const stage = process.argv[2]
 const stage = process.argv[2]
 if (!stage) throw new Error("Stage is required")
 if (!stage) throw new Error("Stage is required")
@@ -12,11 +12,11 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
 // read the secret
 // read the secret
 const ret = await $`bun sst secret list`.cwd(root).text()
 const ret = await $`bun sst secret list`.cwd(root).text()
 const lines = ret.split("\n")
 const lines = ret.split("\n")
-const value = lines.find((line) => line.startsWith("ZEN_LITE_LIMITS"))?.split("=")[1]
-if (!value) throw new Error("ZEN_LITE_LIMITS not found")
+const value = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1]
+if (!value) throw new Error("ZEN_LIMITS not found")
 
 
 // validate value
 // validate value
-LiteData.validate(JSON.parse(value))
+Subscription.validate(JSON.parse(value))
 
 
 // update the secret
 // update the secret
-await $`bun sst secret set ZEN_LITE_LIMITS ${value} --stage ${stage}`
+await $`bun sst secret set ZEN_LIMITS ${value} --stage ${stage}`

+ 0 - 28
packages/console/core/script/update-black.ts

@@ -1,28 +0,0 @@
-#!/usr/bin/env bun
-
-import { $ } from "bun"
-import path from "path"
-import os from "os"
-import { BlackData } from "../src/black"
-
-const root = path.resolve(process.cwd(), "..", "..", "..")
-const secrets = await $`bun sst secret list`.cwd(root).text()
-
-// read value
-const lines = secrets.split("\n")
-const oldValue = lines.find((line) => line.startsWith("ZEN_BLACK_LIMITS"))?.split("=")[1] ?? "{}"
-if (!oldValue) throw new Error("ZEN_BLACK_LIMITS not found")
-
-// store the prettified json to a temp file
-const filename = `black-${Date.now()}.json`
-const tempFile = Bun.file(path.join(os.tmpdir(), filename))
-await tempFile.write(JSON.stringify(JSON.parse(oldValue), null, 2))
-console.log("tempFile", tempFile.name)
-
-// open temp file in vim and read the file on close
-await $`vim ${tempFile.name}`
-const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
-BlackData.validate(JSON.parse(newValue))
-
-// update the secret
-await $`bun sst secret set ZEN_BLACK_LIMITS ${newValue}`

+ 6 - 6
packages/console/core/script/update-lite.ts → packages/console/core/script/update-limits.ts

@@ -3,18 +3,18 @@
 import { $ } from "bun"
 import { $ } from "bun"
 import path from "path"
 import path from "path"
 import os from "os"
 import os from "os"
-import { LiteData } from "../src/lite"
+import { Subscription } from "../src/subscription"
 
 
 const root = path.resolve(process.cwd(), "..", "..", "..")
 const root = path.resolve(process.cwd(), "..", "..", "..")
 const secrets = await $`bun sst secret list`.cwd(root).text()
 const secrets = await $`bun sst secret list`.cwd(root).text()
 
 
 // read value
 // read value
 const lines = secrets.split("\n")
 const lines = secrets.split("\n")
-const oldValue = lines.find((line) => line.startsWith("ZEN_LITE_LIMITS"))?.split("=")[1] ?? "{}"
-if (!oldValue) throw new Error("ZEN_LITE_LIMITS not found")
+const oldValue = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1] ?? "{}"
+if (!oldValue) throw new Error("ZEN_LIMITS not found")
 
 
 // store the prettified json to a temp file
 // store the prettified json to a temp file
-const filename = `lite-${Date.now()}.json`
+const filename = `limits-${Date.now()}.json`
 const tempFile = Bun.file(path.join(os.tmpdir(), filename))
 const tempFile = Bun.file(path.join(os.tmpdir(), filename))
 await tempFile.write(JSON.stringify(JSON.parse(oldValue), null, 2))
 await tempFile.write(JSON.stringify(JSON.parse(oldValue), null, 2))
 console.log("tempFile", tempFile.name)
 console.log("tempFile", tempFile.name)
@@ -22,7 +22,7 @@ console.log("tempFile", tempFile.name)
 // open temp file in vim and read the file on close
 // open temp file in vim and read the file on close
 await $`vim ${tempFile.name}`
 await $`vim ${tempFile.name}`
 const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
 const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
-LiteData.validate(JSON.parse(newValue))
+Subscription.validate(JSON.parse(newValue))
 
 
 // update the secret
 // update the secret
-await $`bun sst secret set ZEN_LITE_LIMITS ${newValue}`
+await $`bun sst secret set ZEN_LIMITS ${newValue}`

+ 2 - 24
packages/console/core/src/black.ts

@@ -2,37 +2,15 @@ import { z } from "zod"
 import { fn } from "./util/fn"
 import { fn } from "./util/fn"
 import { Resource } from "@opencode-ai/console-resource"
 import { Resource } from "@opencode-ai/console-resource"
 import { BlackPlans } from "./schema/billing.sql"
 import { BlackPlans } from "./schema/billing.sql"
+import { Subscription } from "./subscription"
 
 
 export namespace BlackData {
 export namespace BlackData {
-  const Schema = z.object({
-    "200": z.object({
-      fixedLimit: z.number().int(),
-      rollingLimit: z.number().int(),
-      rollingWindow: z.number().int(),
-    }),
-    "100": z.object({
-      fixedLimit: z.number().int(),
-      rollingLimit: z.number().int(),
-      rollingWindow: z.number().int(),
-    }),
-    "20": z.object({
-      fixedLimit: z.number().int(),
-      rollingLimit: z.number().int(),
-      rollingWindow: z.number().int(),
-    }),
-  })
-
-  export const validate = fn(Schema, (input) => {
-    return input
-  })
-
   export const getLimits = fn(
   export const getLimits = fn(
     z.object({
     z.object({
       plan: z.enum(BlackPlans),
       plan: z.enum(BlackPlans),
     }),
     }),
     ({ plan }) => {
     ({ plan }) => {
-      const json = JSON.parse(Resource.ZEN_BLACK_LIMITS.value)
-      return Schema.parse(json)[plan]
+      return Subscription.getLimits()["black"][plan]
     },
     },
   )
   )
 
 

+ 2 - 13
packages/console/core/src/lite.ts

@@ -1,22 +1,11 @@
 import { z } from "zod"
 import { z } from "zod"
 import { fn } from "./util/fn"
 import { fn } from "./util/fn"
 import { Resource } from "@opencode-ai/console-resource"
 import { Resource } from "@opencode-ai/console-resource"
+import { Subscription } from "./subscription"
 
 
 export namespace LiteData {
 export namespace LiteData {
-  const Schema = z.object({
-    rollingLimit: z.number().int(),
-    rollingWindow: z.number().int(),
-    weeklyLimit: z.number().int(),
-    monthlyLimit: z.number().int(),
-  })
-
-  export const validate = fn(Schema, (input) => {
-    return input
-  })
-
   export const getLimits = fn(z.void(), () => {
   export const getLimits = fn(z.void(), () => {
-    const json = JSON.parse(Resource.ZEN_LITE_LIMITS.value)
-    return Schema.parse(json)
+    return Subscription.getLimits()["lite"]
   })
   })
 
 
   export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
   export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)

+ 3 - 33
packages/console/core/src/model.ts

@@ -9,24 +9,7 @@ import { Resource } from "@opencode-ai/console-resource"
 
 
 export namespace ZenData {
 export namespace ZenData {
   const FormatSchema = z.enum(["anthropic", "google", "openai", "oa-compat"])
   const FormatSchema = z.enum(["anthropic", "google", "openai", "oa-compat"])
-  const TrialSchema = z.object({
-    provider: z.string(),
-    limits: z.array(
-      z.object({
-        limit: z.number(),
-        client: z.enum(["cli", "desktop"]).optional(),
-      }),
-    ),
-  })
-  const RateLimitSchema = z.object({
-    period: z.enum(["day", "rolling"]),
-    value: z.number().int(),
-    checkHeader: z.string().optional(),
-    fallbackValue: z.number().int().optional(),
-  })
   export type Format = z.infer<typeof FormatSchema>
   export type Format = z.infer<typeof FormatSchema>
-  export type Trial = z.infer<typeof TrialSchema>
-  export type RateLimit = z.infer<typeof RateLimitSchema>
 
 
   const ModelCostSchema = z.object({
   const ModelCostSchema = z.object({
     input: z.number(),
     input: z.number(),
@@ -43,8 +26,7 @@ export namespace ZenData {
     allowAnonymous: z.boolean().optional(),
     allowAnonymous: z.boolean().optional(),
     byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
     byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
     stickyProvider: z.enum(["strict", "prefer"]).optional(),
     stickyProvider: z.enum(["strict", "prefer"]).optional(),
-    trial: TrialSchema.optional(),
-    rateLimit: RateLimitSchema.optional(),
+    trialProvider: z.string().optional(),
     fallbackProvider: z.string().optional(),
     fallbackProvider: z.string().optional(),
     providers: z.array(
     providers: z.array(
       z.object({
       z.object({
@@ -63,19 +45,12 @@ export namespace ZenData {
     format: FormatSchema.optional(),
     format: FormatSchema.optional(),
     headerMappings: z.record(z.string(), z.string()).optional(),
     headerMappings: z.record(z.string(), z.string()).optional(),
     payloadModifier: z.record(z.string(), z.any()).optional(),
     payloadModifier: z.record(z.string(), z.any()).optional(),
-    family: z.string().optional(),
-  })
-
-  const ProviderFamilySchema = z.object({
-    headers: z.record(z.string(), z.string()).optional(),
-    responseModifier: z.record(z.string(), z.string()).optional(),
   })
   })
 
 
   const ModelsSchema = z.object({
   const ModelsSchema = z.object({
     models: z.record(z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))])),
     models: z.record(z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))])),
     liteModels: z.record(z.string(), ModelSchema),
     liteModels: z.record(z.string(), ModelSchema),
     providers: z.record(z.string(), ProviderSchema),
     providers: z.record(z.string(), ProviderSchema),
-    providerFamilies: z.record(z.string(), ProviderFamilySchema),
   })
   })
 
 
   export const validate = fn(ModelsSchema, (input) => {
   export const validate = fn(ModelsSchema, (input) => {
@@ -115,15 +90,10 @@ export namespace ZenData {
         Resource.ZEN_MODELS29.value +
         Resource.ZEN_MODELS29.value +
         Resource.ZEN_MODELS30.value,
         Resource.ZEN_MODELS30.value,
     )
     )
-    const { models, liteModels, providers, providerFamilies } = ModelsSchema.parse(json)
+    const { models, liteModels, providers } = ModelsSchema.parse(json)
     return {
     return {
       models: modelList === "lite" ? liteModels : models,
       models: modelList === "lite" ? liteModels : models,
-      providers: Object.fromEntries(
-        Object.entries(providers).map(([id, provider]) => [
-          id,
-          { ...provider, ...(provider.family ? providerFamilies[provider.family] : {}) },
-        ]),
-      ),
+      providers,
     }
     }
   })
   })
 }
 }

+ 46 - 0
packages/console/core/src/subscription.ts

@@ -2,8 +2,54 @@ import { z } from "zod"
 import { fn } from "./util/fn"
 import { fn } from "./util/fn"
 import { centsToMicroCents } from "./util/price"
 import { centsToMicroCents } from "./util/price"
 import { getWeekBounds, getMonthlyBounds } from "./util/date"
 import { getWeekBounds, getMonthlyBounds } from "./util/date"
+import { Resource } from "@opencode-ai/console-resource"
 
 
 export namespace Subscription {
 export namespace Subscription {
+  const LimitsSchema = z.object({
+    free: z.object({
+      promoTokens: z.number().int(),
+      dailyRequests: z.number().int(),
+      checkHeader: z.string(),
+      fallbackValue: z.number().int(),
+    }),
+    lite: z.object({
+      rollingLimit: z.number().int(),
+      rollingWindow: z.number().int(),
+      weeklyLimit: z.number().int(),
+      monthlyLimit: z.number().int(),
+    }),
+    black: z.object({
+      "20": z.object({
+        fixedLimit: z.number().int(),
+        rollingLimit: z.number().int(),
+        rollingWindow: z.number().int(),
+      }),
+      "100": z.object({
+        fixedLimit: z.number().int(),
+        rollingLimit: z.number().int(),
+        rollingWindow: z.number().int(),
+      }),
+      "200": z.object({
+        fixedLimit: z.number().int(),
+        rollingLimit: z.number().int(),
+        rollingWindow: z.number().int(),
+      }),
+    }),
+  })
+
+  export const validate = fn(LimitsSchema, (input) => {
+    return input
+  })
+
+  export const getLimits = fn(z.void(), () => {
+    const json = JSON.parse(Resource.ZEN_LIMITS.value)
+    return LimitsSchema.parse(json)
+  })
+
+  export const getFreeLimits = fn(z.void(), () => {
+    return getLimits()["free"]
+  })
+
   export const analyzeRollingUsage = fn(
   export const analyzeRollingUsage = fn(
     z.object({
     z.object({
       limit: z.number().int(),
       limit: z.number().int(),

+ 1 - 5
packages/console/core/sst-env.d.ts

@@ -119,10 +119,6 @@ declare module "sst" {
       "type": "sst.cloudflare.StaticSite"
       "type": "sst.cloudflare.StaticSite"
       "url": string
       "url": string
     }
     }
-    "ZEN_BLACK_LIMITS": {
-      "type": "sst.sst.Secret"
-      "value": string
-    }
     "ZEN_BLACK_PRICE": {
     "ZEN_BLACK_PRICE": {
       "plan100": string
       "plan100": string
       "plan20": string
       "plan20": string
@@ -130,7 +126,7 @@ declare module "sst" {
       "product": string
       "product": string
       "type": "sst.sst.Linkable"
       "type": "sst.sst.Linkable"
     }
     }
-    "ZEN_LITE_LIMITS": {
+    "ZEN_LIMITS": {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string
     }
     }

+ 1 - 1
packages/console/function/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/console-function",
   "name": "@opencode-ai/console-function",
-  "version": "1.2.15",
+  "version": "1.2.16",
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",

+ 1 - 5
packages/console/function/sst-env.d.ts

@@ -119,10 +119,6 @@ declare module "sst" {
       "type": "sst.cloudflare.StaticSite"
       "type": "sst.cloudflare.StaticSite"
       "url": string
       "url": string
     }
     }
-    "ZEN_BLACK_LIMITS": {
-      "type": "sst.sst.Secret"
-      "value": string
-    }
     "ZEN_BLACK_PRICE": {
     "ZEN_BLACK_PRICE": {
       "plan100": string
       "plan100": string
       "plan20": string
       "plan20": string
@@ -130,7 +126,7 @@ declare module "sst" {
       "product": string
       "product": string
       "type": "sst.sst.Linkable"
       "type": "sst.sst.Linkable"
     }
     }
-    "ZEN_LITE_LIMITS": {
+    "ZEN_LIMITS": {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string
     }
     }

+ 1 - 1
packages/console/mail/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/console-mail",
   "name": "@opencode-ai/console-mail",
-  "version": "1.2.15",
+  "version": "1.2.16",
   "dependencies": {
   "dependencies": {
     "@jsx-email/all": "2.2.3",
     "@jsx-email/all": "2.2.3",
     "@jsx-email/cli": "1.4.3",
     "@jsx-email/cli": "1.4.3",

+ 1 - 5
packages/console/resource/sst-env.d.ts

@@ -119,10 +119,6 @@ declare module "sst" {
       "type": "sst.cloudflare.StaticSite"
       "type": "sst.cloudflare.StaticSite"
       "url": string
       "url": string
     }
     }
-    "ZEN_BLACK_LIMITS": {
-      "type": "sst.sst.Secret"
-      "value": string
-    }
     "ZEN_BLACK_PRICE": {
     "ZEN_BLACK_PRICE": {
       "plan100": string
       "plan100": string
       "plan20": string
       "plan20": string
@@ -130,7 +126,7 @@ declare module "sst" {
       "product": string
       "product": string
       "type": "sst.sst.Linkable"
       "type": "sst.sst.Linkable"
     }
     }
-    "ZEN_LITE_LIMITS": {
+    "ZEN_LIMITS": {
       "type": "sst.sst.Secret"
       "type": "sst.sst.Secret"
       "value": string
       "value": string
     }
     }

Some files were not shown because too many files changed in this diff