Просмотр исходного кода

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

shivam kr chaudhary 1 месяц назад
Родитель
Сommit
86a2379af2
100 измененных файлов с 3556 добавлено и 1232 удалено
  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": {
                 "*": "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: |

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

@@ -99,7 +99,6 @@ jobs:
         with:
           name: opencode-cli
           path: packages/opencode/dist
-
     outputs:
       version: ${{ needs.version.outputs.version }}
 
@@ -240,11 +239,130 @@ jobs:
           APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
           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:
     needs:
       - version
       - build-cli
       - build-tauri
+      - build-electron
     runs-on: blacksmith-4vcpu-ubuntu-2404
     steps:
       - uses: actions/checkout@v3
@@ -281,6 +399,12 @@ jobs:
           name: opencode-cli
           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)
         uses: actions/cache@v4
         with:
@@ -308,3 +432,4 @@ jobs:
           GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
           GH_REPO: ${{ needs.version.outputs.repo }}
           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.
 
+### 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
 // Good
 const foo = 1

+ 1 - 0
README.ar.md

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

Разница между файлами не показана из-за своего большого размера
+ 284 - 47
bun.lock


+ 3 - 3
flake.lock

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

+ 1 - 4
infra/console.ts

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

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "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
         ../patches
         ../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:desktop": "bun --cwd packages/desktop tauri dev",
     "dev:web": "bun --cwd packages/app dev",
+    "dev:storybook": "bun --cwd packages/storybook storybook",
     "typecheck": "bun turbo typecheck",
     "prepare": "husky",
     "random": "echo 'Random script'",
@@ -35,7 +36,7 @@
       "@tsconfig/bun": "1.0.9",
       "@cloudflare/workers-types": "4.20251008.0",
       "@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",
       "@tailwindcss/vite": "4.1.11",
       "diff": "8.0.2",
@@ -98,7 +99,8 @@
     "protobufjs",
     "tree-sitter",
     "tree-sitter-bash",
-    "web-tree-sitter"
+    "web-tree-sitter",
+    "electron"
   ],
   "overrides": {
     "@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(?:[/?#]|$)`))
 
-        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
 
-        await page.goto(sessionPath(workspaceDir, created))
-        await expect(page.locator(promptSelector)).toBeVisible()
         await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
 
         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 }) => {
   await withDockSession(sdk, "e2e composer dock question", async (session) => {
     await withDockSeed(sdk, session.id, async () => {

+ 1 - 1
packages/app/package.json

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

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

@@ -145,6 +145,7 @@ try {
     Object.assign(process.env, serverEnv)
     process.env.AGENT = "1"
     process.env.OPENCODE = "1"
+    process.env.OPENCODE_PID = String(process.pid)
 
     const log = await import("../../opencode/src/util/log")
     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 { ThemeProvider } from "@opencode-ai/ui/theme"
 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 { CommentsProvider } from "@/context/comments"
 import { FileProvider } from "@/context/file"
@@ -28,6 +28,7 @@ import { TerminalProvider } from "@/context/terminal"
 import DirectoryLayout from "@/pages/directory-layout"
 import Layout from "@/pages/layout"
 import { ErrorPage } from "./pages/error"
+import { Dynamic } from "solid-js/web"
 
 const Home = lazy(() => import("@/pages/home"))
 const Session = lazy(() => import("@/pages/session"))
@@ -144,13 +145,15 @@ export function AppInterface(props: {
   children?: JSX.Element
   defaultServer: ServerConnection.Key
   servers?: Array<ServerConnection.Any>
+  router?: Component<BaseRouterProps>
 }) {
   return (
     <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
       <ServerKey>
         <GlobalSDKProvider>
           <GlobalSyncProvider>
-            <Router
+            <Dynamic
+              component={props.router ?? Router}
               root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
             >
               <Route path="/" component={HomeRoute} />
@@ -158,7 +161,7 @@ export function AppInterface(props: {
                 <Route path="/" component={SessionIndexRoute} />
                 <Route path="/session/:id?" component={SessionRoute} />
               </Route>
-            </Router>
+            </Dynamic>
           </GlobalSyncProvider>
         </GlobalSDKProvider>
       </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 { Icon } from "@opencode-ai/ui/icon"
 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 { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 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="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">
             <Switch>
               <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 { useDialog } from "@opencode-ai/ui/context/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 { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Tag } from "@opencode-ai/ui/tag"
@@ -95,7 +94,7 @@ export const DialogSelectModelUnpaid: Component = () => {
               >
                 {(i) => (
                   <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>
                     <Show when={i.id === "opencode"}>
                       <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 { Tag } from "@opencode-ai/ui/tag"
 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 { useLanguage } from "@/context/language"
 import { DialogCustomProvider } from "./dialog-custom-provider"
 
 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 = () => {
   const dialog = useDialog()
   const providers = useProviders()
@@ -69,7 +63,7 @@ export const DialogSelectProvider: Component = () => {
       >
         {(i) => (
           <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>
             <Show when={i.id === "opencode"}>
               <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 = file.tree.children(props.path)
     const current = filter()

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

@@ -1,4 +1,5 @@
 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 { createStore } from "solid-js/store"
 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 { Icon } from "@opencode-ai/ui/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 { IconButton } from "@opencode-ai/ui/icon-button"
 import { Select } from "@opencode-ai/ui/select"
@@ -244,6 +244,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     draggingType: "image" | "@mention" | null
     mode: "normal" | "shell"
     applyingHistory: boolean
+    pendingAutoAccept: boolean
   }>({
     popover: null,
     historyIndex: -1,
@@ -252,8 +253,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     draggingType: null,
     mode: "normal",
     applyingHistory: false,
+    pendingAutoAccept: false,
   })
 
+  const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
+
   const commentCount = createMemo(() => {
     if (store.mode === "shell") return 0
     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 byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
     return prompt.context.items().flatMap((item) => {
@@ -592,7 +602,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     setActive: setSlashActive,
     onInput: slashOnInput,
     onKeyDown: slashOnKeyDown,
-    refetch: slashRefetch,
   } = useFilteredList<SlashCommand>({
     items: slashCommands,
     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
   createEffect(() => {
     const activeId = slashActive()
@@ -957,10 +958,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     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({
     info,
     imageAttachments,
     commentCount,
+    autoAccept: () => accepting(),
     mode: () => store.mode,
     working,
     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 (
     <div class="relative size-full _max-h-[320px] flex flex-col gap-0">
       <PromptPopover
@@ -1251,10 +1253,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
             <div
               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
@@ -1267,6 +1268,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   type="button"
                   variant="ghost"
                   class="size-8 p-0"
+                  style={{
+                    opacity: buttonsSpring(),
+                    transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
+                    filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
+                  }}
                   onClick={pick}
                   disabled={store.mode !== "normal"}
                   tabIndex={store.mode === "normal" ? undefined : -1}
@@ -1304,6 +1310,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   icon={working() ? "stop" : "arrow-up"}
                   variant="primary"
                   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")}
                 />
               </Tooltip>
@@ -1323,9 +1334,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 <Button
                   data-action="prompt-permissions"
                   variant="ghost"
-                  disabled={!params.id}
                   onClick={() => {
-                    if (!params.id) return
+                    if (!params.id) {
+                      setStore("pendingAutoAccept", (value) => !value)
+                      return
+                    }
                     permission.toggleAutoAccept(params.id, sdk.directory)
                   }}
                   classList={{
@@ -1354,14 +1367,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       <Show when={store.mode === "normal" || store.mode === "shell"}>
         <DockTray attach="top">
           <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
                   placement="top"
                   gutter={4}
@@ -1375,7 +1395,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                     onSelect={local.agent.set}
                     class="capitalize max-w-[160px]"
                     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"
                   />
                 </TooltipKeybind>
@@ -1393,12 +1419,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                         variant="ghost"
                         size="normal"
                         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 />)}
                       >
                         <Show when={local.model.current()?.provider?.id}>
                           <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"
                             style={{ "will-change": "opacity", transform: "translateZ(0)" }}
                           />
@@ -1422,13 +1454,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                       triggerProps={{
                         variant: "ghost",
                         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",
                       }}
                     >
                       <Show when={local.model.current()?.provider?.id}>
                         <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"
                           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)}
                     class="capitalize max-w-[160px]"
                     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"
                   />
                 </TooltipKeybind>
-              </Show>
+              </div>
             </div>
             <div class="shrink-0">
               <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 createdSessions: string[] = []
+const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
 const sentShell: 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", () => ({
     usePrompt: () => ({
       current: () => promptValue,
@@ -145,6 +154,7 @@ beforeAll(async () => {
 beforeEach(() => {
   createdClients.length = 0
   createdSessions.length = 0
+  enabledAutoAccept.length = 0
   sentShell.length = 0
   syncedDirectories.length = 0
   selected = "/repo/worktree-a"
@@ -156,6 +166,7 @@ describe("prompt submit worktree selection", () => {
       info: () => undefined,
       imageAttachments: () => [],
       commentCount: () => 0,
+      autoAccept: () => false,
       mode: () => "shell",
       working: () => false,
       editor: () => undefined,
@@ -181,4 +192,31 @@ describe("prompt submit worktree selection", () => {
     expect(sentShell).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 { useLayout } from "@/context/layout"
 import { useLocal } from "@/context/local"
+import { usePermission } from "@/context/permission"
 import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
@@ -27,6 +28,7 @@ type PromptSubmitInput = {
   info: Accessor<{ id: string } | undefined>
   imageAttachments: Accessor<ImageAttachmentPart[]>
   commentCount: Accessor<number>
+  autoAccept: Accessor<boolean>
   mode: Accessor<"normal" | "shell">
   working: Accessor<boolean>
   editor: () => HTMLDivElement | undefined
@@ -56,6 +58,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
   const sync = useSync()
   const globalSync = useGlobalSync()
   const local = useLocal()
+  const permission = usePermission()
   const prompt = usePrompt()
   const layout = useLayout()
   const language = useLanguage()
@@ -140,6 +143,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
 
     const projectDirectory = sdk.directory
     const isNewSession = !params.id
+    const shouldAutoAccept = isNewSession && input.autoAccept()
     const worktreeSelection = input.newSessionWorktree?.() || "main"
 
     let sessionDirectory = projectDirectory
@@ -197,6 +201,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
           return undefined
         })
       if (session) {
+        if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
         layout.handoff.setTabs(base64Encode(sessionDirectory), 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>
   currentSession: () =>
     | {
-        id: string
         share?: {
           url?: string
         }
       }
     | undefined
+  sessionID: () => string | undefined
   projectDirectory: () => string
   platform: ReturnType<typeof usePlatform>
 }) {
@@ -167,11 +167,11 @@ function useSessionShare(args: {
   })
 
   const shareSession = () => {
-    const session = args.currentSession()
-    if (!session || state.share) return
+    const sessionID = args.sessionID()
+    if (!sessionID || state.share) return
     setState("share", true)
     args.globalSDK.client.session
-      .share({ sessionID: session.id, directory: args.projectDirectory() })
+      .share({ sessionID, directory: args.projectDirectory() })
       .catch((error) => {
         console.error("Failed to share session", error)
       })
@@ -181,11 +181,11 @@ function useSessionShare(args: {
   }
 
   const unshareSession = () => {
-    const session = args.currentSession()
-    if (!session || state.unshare) return
+    const sessionID = args.sessionID()
+    if (!sessionID || state.unshare) return
     setState("unshare", true)
     args.globalSDK.client.session
-      .unshare({ sessionID: session.id, directory: args.projectDirectory() })
+      .unshare({ sessionID, directory: args.projectDirectory() })
       .catch((error) => {
         console.error("Failed to unshare session", error)
       })
@@ -243,9 +243,9 @@ export function SessionHeader() {
   })
   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 showShare = createMemo(() => shareEnabled() && !!currentSession())
+  const showShare = createMemo(() => shareEnabled() && !!params.id)
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const view = createMemo(() => layout.view(sessionKey))
   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 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) => {
     if (opening() || !canOpen() || !platform.openPath) return
@@ -347,6 +346,7 @@ export function SessionHeader() {
   const share = useSessionShare({
     globalSDK,
     currentSession,
+    sessionID: () => params.id,
     projectDirectory,
     platform,
   })
@@ -458,7 +458,7 @@ export function SessionHeader() {
                                   value={current().id}
                                   onChange={(value) => {
                                     if (!OPEN_APPS.includes(value as OpenApp)) return
-                                    setPrefs("app", value as OpenApp)
+                                    selectApp(value as OpenApp)
                                   }}
                                 >
                                   <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 { IconButton } from "@opencode-ai/ui/icon-button"
 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 { useLanguage } from "@/context/language"
 import { useModels } from "@/context/models"
@@ -98,7 +97,7 @@ export const SettingsModels: Component = () => {
               {(group) => (
                 <div class="flex flex-col gap-1">
                   <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>
                   </div>
                   <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 { Tag } from "@opencode-ai/ui/tag"
 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 { createMemo, type Component, For, Show } from "solid-js"
 import { useLanguage } from "@/context/language"
@@ -33,11 +32,6 @@ export const SettingsProviders: Component = () => {
   const globalSync = useGlobalSync()
   const providers = useProviders()
 
-  const icon = (id: string): IconName => {
-    if (iconNames.includes(id as IconName)) return id as IconName
-    return "synthetic"
-  }
-
   const connected = createMemo(() => {
     return providers
       .connected()
@@ -154,7 +148,7 @@ export const SettingsProviders: Component = () => {
                 {(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="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>
                       <Tag>{type(item)}</Tag>
                     </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-col min-w-0">
                     <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>
                       <Show when={item.id === "opencode"}>
                         <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-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>
                   <Tag>{language.t("settings.providers.tag.custom")}</Tag>
                 </div>

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

@@ -202,29 +202,26 @@ export function StatusPopover() {
       triggerAs={Button}
       triggerProps={{
         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 },
       }}
       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>
       }
       class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
       gutter={4}
       placement="bottom-end"
-      shift={-136}
+      shift={-168}
     >
       <div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
         <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 { showToast } from "@opencode-ai/ui/toast"
 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 { matchKeybind, parseKeybind } from "@/context/command"
 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 = () => {
     if (disposed) return
@@ -259,8 +259,7 @@ export const Terminal = (props: TerminalProps) => {
   }
 
   createEffect(() => {
-    const colors = getTerminalColors()
-    setTerminalColors(colors)
+    const colors = terminalColors()
     if (!term) return
     setOptionIfSupported(term, "theme", colors)
   })

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

@@ -157,6 +157,7 @@ export function Titlebar() {
     <header
       class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
       style={{ "min-height": minHeight() }}
+      data-tauri-drag-region
       onMouseDown={drag}
       onDblClick={maximize}
     >
@@ -276,6 +277,7 @@ export function Titlebar() {
           "flex items-center min-w-0 justify-end": true,
           "pr-2": !windows(),
         }}
+        data-tauri-drag-region
         onMouseDown={drag}
       >
         <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 {
   createContext,
-  createEffect,
   getOwner,
   Match,
   onCleanup,
@@ -35,7 +34,6 @@ import { trimSessions } from "./global-sync/session-trim"
 import type { ProjectMeta } from "./global-sync/types"
 import { SESSION_RECENT_LIMIT } from "./global-sync/types"
 import { sanitizeProject } from "./global-sync/utils"
-import { usePlatform } from "./platform"
 import { formatServerError } from "@/utils/server-errors"
 
 type GlobalStore = {
@@ -54,7 +52,6 @@ type GlobalStore = {
 
 function createGlobalSync() {
   const globalSDK = useGlobalSDK()
-  const platform = usePlatform()
   const language = useLanguage()
   const owner = getOwner()
   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 sessionMeta = new Map<string, { limit: number }>()
 
-  const [projectCache, setProjectCache, , projectCacheReady] = persisted(
+  const [projectCache, setProjectCache, projectInit] = persisted(
     Persist.global("globalSync.project", ["globalSync.project.v1"]),
     createStore({ value: [] as Project[] }),
   )
@@ -80,6 +77,57 @@ function createGlobalSync() {
     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) => {
     if (!sessionID) return
     if (!todos) {
@@ -127,30 +175,6 @@ function createGlobalSync() {
     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) {
     const pending = sessionLoads.get(directory)
     if (pending) return pending
@@ -259,13 +283,7 @@ function createGlobalSync() {
         event,
         project: globalStore.project,
         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") {
         for (const directory of Object.keys(children.children)) {
@@ -316,7 +334,7 @@ function createGlobalSync() {
       unknownError: language.t("error.chain.unknown"),
       invalidConfigurationError: language.t("error.server.invalidConfiguration"),
       formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
-      setGlobalStore,
+      setGlobalStore: setBootStore,
     })
   }
 
@@ -340,7 +358,9 @@ function createGlobalSync() {
       .update({ config })
       .then(bootstrap)
       .then(() => {
-        setGlobalStore("reload", "complete")
+        queue.refresh()
+        setGlobalStore("reload", undefined)
+        queue.refresh()
       })
       .catch((error) => {
         setGlobalStore("reload", undefined)
@@ -350,7 +370,7 @@ function createGlobalSync() {
 
   return {
     data: globalStore,
-    set: setGlobalStore,
+    set,
     get 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 { Persist, persisted } from "@/utils/persist"
 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")
       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, () =>
         persisted(
@@ -154,10 +153,12 @@ export function createChildStoreManager(input: {
 
       const init = () =>
         createRoot((dispose) => {
+          const initialMeta = meta[0].value
+          const initialIcon = icon[0].value
           const child = createStore<State>({
             project: "",
-            projectMeta: meta[0].value,
-            icon: icon[0].value,
+            projectMeta: initialMeta,
+            icon: initialIcon,
             provider: { all: [], connected: [], default: {} },
             config: {},
             path: { state: "", config: "", worktree: "", directory: "", home: "" },
@@ -181,16 +182,27 @@ export function createChildStoreManager(input: {
           children[directory] = child
           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
             if (!cached?.branch) return
             child[1]("vcs", (value) => value ?? cached)
           })
-          createEffect(() => {
+
+          onPersistedInit(meta[2], () => {
+            if (child[0].projectMeta !== initialMeta) return
             child[1]("projectMeta", meta[0].value)
           })
-          createEffect(() => {
+
+          onPersistedInit(icon[2], () => {
+            if (child[0].icon !== initialIcon) return
             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 { Project } from "@opencode-ai/sdk/v2"
 import { Persist, persisted, removePersisted } from "@/utils/persist"
+import { decode64 } from "@/utils/base64"
 import { same } from "@/utils/same"
 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 DEFAULT_PANEL_WIDTH = 344
@@ -96,6 +98,38 @@ function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string):
   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({
   name: "Layout",
   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 {
         ...value,
         sidebar: migratedSidebar,
         review: migratedReview,
         fileTree: migratedFileTree,
+        sessionTabs: migratedSessionTabs,
       }
     }
 
@@ -745,22 +813,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       },
       tabs(sessionKey: string | Accessor<string>) {
         const key = createSessionKeyReader(sessionKey, ensureKey)
+        const path = createMemo(() => sessionPath(key()))
         const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
+        const normalize = (tab: string) => normalizeSessionTab(path(), tab)
+        const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
         return {
           tabs,
           active: createMemo(() => tabs().active),
           all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
           setActive(tab: string | undefined) {
             const session = key()
+            const next = tab ? normalize(tab) : tab
             if (!store.sessionTabs[session]) {
-              setStore("sessionTabs", session, { all: [], active: tab })
+              setStore("sessionTabs", session, { all: [], active: next })
             } else {
-              setStore("sessionTabs", session, "active", tab)
+              setStore("sessionTabs", session, "active", next)
             }
           },
           setAll(all: string[]) {
             const session = key()
-            const next = all.filter((tab) => tab !== "review")
+            const next = normalizeAll(all).filter((tab) => tab !== "review")
             if (!store.sessionTabs[session]) {
               setStore("sessionTabs", session, { all: next, active: undefined })
             } else {
@@ -769,7 +841,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           },
           async open(tab: string) {
             const session = key()
-            const next = nextSessionTabsForOpen(store.sessionTabs[session], tab)
+            const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
             setStore("sessionTabs", session, next)
           },
           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)
   })
 
-  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 autoAccept = {
       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", () => {

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

@@ -37,5 +37,5 @@ export function autoRespondsPermission(
   const value = sessionLineage(session, permission.sessionID)
     .map((id) => accepted(autoAccept, id, directory))
     .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) {
   const messages = draft.message[input.sessionID]
-  if (!messages) {
-    draft.message[input.sessionID] = [input.message]
-  }
   if (messages) {
     const result = Binary.search(messages, input.message.id, (m) => m.id)
     messages.splice(result.index, 0, input.message)
+  } else {
+    draft.message[input.sessionID] = [input.message]
   }
   draft.part[input.message.id] = sortParts(input.parts)
 }
@@ -105,7 +104,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       return globalSync.child(directory)
     }
     const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
-    const messagePageSize = 400
+    const messagePageSize = 200
     const inflight = new Map<string, Promise<void>>()
     const inflightDiff = 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
     }
 
-    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 messages = await retry(() =>
         input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
       )
       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) }))
       return {
         session,
@@ -159,8 +150,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         .then((next) => {
           batch(() => {
             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("complete", key, next.complete)
@@ -229,17 +220,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           const client = sdk.client
           const [store, setStore] = globalSync.child(directory)
           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
             ? 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(() => {}))
         },
@@ -290,14 +270,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           const client = sdk.client
           const [store, setStore] = globalSync.child(directory)
           const existing = store.todo[sessionID]
+          const cached = globalSync.data.session_todo[sessionID]
           if (existing !== undefined) {
-            if (globalSync.data.session_todo[sessionID] === undefined) {
+            if (cached === undefined) {
               globalSync.todo.set(sessionID, existing)
             }
             return
           }
 
-          const cached = globalSync.data.session_todo[sessionID]
           if (cached !== undefined) {
             setStore("todo", sessionID, reconcile(cached, { key: "id" }))
           }
@@ -324,11 +304,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             const key = keyFor(sdk.directory, sessionID)
             return meta.loading[key] ?? false
           },
-          async loadMore(sessionID: string, count = messagePageSize) {
+          async loadMore(sessionID: string, count?: number) {
             const directory = sdk.directory
             const client = sdk.client
             const [, setStore] = globalSync.child(directory)
             const key = keyFor(directory, sessionID)
+            const step = count ?? messagePageSize
             if (meta.loading[key]) return
             if (meta.complete[key]) return
 
@@ -338,7 +319,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
               client,
               setStore,
               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 { playSound, soundSrc } from "@/utils/sound"
 import { createAim } from "@/utils/aim"
+import { setNavigate } from "@/utils/notification-click"
 import { Worktree as WorktreeState } from "@/utils/worktree"
 
 import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -59,11 +60,11 @@ import { useLanguage, type Locale } from "@/context/language"
 import {
   childMapByParent,
   displayName,
+  effectiveWorkspaceOrder,
   errorMessage,
   getDraggableId,
   latestRootSession,
   sortedRootSessions,
-  syncWorkspaceOrder,
   workspaceKey,
 } from "./layout/helpers"
 import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links"
@@ -107,6 +108,7 @@ export default function Layout(props: ParentProps) {
   const notification = useNotification()
   const permission = usePermission()
   const navigate = useNavigate()
+  setNavigate(navigate)
   const providers = useProviders()
   const dialog = useDialog()
   const command = useCommand()
@@ -481,21 +483,6 @@ export default function Layout(props: ParentProps) {
     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(
     on(
       () => ({ 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)()
   })
 
-  createEffect(() => {
-    if (!pageReady()) return
-    if (!layoutReady()) return
+  const visibleSessionDirs = createMemo(() => {
     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(() => {
@@ -593,25 +568,17 @@ export default function Layout(props: ParentProps) {
   })
 
   const currentSessions = createMemo(() => {
-    const project = currentProject()
-    if (!project) return [] as Session[]
     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 = {
@@ -826,7 +793,6 @@ export default function Layout(props: ParentProps) {
     }
 
     navigateToSession(session)
-    queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
   }
 
   function navigateSessionByUnseen(offset: number) {
@@ -861,7 +827,6 @@ export default function Layout(props: ParentProps) {
       }
 
       navigateToSession(session)
-      queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
       return
     }
   }
@@ -1094,34 +1059,90 @@ export default function Layout(props: ParentProps) {
     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) {
     if (!directory) return
     const root = projectRoot(directory)
     server.projects.touch(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 }) => {
+      if (!canOpen(target.directory)) return false
       const resolved = await globalSDK.client.session
         .get({ sessionID: target.id })
         .then((x) => x.data)
         .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]
     if (projectSession?.id) {
-      await openSession(projectSession)
-      return
+      await refreshDirs(projectSession.directory)
+      const opened = await openSession(projectSession)
+      if (opened) return
+      clearLastProjectSession(root)
     }
 
     const latest = latestRootSession(
       dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
       Date.now(),
     )
-    if (latest) {
-      await openSession(latest)
+    if (latest && (await openSession(latest))) {
       return
     }
 
@@ -1137,8 +1158,7 @@ export default function Layout(props: ParentProps) {
       ),
       Date.now(),
     )
-    if (fetched) {
-      await openSession(fetched)
+    if (fetched && (await openSession(fetched))) {
       return
     }
 
@@ -1195,11 +1215,28 @@ export default function Layout(props: ParentProps) {
   }
 
   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)
-    if (next) navigateToProject(next.worktree)
-    else navigate("/")
+    queueMicrotask(() => {
+      void navigateToProject(next.worktree)
+    })
   }
 
   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
 
+    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)
 
     const result = await globalSDK.client.worktree
@@ -1260,6 +1305,10 @@ export default function Layout(props: ParentProps) {
 
     if (!result) return
 
+    if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) {
+      clearLastProjectSession(root)
+    }
+
     globalSync.set(
       "project",
       produce((draft) => {
@@ -1273,8 +1322,18 @@ export default function Layout(props: ParentProps) {
     layout.projects.close(directory)
     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 leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory)
+      if (leaveDeletedWorkspace) {
+        navigateWithSidebarReset(`/${base64Encode(props.root)}/session`)
+      }
       dialog.close()
-      void deleteWorkspace(props.root, props.directory)
+      void deleteWorkspace(props.root, props.directory, leaveDeletedWorkspace)
     }
 
     const description = () => {
@@ -1486,26 +1549,42 @@ export default function Layout(props: ParentProps) {
     )
   }
 
+  const activeRoute = {
+    session: "",
+    sessionProject: "",
+  }
+
   createEffect(
     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)
         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>()
 
-  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) {
     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 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(() => {
@@ -1623,7 +1688,11 @@ export default function Layout(props: ParentProps) {
     const [item] = result.splice(fromIndex, 1)
     if (!item) return
     result.splice(toIndex, 0, item)
-    setStore("workspaceOrder", project.worktree, result)
+    setStore(
+      "workspaceOrder",
+      project.worktree,
+      result.filter((directory) => workspaceKey(directory) !== workspaceKey(project.worktree)),
+    )
   }
 
   function handleWorkspaceDragEnd() {
@@ -1661,10 +1730,9 @@ export default function Layout(props: ParentProps) {
       const existing = prev ?? []
       const next = existing.filter((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)
@@ -2015,7 +2083,11 @@ export default function Layout(props: ParentProps) {
               onOpenSettings={openSettings}
               helpLabel={() => language.t("sidebar.help")}
               onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
-              renderPanel={() => <SidebarPanel project={currentProject()} />}
+              renderPanel={() => (
+                <Show when={currentProject()} keyed>
+                  {(project) => <SidebarPanel project={project} />}
+                </Show>
+              )}
             />
           </div>
           <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
 }
 
-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 { createResizeObserver } from "@solid-primitives/resize-observer"
 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 { 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() {
   const layout = useLayout()
   const local = useLocal()
@@ -138,24 +359,6 @@ export default function Page() {
     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 diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
   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)
   })
 
-  const emptyUserMessages: UserMessage[] = []
   const userMessages = createMemo(
     () => messages().filter((m) => m.role === "user") as UserMessage[],
     emptyUserMessages,
@@ -211,29 +413,26 @@ export default function Page() {
 
   const [store, setStore] = createStore({
     messageId: undefined as string | undefined,
-    turnStart: 0,
     mobileTab: "session" as "session" | "changes",
     changes: "session" as "session" | "turn",
     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 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(() => {
     if (store.newSessionWorktree === "create") return "create"
     const project = sync.project
@@ -302,13 +501,15 @@ export default function Page() {
 
   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(
     on(
@@ -535,35 +736,12 @@ export default function Page() {
     loadingClass: 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
             title={changesTitle()}
+            empty={emptyTurn()}
             diffs={reviewDiffs}
             view={view}
             diffStyle={input.diffStyle}
@@ -580,39 +758,64 @@ export default function Page() {
             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>
+        </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 = () => (
@@ -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(
     () => promptDock,
@@ -986,7 +1117,9 @@ export default function Page() {
 
       const el = scroller
       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
 
@@ -1002,13 +1135,12 @@ export default function Page() {
     sessionID: () => params.id,
     messagesReady,
     visibleUserMessages,
-    turnStart: () => store.turnStart,
+    turnStart: historyWindow.turnStart,
     currentMessageId: () => store.messageId,
     pendingMessage: () => ui.pendingMessage,
     setPendingMessage: (value) => setUi("pendingMessage", value),
     setActiveMessage,
-    setTurnStart: (value) => setStore("turnStart", value),
-    scheduleTurnBackfill,
+    setTurnStart: historyWindow.setTurnStart,
     autoScroll,
     scroller: () => scroller,
     anchor,
@@ -1021,7 +1153,6 @@ export default function Page() {
   })
 
   onCleanup(() => {
-    cancelTurnBackfill()
     document.removeEventListener("keydown", handleKeyDown)
     scrollSpy.destroy()
     if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
@@ -1076,6 +1207,7 @@ export default function Page() {
                     hasScrollGesture={hasScrollGesture}
                     isDesktop={isDesktop()}
                     onScrollSpyScroll={scrollSpy.onScroll}
+                    onTurnBackfillScroll={historyWindow.onScrollerScroll}
                     onAutoScrollInteraction={autoScroll.handleInteraction}
                     centered={centered()}
                     setContentRef={(el) => {
@@ -1085,21 +1217,16 @@ export default function Page() {
                       const root = scroller
                       if (root) scheduleScrollState(root)
                     }}
-                    turnStart={store.turnStart}
-                    onRenderEarlier={() => setStore("turnStart", 0)}
+                    turnStart={historyWindow.turnStart()}
                     historyMore={historyMore()}
                     historyLoading={historyLoading()}
                     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}
                     onRegisterMessage={scrollSpy.register}
                     onUnregisterMessage={scrollSpy.unregister}
-                    lastUserMessageID={lastUserMessage()?.id}
                   />
                 </Show>
               </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 { useSpring } from "@opencode-ai/ui/motion-spring"
 import { PromptInput } from "@/components/prompt-input"
 import { useLanguage } from "@/context/language"
 import { usePrompt } from "@/context/prompt"
@@ -18,6 +19,23 @@ export function SessionComposerRegion(props: {
   onSubmit: () => void
   onResponseSubmit: () => 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 prompt = usePrompt()
@@ -43,6 +61,37 @@ export function SessionComposerRegion(props: {
     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 (
     <div
       ref={props.setPromptDockRef}
@@ -87,30 +136,46 @@ export function SessionComposerRegion(props: {
               </div>
             }
           >
-            <Show when={props.state.dock()}>
+            <Show when={dock()}>
               <div
                 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>
             </Show>
             <div
               classList={{
                 "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

+ 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 sdk = useSDK()
   const sync = useSync()
@@ -96,12 +96,19 @@ export function createSessionComposerState() {
   let timer: 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 = () => {
     if (timer) window.clearTimeout(timer)
     timer = window.setTimeout(() => {
       setStore({ dock: false, closing: false })
       timer = undefined
-    }, 400)
+    }, closeMs())
   }
 
   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 { AnimatedNumber } from "@opencode-ai/ui/animated-number"
 import { Checkbox } from "@opencode-ai/ui/checkbox"
 import { DockTray } from "@opencode-ai/ui/dock-surface"
 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"
 
 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({
     collapsed: false,
   })
 
   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(
     () =>
@@ -53,56 +73,134 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
   )
 
   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 (
     <DockTray
       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 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>
     </DockTray>
   )
@@ -171,33 +269,43 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
           }, 250)
         }}
       >
-        <For each={props.todos}>
+        <Index each={props.todos}>
           {(todo) => (
             <Checkbox
               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"
-                classList={{
-                  "text-text-weak": todo.status === "completed" || todo.status === "cancelled",
-                  "text-text-strong": todo.status !== "completed" && todo.status !== "cancelled",
-                }}
                 style={{
                   "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>
           )}
-        </For>
+        </Index>
       </div>
       <div
         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 scrollFrame: number | undefined
+  let restoreFrame: number | undefined
   let pending: { x: number; y: number } | undefined
   let codeScroll: HTMLElement[] = []
   let find: FileSearchHandle | null = null
@@ -349,6 +350,15 @@ export function FileTabContent(props: { tab: string }) {
     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 }) => {
     if (codeScroll.length === 0) syncCodeScroll()
 
@@ -364,46 +374,29 @@ export function FileTabContent(props: { tab: string }) {
     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(() => {
     for (const item of codeScroll) {
       item.removeEventListener("scroll", handleCodeScroll)
     }
 
-    if (scrollFrame === undefined) return
-    cancelAnimationFrame(scrollFrame)
+    if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
+    if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
   })
 
   const renderFile = (source: string) => (
@@ -421,7 +414,7 @@ export function FileTabContent(props: { tab: string }) {
         selectedLines={activeSelection()}
         commentedLines={commentedLines()}
         onRendered={() => {
-          requestAnimationFrame(restoreScroll)
+          queueRestore()
         }}
         annotations={commentsUi.annotations()}
         renderAnnotation={commentsUi.renderAnnotation}
@@ -440,7 +433,7 @@ export function FileTabContent(props: { tab: string }) {
           mode: "auto",
           path: path(),
           current: state()?.content,
-          onLoad: () => requestAnimationFrame(restoreScroll),
+          onLoad: queueRestore,
           onError: (args: { kind: "image" | "audio" | "svg" }) => {
             if (args.kind !== "svg") return
             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 { useNavigate, useParams } from "@solidjs/router"
 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 { SessionTurn } from "@opencode-ai/ui/session-turn"
 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 { Binary } from "@opencode-ai/util/binary"
 import { getFilename } from "@opencode-ai/util/path"
 import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
 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[] =>
   parts.flatMap((part) => {
     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: {
   mobileChanges: boolean
   mobileFallback: JSX.Element
@@ -93,11 +194,11 @@ export function MessageTimeline(props: {
   hasScrollGesture: () => boolean
   isDesktop: boolean
   onScrollSpyScroll: () => void
+  onTurnBackfillScroll: () => void
   onAutoScrollInteraction: (event: MouseEvent) => void
   centered: boolean
   setContentRef: (el: HTMLDivElement) => void
   turnStart: number
-  onRenderEarlier: () => void
   historyMore: boolean
   historyLoading: boolean
   onLoadEarlier: () => void
@@ -105,7 +206,6 @@ export function MessageTimeline(props: {
   anchor: (id: string) => string
   onRegisterMessage: (el: HTMLDivElement, id: string) => void
   onUnregisterMessage: (id: string) => void
-  lastUserMessageID?: string
 }) {
   let touchGesture: number | undefined
 
@@ -117,8 +217,43 @@ export function MessageTimeline(props: {
   const dialog = useDialog()
   const language = useLanguage()
 
+  const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + 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 id = sessionID()
     if (!id) return
@@ -127,6 +262,13 @@ export function MessageTimeline(props: {
   const titleValue = createMemo(() => info()?.title)
   const parentID = createMemo(() => info()?.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({
     draft: "",
@@ -343,8 +485,10 @@ export function MessageTimeline(props: {
         <div
           class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
           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
@@ -393,6 +537,7 @@ export function MessageTimeline(props: {
           }}
           onScroll={(e) => {
             props.onScheduleScrollState(e.currentTarget)
+            props.onTurnBackfillScroll()
             if (!props.hasScrollGesture()) return
             props.onAutoScrollHandleScroll()
             props.onMarkScrollGesture(e.currentTarget)
@@ -530,14 +675,7 @@ export function MessageTimeline(props: {
               "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">
                 <Button
                   variant="ghost"
@@ -552,56 +690,74 @@ export function MessageTimeline(props: {
                 </Button>
               </div>
             </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 (
                   <div
-                    id={props.anchor(message.id)}
-                    data-message-id={message.id}
+                    id={props.anchor(messageID)}
+                    data-message-id={messageID}
                     ref={(el) => {
-                      props.onRegisterMessage(el, message.id)
-                      onCleanup(() => props.onUnregisterMessage(message.id))
+                      props.onRegisterMessage(el, messageID)
+                      onCleanup(() => props.onUnregisterMessage(messageID))
                     }}
                     classList={{
                       "min-w-0 w-full max-w-full": true,
                       "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="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
                           <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>
-                              )}
-                            </For>
+                                )
+                              }}
+                            </Index>
                           </div>
                         </div>
                       </div>
                     </Show>
                     <SessionTurn
                       sessionID={sessionID() ?? ""}
-                      messageID={message.id}
-                      lastUserMessageID={props.lastUserMessageID}
+                      messageID={messageID}
+                      active={active()}
+                      queued={queued()}
+                      status={active() ? sessionStatus() : undefined}
                       showReasoningSummaries={settings.general.showReasoningSummaries()}
                       shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
                       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 { SessionReview } from "@opencode-ai/ui/session-review"
 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(() => {
     if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
@@ -176,7 +156,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
       open={props.view().review.open()}
       onOpenChange={props.view().review.setOpen}
       classes={{
-        root: props.classes?.root ?? "pb-6 pr-3",
+        root: props.classes?.root ?? "pr-3",
         header: props.classes?.header ?? "px-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(
       () => terminal.all().length,
       (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"
 
 export const messageIdFromHash = (hash: string) => {
@@ -19,7 +19,6 @@ export const useSessionHashScroll = (input: {
   setPendingMessage: (value: string | undefined) => void
   setActiveMessage: (message: UserMessage | undefined) => void
   setTurnStart: (value: number) => void
-  scheduleTurnBackfill: () => void
   autoScroll: { pause: () => void; forceScrollToBottom: () => void }
   scroller: () => HTMLDivElement | undefined
   anchor: (id: string) => string
@@ -29,6 +28,7 @@ export const useSessionHashScroll = (input: {
   const visibleUserMessages = createMemo(() => input.visibleUserMessages())
   const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
   const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
+  let pendingKey = ""
 
   const clearMessageHash = () => {
     if (!window.location.hash) return
@@ -58,7 +58,6 @@ export const useSessionHashScroll = (input: {
     const index = messageIndex().get(message.id) ?? -1
     if (index !== -1 && index < input.turnStart()) {
       input.setTurnStart(index)
-      input.scheduleTurnBackfill()
 
       requestAnimationFrame(() => {
         const el = document.getElementById(input.anchor(message.id))
@@ -132,15 +131,6 @@ export const useSessionHashScroll = (input: {
     if (el) input.scheduleScrollState(el)
   }
 
-  createEffect(
-    on(input.sessionKey, (key) => {
-      if (!input.sessionID()) return
-      const messageID = input.consumePendingMessage(key)
-      if (!messageID) return
-      input.setPendingMessage(messageID)
-    }),
-  )
-
   createEffect(() => {
     if (!input.sessionID() || !input.messagesReady()) return
     requestAnimationFrame(() => applyHash("auto"))
@@ -152,7 +142,20 @@ export const useSessionHashScroll = (input: {
     visibleUserMessages()
     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 (input.currentMessageId() === targetId) return
 
@@ -164,9 +167,16 @@ export const useSessionHashScroll = (input: {
     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)
     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", () => {
-  test("focuses and navigates when href exists", () => {
+  afterEach(() => {
+    setNavigate(undefined as any)
+  })
+
+  test("navigates via registered navigate function", () => {
     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[] = []
-    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
-  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",
-  "version": "1.2.15",
+  "version": "1.2.16",
   "type": "module",
   "license": "MIT",
   "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 "./app.css"
 import { LanguageProvider } from "~/context/language"
-import { I18nProvider } from "~/context/i18n"
+import { I18nProvider, useI18n } from "~/context/i18n"
 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() {
   return (
     <Router
@@ -19,10 +31,7 @@ export default function App() {
         <LanguageProvider>
           <I18nProvider>
             <MetaProvider>
-              <Title>opencode</Title>
-              <Meta name="description" content="OpenCode - The open source coding agent." />
-              <Favicon />
-              <Font />
+              <AppMeta />
               <Suspense>{props.children}</Suspense>
             </MetaProvider>
           </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">
       <div onContextMenu={handleLogoContextMenu}>
         <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>
       </div>
 

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

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

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

@@ -15,6 +15,7 @@ export const dict = {
   "nav.home": "Início",
   "nav.openMenu": "Abrir menu",
   "nav.getStartedFree": "Começar grátis",
+  "nav.logoAlt": "OpenCode",
 
   "nav.context.copyLogo": "Copiar logo como SVG",
   "nav.context.copyWordmark": "Copiar marca como SVG",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "Documentação",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "logo opencode claro",
+  "notFound.logoDarkAlt": "logo opencode escuro",
 
   "user.logout": "Sair",
 
+  "auth.callback.error.codeMissing": "Nenhum código de autorização encontrado.",
+
   "workspace.select": "Selecionar workspace",
   "workspace.createNew": "+ 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.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",
 
   "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.screenshot.caption": "OpenCode TUI com o 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.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",
   "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.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",
@@ -451,6 +478,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "Por favor, atualize sua forma de pagamento e tente novamente.",
   "workspace.reload.retrying": "Tentando novamente...",
   "workspace.reload.retry": "Tentar novamente",
+  "workspace.reload.error.paymentFailed": "Pagamento falhou.",
 
   "workspace.payments.title": "Histórico de Pagamentos",
   "workspace.payments.subtitle": "Transações de pagamento recentes.",
@@ -571,6 +599,10 @@ export const dict = {
   "enterprise.form.send": "Enviar",
   "enterprise.form.sending": "Enviando...",
   "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.q1": "O que é OpenCode Enterprise?",
   "enterprise.faq.a1":
@@ -603,6 +635,7 @@ export const dict = {
   "bench.list.table.agent": "Agente",
   "bench.list.table.model": "Modelo",
   "bench.list.table.score": "Pontuação",
+  "bench.submission.error.allFieldsRequired": "Todos os campos são obrigatórios.",
 
   "bench.detail.title": "Benchmark - {{task}}",
   "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.openMenu": "Åbn menu",
   "nav.getStartedFree": "Kom i gang gratis",
+  "nav.logoAlt": "OpenCode",
 
   "nav.context.copyLogo": "Kopier logo som SVG",
   "nav.context.copyWordmark": "Kopier wordmark som SVG",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "Dokumentation",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "opencode logo light",
+  "notFound.logoDarkAlt": "opencode logo dark",
 
   "user.logout": "Log ud",
 
+  "auth.callback.error.codeMissing": "Ingen autorisationskode fundet.",
+
   "workspace.select": "Vælg workspace",
   "workspace.createNew": "+ 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.reloadTriggerMin": "Saldogrænse skal være mindst ${{amount}}",
 
+  "app.meta.description": "OpenCode - Den open source kodningsagent.",
+
   "home.title": "OpenCode | Den open source AI-kodningsagent",
 
   "temp.title": "opencode | AI-kodningsagent bygget til terminalen",
@@ -91,6 +98,8 @@ export const dict = {
   "temp.feature.models.afterLink": ", inklusive lokale modeller",
   "temp.screenshot.caption": "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.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",
   "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.description": "Få adgang til Claude, GPT, Gemini og mere med OpenCode Black-abonnementer.",
   "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.retrying": "Prøver igen...",
   "workspace.reload.retry": "Prøv igen",
+  "workspace.reload.error.paymentFailed": "Betaling mislykkedes.",
 
   "workspace.payments.title": "Betalingshistorik",
   "workspace.payments.subtitle": "Seneste betalingstransaktioner.",
@@ -567,6 +595,10 @@ export const dict = {
   "enterprise.form.send": "Send",
   "enterprise.form.sending": "Sender...",
   "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.q1": "Hvad er OpenCode Enterprise?",
   "enterprise.faq.a1":
@@ -599,6 +631,7 @@ export const dict = {
   "bench.list.table.agent": "Agent",
   "bench.list.table.model": "Model",
   "bench.list.table.score": "Score",
+  "bench.submission.error.allFieldsRequired": "Alle felter er påkrævet.",
 
   "bench.detail.title": "Benchmark - {{task}}",
   "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.openMenu": "Menü öffnen",
   "nav.getStartedFree": "Kostenlos starten",
+  "nav.logoAlt": "OpenCode",
 
   "nav.context.copyLogo": "Logo als SVG kopieren",
   "nav.context.copyWordmark": "Wortmarke als SVG kopieren",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "Dokumentation",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "OpenCode Logo hell",
+  "notFound.logoDarkAlt": "OpenCode Logo dunkel",
 
   "user.logout": "Abmelden",
 
+  "auth.callback.error.codeMissing": "Kein Autorisierungscode gefunden.",
+
   "workspace.select": "Workspace auswählen",
   "workspace.createNew": "+ Neuen Workspace erstellen",
   "workspace.modal.title": "Neuen Workspace erstellen",
@@ -76,6 +81,8 @@ export const dict = {
   "error.reloadAmountMin": "Aufladebetrag 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",
 
   "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.screenshot.caption": "OpenCode TUI mit dem 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.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",
   "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.description": "Erhalte Zugriff auf Claude, GPT, Gemini und mehr mit OpenCode Black Abos.",
   "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.retrying": "Versuche erneut...",
   "workspace.reload.retry": "Erneut versuchen",
+  "workspace.reload.error.paymentFailed": "Zahlung fehlgeschlagen.",
 
   "workspace.payments.title": "Zahlungshistorie",
   "workspace.payments.subtitle": "Kürzliche Zahlungstransaktionen.",
@@ -571,6 +599,10 @@ export const dict = {
   "enterprise.form.send": "Senden",
   "enterprise.form.sending": "Sende...",
   "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.q1": "Was ist OpenCode Enterprise?",
   "enterprise.faq.a1":
@@ -603,6 +635,7 @@ export const dict = {
   "bench.list.table.agent": "Agent",
   "bench.list.table.model": "Modell",
   "bench.list.table.score": "Score",
+  "bench.submission.error.allFieldsRequired": "Alle Felder sind erforderlich.",
 
   "bench.detail.title": "Benchmark - {{task}}",
   "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.openMenu": "Open menu",
   "nav.getStartedFree": "Get started for free",
+  "nav.logoAlt": "OpenCode",
 
   "nav.context.copyLogo": "Copy logo as SVG",
   "nav.context.copyWordmark": "Copy wordmark as SVG",
@@ -38,9 +39,13 @@ export const dict = {
   "notFound.docs": "Docs",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "opencode logo light",
+  "notFound.logoDarkAlt": "opencode logo dark",
 
   "user.logout": "Logout",
 
+  "auth.callback.error.codeMissing": "No authorization code found.",
+
   "workspace.select": "Select workspace",
   "workspace.createNew": "+ 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.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",
 
   "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.screenshot.caption": "opencode TUI with the 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.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",
   "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.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",
@@ -443,6 +470,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "Please update your payment method and try again.",
   "workspace.reload.retrying": "Retrying...",
   "workspace.reload.retry": "Retry",
+  "workspace.reload.error.paymentFailed": "Payment failed.",
 
   "workspace.payments.title": "Payments History",
   "workspace.payments.subtitle": "Recent payment transactions.",
@@ -561,6 +589,10 @@ export const dict = {
   "enterprise.form.send": "Send",
   "enterprise.form.sending": "Sending...",
   "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.q1": "What is OpenCode Enterprise?",
   "enterprise.faq.a1":
@@ -593,6 +625,7 @@ export const dict = {
   "bench.list.table.agent": "Agent",
   "bench.list.table.model": "Model",
   "bench.list.table.score": "Score",
+  "bench.submission.error.allFieldsRequired": "All fields are required.",
 
   "bench.detail.title": "Benchmark - {{task}}",
   "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.openMenu": "Abrir menú",
   "nav.getStartedFree": "Empezar gratis",
+  "nav.logoAlt": "OpenCode",
 
   "nav.context.copyLogo": "Copiar logo como SVG",
   "nav.context.copyWordmark": "Copiar marca como SVG",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "Documentación",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "opencode logo claro",
+  "notFound.logoDarkAlt": "opencode logo oscuro",
 
   "user.logout": "Cerrar sesión",
 
+  "auth.callback.error.codeMissing": "No se encontró código de autorización.",
+
   "workspace.select": "Seleccionar espacio de trabajo",
   "workspace.createNew": "+ 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.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",
 
   "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.screenshot.caption": "opencode TUI con el 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.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",
   "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.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",
@@ -452,6 +479,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "Por favor actualiza tu método de pago e intenta de nuevo.",
   "workspace.reload.retrying": "Reintentando...",
   "workspace.reload.retry": "Reintentar",
+  "workspace.reload.error.paymentFailed": "El pago falló.",
 
   "workspace.payments.title": "Historial de Pagos",
   "workspace.payments.subtitle": "Transacciones de pago recientes.",
@@ -571,6 +599,10 @@ export const dict = {
   "enterprise.form.send": "Enviar",
   "enterprise.form.sending": "Enviando...",
   "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.q1": "¿Qué es OpenCode Enterprise?",
   "enterprise.faq.a1":
@@ -603,6 +635,7 @@ export const dict = {
   "bench.list.table.agent": "Agente",
   "bench.list.table.model": "Modelo",
   "bench.list.table.score": "Puntuación",
+  "bench.submission.error.allFieldsRequired": "Todos los campos son obligatorios.",
 
   "bench.detail.title": "Benchmark - {{task}}",
   "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 = {
   ...en,
+  "app.meta.description": "OpenCode - L'agent de code open source.",
   "nav.github": "GitHub",
   "nav.docs": "Documentation",
   "nav.changelog": "Changelog",
@@ -15,6 +16,7 @@ export const dict = {
   "nav.home": "Accueil",
   "nav.openMenu": "Ouvrir le menu",
   "nav.getStartedFree": "Commencer gratuitement",
+  "nav.logoAlt": "OpenCode",
 
   "nav.context.copyLogo": "Copier le logo en SVG",
   "nav.context.copyWordmark": "Copier le logotype en SVG",
@@ -42,6 +44,8 @@ export const dict = {
   "notFound.docs": "Documentation",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "opencode logo light",
+  "notFound.logoDarkAlt": "opencode logo dark",
 
   "user.logout": "Se déconnecter",
 
@@ -75,6 +79,7 @@ export const dict = {
   "error.modelRequired": "Le modèle est requis",
   "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}} $",
+  "auth.callback.error.codeMissing": "Aucun code d'autorisation trouvé.",
 
   "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.screenshot.caption": "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.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",
   "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.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",
@@ -457,6 +482,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "Veuillez mettre à jour votre méthode de paiement et réessayer.",
   "workspace.reload.retrying": "Nouvelle tentative...",
   "workspace.reload.retry": "Réessayer",
+  "workspace.reload.error.paymentFailed": "Échec du paiement.",
 
   "workspace.payments.title": "Historique des paiements",
   "workspace.payments.subtitle": "Transactions de paiement récentes.",
@@ -581,6 +607,10 @@ export const dict = {
   "enterprise.form.send": "Envoyer",
   "enterprise.form.sending": "Envoi...",
   "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.q1": "Qu'est-ce que OpenCode Enterprise ?",
   "enterprise.faq.a1":
@@ -640,4 +670,5 @@ export const dict = {
   "bench.detail.table.duration": "Durée",
   "bench.detail.run.title": "Exécution {{n}}",
   "bench.detail.rawJson": "JSON brut",
+  "bench.submission.error.allFieldsRequired": "Tous les champs sont requis.",
 } satisfies Dict

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

@@ -15,6 +15,7 @@ export const dict = {
   "nav.home": "Home",
   "nav.openMenu": "Apri menu",
   "nav.getStartedFree": "Inizia gratis",
+  "nav.logoAlt": "OpenCode",
 
   "nav.context.copyLogo": "Copia il logo come SVG",
   "nav.context.copyWordmark": "Copia il wordmark come SVG",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "Documentazione",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "logo chiaro di opencode",
+  "notFound.logoDarkAlt": "logo scuro di opencode",
 
   "user.logout": "Esci",
 
+  "auth.callback.error.codeMissing": "Nessun codice di autorizzazione trovato.",
+
   "workspace.select": "Seleziona workspace",
   "workspace.createNew": "+ 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.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",
 
   "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.screenshot.caption": "OpenCode TUI con il 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.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",
   "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.description":
     "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.retrying": "Riprovo...",
   "workspace.reload.retry": "Riprova",
+  "workspace.reload.error.paymentFailed": "Pagamento fallito.",
 
   "workspace.payments.title": "Cronologia Pagamenti",
   "workspace.payments.subtitle": "Transazioni di pagamento recenti.",
@@ -569,6 +597,10 @@ export const dict = {
   "enterprise.form.send": "Invia",
   "enterprise.form.sending": "Invio...",
   "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.q1": "Cos'è OpenCode Enterprise?",
   "enterprise.faq.a1":
@@ -601,6 +633,7 @@ export const dict = {
   "bench.list.table.agent": "Agente",
   "bench.list.table.model": "Modello",
   "bench.list.table.score": "Punteggio",
+  "bench.submission.error.allFieldsRequired": "Tutti i campi sono obbligatori.",
 
   "bench.detail.title": "Benchmark - {{task}}",
   "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.openMenu": "メニューを開く",
   "nav.getStartedFree": "無料ではじめる",
+  "nav.logoAlt": "OpenCode",
 
   "nav.context.copyLogo": "ロゴをSVGでコピー",
   "nav.context.copyWordmark": "ワードマークをSVGでコピー",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "ドキュメント",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "opencodeのロゴ(ライト)",
+  "notFound.logoDarkAlt": "opencodeのロゴ(ダーク)",
 
   "user.logout": "ログアウト",
 
+  "auth.callback.error.codeMissing": "認証コードが見つかりません。",
+
   "workspace.select": "ワークスペースを選択",
   "workspace.createNew": "+ 新しいワークスペースを作成",
   "workspace.modal.title": "新しいワークスペースを作成",
@@ -76,6 +81,8 @@ export const dict = {
   "error.reloadAmountMin": "リロード額は少なくとも ${{amount}} である必要があります",
   "error.reloadTriggerMin": "残高トリガーは少なくとも ${{amount}} である必要があります",
 
+  "app.meta.description": "OpenCode - オープンソースのコーディングエージェント。",
+
   "home.title": "OpenCode | オープンソースのAIコーディングエージェント",
 
   "temp.title": "OpenCode | ターミナル向けに構築されたAIコーディングエージェント",
@@ -91,6 +98,8 @@ export const dict = {
   "temp.feature.models.afterLink": "を通じて75以上のLLMプロバイダーをサポート",
   "temp.screenshot.caption": "tokyonight テーマを使用した OpenCode TUI",
   "temp.screenshot.alt": "tokyonight テーマの OpenCode TUI",
+  "temp.logoLightAlt": "opencodeのロゴ(ライト)",
+  "temp.logoDarkAlt": "opencodeのロゴ(ダーク)",
 
   "home.banner.badge": "新着",
   "home.banner.text": "デスクトップアプリのベータ版が利用可能",
@@ -239,6 +248,25 @@ export const dict = {
     "すべてのZenモデルは米国でホストされています。プロバイダーはゼロ保持ポリシーに従い、モデルのトレーニングにデータを使用しません(",
   "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.description": "OpenCode Black サブスクリプションプランで、Claude、GPT、Gemini などにアクセス。",
   "black.hero.title": "世界最高峰のコーディングモデルすべてにアクセス",
@@ -448,6 +476,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "支払い方法を更新して、もう一度お試しください。",
   "workspace.reload.retrying": "再試行中...",
   "workspace.reload.retry": "再試行",
+  "workspace.reload.error.paymentFailed": "支払いに失敗しました。",
 
   "workspace.payments.title": "支払い履歴",
   "workspace.payments.subtitle": "最近の支払い取引。",
@@ -568,6 +597,10 @@ export const dict = {
   "enterprise.form.send": "送信",
   "enterprise.form.sending": "送信中...",
   "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.q1": "OpenCode Enterpriseとは?",
   "enterprise.faq.a1":
@@ -600,6 +633,7 @@ export const dict = {
   "bench.list.table.agent": "エージェント",
   "bench.list.table.model": "モデル",
   "bench.list.table.score": "スコア",
+  "bench.submission.error.allFieldsRequired": "すべての項目は必須です。",
 
   "bench.detail.title": "ベンチマーク - {{task}}",
   "bench.detail.notFound": "タスクが見つかりません",

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

@@ -15,6 +15,7 @@ export const dict = {
   "nav.home": "홈",
   "nav.openMenu": "메뉴 열기",
   "nav.getStartedFree": "무료로 시작하기",
+  "nav.logoAlt": "OpenCode",
 
   "nav.context.copyLogo": "로고를 SVG로 복사",
   "nav.context.copyWordmark": "워드마크를 SVG로 복사",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "문서",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "opencode 밝은 로고",
+  "notFound.logoDarkAlt": "opencode 어두운 로고",
 
   "user.logout": "로그아웃",
 
+  "auth.callback.error.codeMissing": "인증 코드를 찾을 수 없습니다.",
+
   "workspace.select": "워크스페이스 선택",
   "workspace.createNew": "+ 새 워크스페이스 만들기",
   "workspace.modal.title": "새 워크스페이스 만들기",
@@ -76,6 +81,8 @@ export const dict = {
   "error.reloadAmountMin": "충전 금액은 최소 ${{amount}}이어야 합니다",
   "error.reloadTriggerMin": "잔액 트리거는 최소 ${{amount}}이어야 합니다",
 
+  "app.meta.description": "OpenCode - 오픈 소스 코딩 에이전트.",
+
   "home.title": "OpenCode | 오픈 소스 AI 코딩 에이전트",
 
   "temp.title": "OpenCode | 터미널을 위해 만들어진 AI 코딩 에이전트",
@@ -91,6 +98,8 @@ export const dict = {
   "temp.feature.models.afterLink": "를 통해 75개 이상의 LLM 제공자 지원",
   "temp.screenshot.caption": "tokyonight 테마가 적용된 OpenCode TUI",
   "temp.screenshot.alt": "tokyonight 테마가 적용된 OpenCode TUI",
+  "temp.logoLightAlt": "opencode 밝은 로고",
+  "temp.logoDarkAlt": "opencode 어두운 로고",
 
   "home.banner.badge": "신규",
   "home.banner.text": "데스크톱 앱 베타 버전 출시",
@@ -236,6 +245,24 @@ export const dict = {
     "모든 Zen 모델은 미국에서 호스팅됩니다. 제공자들은 데이터 보존 금지 정책을 따르며 모델 학습에 데이터를 사용하지 않습니다. 단,",
   "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.description": "OpenCode Black 구독 플랜으로 Claude, GPT, Gemini 등에 액세스하세요.",
   "black.hero.title": "세계 최고의 코딩 모델에 액세스하세요",
@@ -445,6 +472,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "결제 수단을 업데이트하고 다시 시도해 주세요.",
   "workspace.reload.retrying": "재시도 중...",
   "workspace.reload.retry": "재시도",
+  "workspace.reload.error.paymentFailed": "결제에 실패했습니다.",
 
   "workspace.payments.title": "결제 내역",
   "workspace.payments.subtitle": "최근 결제 거래 내역입니다.",
@@ -562,6 +590,10 @@ export const dict = {
   "enterprise.form.send": "전송",
   "enterprise.form.sending": "전송 중...",
   "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.q1": "OpenCode 엔터프라이즈란 무엇인가요?",
   "enterprise.faq.a1":
@@ -594,6 +626,7 @@ export const dict = {
   "bench.list.table.agent": "에이전트",
   "bench.list.table.model": "모델",
   "bench.list.table.score": "점수",
+  "bench.submission.error.allFieldsRequired": "모든 필드는 필수 항목입니다.",
 
   "bench.detail.title": "벤치마크 - {{task}}",
   "bench.detail.notFound": "태스크를 찾을 수 없음",

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

@@ -15,6 +15,7 @@ export const dict = {
   "nav.home": "Hjem",
   "nav.openMenu": "Åpne meny",
   "nav.getStartedFree": "Kom i gang gratis",
+  "nav.logoAlt": "OpenCode",
 
   "nav.context.copyLogo": "Kopier logo som SVG",
   "nav.context.copyWordmark": "Kopier wordmark som SVG",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "Dokumentasjon",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "opencode logo lys",
+  "notFound.logoDarkAlt": "opencode logo mørk",
 
   "user.logout": "Logg ut",
 
+  "auth.callback.error.codeMissing": "Ingen autorisasjonskode funnet.",
+
   "workspace.select": "Velg arbeidsområde",
   "workspace.createNew": "+ 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.reloadTriggerMin": "Saldo-trigger må være minst ${{amount}}",
 
+  "app.meta.description": "OpenCode - Den åpne kildekode kodingsagenten.",
+
   "home.title": "OpenCode | Den åpne kildekode AI-kodingsagenten",
 
   "temp.title": "opencode | AI-kodingsagent bygget for terminalen",
@@ -91,6 +98,8 @@ export const dict = {
   "temp.feature.models.afterLink": ", inkludert lokale modeller",
   "temp.screenshot.caption": "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.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",
   "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.description": "Få tilgang til Claude, GPT, Gemini og mer med OpenCode Black-abonnementer.",
   "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.retrying": "Prøver på nytt...",
   "workspace.reload.retry": "Prøv på nytt",
+  "workspace.reload.error.paymentFailed": "Betaling mislyktes.",
 
   "workspace.payments.title": "Betalingshistorikk",
   "workspace.payments.subtitle": "Nylige betalingstransaksjoner.",
@@ -567,6 +595,10 @@ export const dict = {
   "enterprise.form.send": "Send",
   "enterprise.form.sending": "Sender...",
   "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.q1": "Hva er OpenCode Enterprise?",
   "enterprise.faq.a1":
@@ -599,6 +631,7 @@ export const dict = {
   "bench.list.table.agent": "Agent",
   "bench.list.table.model": "Modell",
   "bench.list.table.score": "Poengsum",
+  "bench.submission.error.allFieldsRequired": "Alle felt er obligatoriske.",
 
   "bench.detail.title": "Benchmark - {{task}}",
   "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.openMenu": "Otwórz menu",
   "nav.getStartedFree": "Zacznij za darmo",
+  "nav.logoAlt": "OpenCode",
 
   "nav.context.copyLogo": "Skopiuj logo jako SVG",
   "nav.context.copyWordmark": "Skopiuj logotyp jako SVG",
@@ -41,9 +42,13 @@ export const dict = {
   "notFound.docs": "Dokumentacja",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "jasne logo opencode",
+  "notFound.logoDarkAlt": "ciemne logo opencode",
 
   "user.logout": "Wyloguj się",
 
+  "auth.callback.error.codeMissing": "Nie znaleziono kodu autoryzacji.",
+
   "workspace.select": "Wybierz obszar roboczy",
   "workspace.createNew": "+ 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.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",
 
   "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.screenshot.caption": "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.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",
   "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.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",
@@ -450,6 +477,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "Zaktualizuj metodę płatności i spróbuj ponownie.",
   "workspace.reload.retrying": "Ponawianie...",
   "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.subtitle": "Ostatnie transakcje płatnicze.",
@@ -570,6 +598,10 @@ export const dict = {
   "enterprise.form.send": "Wyślij",
   "enterprise.form.sending": "Wysyłanie...",
   "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.q1": "Czym jest OpenCode Enterprise?",
   "enterprise.faq.a1":
@@ -602,6 +634,7 @@ export const dict = {
   "bench.list.table.agent": "Agent",
   "bench.list.table.model": "Model",
   "bench.list.table.score": "Wynik",
+  "bench.submission.error.allFieldsRequired": "Wszystkie pola są wymagane.",
 
   "bench.detail.title": "Benchmark - {{task}}",
   "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.openMenu": "Открыть меню",
   "nav.getStartedFree": "Начать бесплатно",
+  "nav.logoAlt": "OpenCode",
 
   "nav.context.copyLogo": "Скопировать логотип как SVG",
   "nav.context.copyWordmark": "Скопировать название как SVG",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "Документация",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "светлый логотип opencode",
+  "notFound.logoDarkAlt": "темный логотип opencode",
 
   "user.logout": "Выйти",
 
+  "auth.callback.error.codeMissing": "Код авторизации не найден.",
+
   "workspace.select": "Выбрать рабочее пространство",
   "workspace.createNew": "+ Создать рабочее пространство",
   "workspace.modal.title": "Создать рабочее пространство",
@@ -76,6 +81,8 @@ export const dict = {
   "error.reloadAmountMin": "Сумма пополнения должна быть не менее ${{amount}}",
   "error.reloadTriggerMin": "Порог баланса должен быть не менее ${{amount}}",
 
+  "app.meta.description": "OpenCode - AI-агент с открытым кодом для программирования.",
+
   "home.title": "OpenCode | AI-агент с открытым кодом для программирования",
 
   "temp.title": "opencode | AI-агент для программирования в терминале",
@@ -91,6 +98,8 @@ export const dict = {
   "temp.feature.models.afterLink": ", включая локальные модели",
   "temp.screenshot.caption": "OpenCode TUI с темой tokyonight",
   "temp.screenshot.alt": "OpenCode TUI с темой tokyonight",
+  "temp.logoLightAlt": "светлый логотип opencode",
+  "temp.logoDarkAlt": "темный логотип opencode",
 
   "home.banner.badge": "Новое",
   "home.banner.text": "Доступно десктопное приложение (бета)",
@@ -244,6 +253,24 @@ export const dict = {
     "Все модели Zen размещены в США. Провайдеры следуют политике нулевого хранения и не используют ваши данные для обучения моделей, за",
   "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.description": "Получите доступ к Claude, GPT, Gemini и другим моделям с подпиской OpenCode Black.",
   "black.hero.title": "Доступ к лучшим моделям для кодинга в мире",
@@ -455,6 +482,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "Пожалуйста, обновите способ оплаты и попробуйте снова.",
   "workspace.reload.retrying": "Повторная попытка...",
   "workspace.reload.retry": "Повторить",
+  "workspace.reload.error.paymentFailed": "Ошибка оплаты.",
 
   "workspace.payments.title": "История платежей",
   "workspace.payments.subtitle": "Недавние транзакции.",
@@ -574,6 +602,10 @@ export const dict = {
   "enterprise.form.send": "Отправить",
   "enterprise.form.sending": "Отправка...",
   "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.q1": "Что такое OpenCode Enterprise?",
   "enterprise.faq.a1":
@@ -606,6 +638,7 @@ export const dict = {
   "bench.list.table.agent": "Агент",
   "bench.list.table.model": "Модель",
   "bench.list.table.score": "Оценка",
+  "bench.submission.error.allFieldsRequired": "Все поля обязательны.",
 
   "bench.detail.title": "Бенчмарк - {{task}}",
   "bench.detail.notFound": "Задача не найдена",

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

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

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

@@ -15,6 +15,7 @@ export const dict = {
   "nav.home": "Ana sayfa",
   "nav.openMenu": "Menüyü aç",
   "nav.getStartedFree": "Ücretsiz başla",
+  "nav.logoAlt": "OpenCode",
 
   "nav.context.copyLogo": "Logoyu SVG olarak kopyala",
   "nav.context.copyWordmark": "Wordmark'ı SVG olarak kopyala",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "Dokümantasyon",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "opencode açık logo",
+  "notFound.logoDarkAlt": "opencode koyu logo",
 
   "user.logout": "Çıkış",
 
+  "auth.callback.error.codeMissing": "Yetkilendirme kodu bulunamadı.",
+
   "workspace.select": "Çalışma alanı seç",
   "workspace.createNew": "+ 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.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ı",
 
   "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.screenshot.caption": "opencode TUI ve tokyonight teması",
   "temp.screenshot.alt": "tokyonight temalı opencode TUI",
+  "temp.logoLightAlt": "opencode açık logo",
+  "temp.logoDarkAlt": "opencode koyu logo",
 
   "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.downloadNow": "Ş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.monthlyDevs": "Aylık Geliştiriciler",
 
-  "home.privacy.title": "Önce gizlilik için tasarlandı",
+  "home.privacy.title": "Gizlilik öncelikli tasarlandı",
   "home.privacy.body":
     "OpenCode kodunuzu veya bağlam verilerinizi saklamaz; bu sayede gizliliğe duyarlı ortamlarda çalışabilir.",
   "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.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.localLink": "yerel modellerinizi",
+  "home.faq.a3.p4.localLink": "yerel modellerinizi bağlayabilirsiniz",
   "home.faq.q4": "Mevcut AI aboneliklerimi OpenCode ile kullanabilir miyim?",
   "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.",
   "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.and": "ve",
   "home.faq.a5.web": "web",
@@ -169,10 +178,10 @@ export const dict = {
   "home.faq.a7.p2.shareLink": "paylaşım sayfaları",
   "home.faq.q8": "OpenCode açık kaynak mı?",
   "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.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.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",
   "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.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",
@@ -451,6 +478,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "Lütfen ödeme yönteminizi güncelleyin ve tekrar deneyin.",
   "workspace.reload.retrying": "Yeniden deneniyor...",
   "workspace.reload.retry": "Yeniden dene",
+  "workspace.reload.error.paymentFailed": "Ödeme başarısız.",
 
   "workspace.payments.title": "Ödeme Geçmişi",
   "workspace.payments.subtitle": "Son ödeme işlemleri.",
@@ -571,6 +599,10 @@ export const dict = {
   "enterprise.form.send": "Gönder",
   "enterprise.form.sending": "Gönderiliyor...",
   "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.q1": "OpenCode Enterprise nedir?",
   "enterprise.faq.a1":
@@ -603,6 +635,7 @@ export const dict = {
   "bench.list.table.agent": "Ajan",
   "bench.list.table.model": "Model",
   "bench.list.table.score": "Puan",
+  "bench.submission.error.allFieldsRequired": "Tüm alanlar gereklidir.",
 
   "bench.detail.title": "Benchmark - {{task}}",
   "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.openMenu": "打开菜单",
   "nav.getStartedFree": "免费开始",
+  "nav.logoAlt": "OpenCode",
 
   "nav.context.copyLogo": "复制 Logo (SVG)",
   "nav.context.copyWordmark": "复制商标 (SVG)",
@@ -42,9 +43,13 @@ export const dict = {
   "notFound.docs": "文档",
   "notFound.github": "GitHub",
   "notFound.discord": "Discord",
+  "notFound.logoLightAlt": "opencode logo 亮色",
+  "notFound.logoDarkAlt": "opencode logo 暗色",
 
   "user.logout": "退出登录",
 
+  "auth.callback.error.codeMissing": "未找到授权码。",
+
   "workspace.select": "选择工作区",
   "workspace.createNew": "+ 新建工作区",
   "workspace.modal.title": "新建工作区",
@@ -76,6 +81,8 @@ export const dict = {
   "error.reloadAmountMin": "充值金额必须至少为 ${{amount}}",
   "error.reloadTriggerMin": "余额触发阈值必须至少为 ${{amount}}",
 
+  "app.meta.description": "OpenCode - 开源编程代理。",
+
   "home.title": "OpenCode | 开源 AI 编程代理",
 
   "temp.title": "OpenCode | 专为终端打造的 AI 编程代理",
@@ -91,6 +98,8 @@ export const dict = {
   "temp.feature.models.afterLink": ",包括本地模型",
   "temp.screenshot.caption": "使用 Tokyonight 主题的 OpenCode TUI",
   "temp.screenshot.alt": "使用 Tokyonight 主题的 OpenCode TUI",
+  "temp.logoLightAlt": "opencode logo 亮色",
+  "temp.logoDarkAlt": "opencode logo 暗色",
 
   "home.banner.badge": "新",
   "home.banner.text": "桌面应用 Beta 版现已推出",
@@ -229,6 +238,22 @@ export const dict = {
   "zen.privacy.beforeExceptions": "所有 Zen 模型均托管在美国。提供商遵循零留存政策,不使用您的数据进行模型训练,",
   "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.description": "通过 OpenCode Black 订阅计划使用 Claude, GPT, Gemini 等模型。",
   "black.hero.title": "访问全球顶尖编程模型",
@@ -436,6 +461,7 @@ export const dict = {
   "workspace.reload.updatePaymentMethod": "请更新您的付款方式并重试。",
   "workspace.reload.retrying": "正在重试...",
   "workspace.reload.retry": "重试",
+  "workspace.reload.error.paymentFailed": "支付失败。",
 
   "workspace.payments.title": "支付历史",
   "workspace.payments.subtitle": "近期支付交易。",
@@ -552,6 +578,10 @@ export const dict = {
   "enterprise.form.send": "发送",
   "enterprise.form.sending": "正在发送...",
   "enterprise.form.success": "消息已发送,我们会尽快与您联系。",
+  "enterprise.form.success.submitted": "表单提交成功。",
+  "enterprise.form.error.allFieldsRequired": "所有字段均为必填项。",
+  "enterprise.form.error.invalidEmailFormat": "邮箱格式无效。",
+  "enterprise.form.error.internalServer": "内部服务器错误。",
   "enterprise.faq.title": "常见问题",
   "enterprise.faq.q1": "什么是 OpenCode 企业版?",
   "enterprise.faq.a1":
@@ -584,6 +614,7 @@ export const dict = {
   "bench.list.table.agent": "代理",
   "bench.list.table.model": "模型",
   "bench.list.table.score": "分数",
+  "bench.submission.error.allFieldsRequired": "所有字段均为必填项。",
 
   "bench.detail.title": "基准测试 - {{task}}",
   "bench.detail.notFound": "未找到任务",

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

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

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

@@ -48,6 +48,9 @@ const map = {
   "Provider is required": "error.providerRequired",
   "API key is required": "error.apiKeyRequired",
   "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>
 
 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">
         <section data-component="top">
           <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>
           <h1 data-slot="title">{i18n.t("notFound.heading")}</h1>
         </section>

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

@@ -1,5 +1,7 @@
 import type { APIEvent } from "@solidjs/start/server"
 import { AWS } from "@opencode-ai/console-core/aws.js"
+import { i18n } from "~/i18n"
+import { localeFromRequest } from "~/lib/language"
 
 interface EnterpriseFormData {
   name: string
@@ -9,18 +11,19 @@ interface EnterpriseFormData {
 }
 
 export async function POST(event: APIEvent) {
+  const dict = i18n(localeFromRequest(event.request))
   try {
     const body = (await event.request.json()) as EnterpriseFormData
 
     // Validate required fields
     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
     const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
     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
@@ -39,9 +42,9 @@ ${body.email}`.trim()
       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) {
     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 { AuthClient } from "~/context/auth"
 import { useAuthSession } from "~/context/auth"
+import { i18n } from "~/i18n"
 import { localeFromRequest, route } from "~/lib/language"
 
 export async function GET(input: APIEvent) {
   const url = new URL(input.request.url)
   const locale = localeFromRequest(input.request)
+  const dict = i18n(locale)
 
   try {
     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}`)
     if (result.err) throw new Error(result.err.message)
     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 { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
 import { Identifier } from "@opencode-ai/console-core/identifier.js"
+import { i18n } from "~/i18n"
+import { localeFromRequest } from "~/lib/language"
 
 interface SubmissionBody {
   model: string
@@ -10,10 +12,11 @@ interface SubmissionBody {
 }
 
 export async function POST(event: APIEvent) {
+  const dict = i18n(localeFromRequest(event.request))
   const body = (await event.request.json()) as SubmissionBody
 
   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) =>

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

@@ -33,6 +33,7 @@ const brandAssets = "/opencode-brand-assets.zip"
 
 export default function Brand() {
   const i18n = useI18n()
+  const alt = i18n.t("brand.meta.description")
   const downloadFile = async (url: string, filename: string) => {
     try {
       const response = await fetch(url)
@@ -88,7 +89,7 @@ export default function Brand() {
 
             <div data-component="brand-grid">
               <div>
-                <img src={previewLogoLight} alt="OpenCode brand guidelines" />
+                <img src={previewLogoLight} alt={alt} />
                 <div data-component="actions">
                   <button onClick={() => downloadFile(logoLightPng, "opencode-logo-light.png")}>
                     PNG
@@ -115,7 +116,7 @@ export default function Brand() {
                 </div>
               </div>
               <div>
-                <img src={previewLogoDark} alt="OpenCode brand guidelines" />
+                <img src={previewLogoDark} alt={alt} />
                 <div data-component="actions">
                   <button onClick={() => downloadFile(logoDarkPng, "opencode-logo-dark.png")}>
                     PNG
@@ -142,7 +143,7 @@ export default function Brand() {
                 </div>
               </div>
               <div>
-                <img src={previewLogoLightSquare} alt="OpenCode brand guidelines" />
+                <img src={previewLogoLightSquare} alt={alt} />
                 <div data-component="actions">
                   <button onClick={() => downloadFile(logoLightSquarePng, "opencode-logo-light-square.png")}>
                     PNG
@@ -169,7 +170,7 @@ export default function Brand() {
                 </div>
               </div>
               <div>
-                <img src={previewLogoDarkSquare} alt="OpenCode brand guidelines" />
+                <img src={previewLogoDarkSquare} alt={alt} />
                 <div data-component="actions">
                   <button onClick={() => downloadFile(logoDarkSquarePng, "opencode-logo-dark-square.png")}>
                     PNG
@@ -196,7 +197,7 @@ export default function Brand() {
                 </div>
               </div>
               <div>
-                <img src={previewWordmarkLight} alt="OpenCode brand guidelines" />
+                <img src={previewWordmarkLight} alt={alt} />
                 <div data-component="actions">
                   <button onClick={() => downloadFile(wordmarkLightPng, "opencode-wordmark-light.png")}>
                     PNG
@@ -223,7 +224,7 @@ export default function Brand() {
                 </div>
               </div>
               <div>
-                <img src={previewWordmarkDark} alt="OpenCode brand guidelines" />
+                <img src={previewWordmarkDark} alt={alt} />
                 <div data-component="actions">
                   <button onClick={() => downloadFile(wordmarkDarkPng, "opencode-wordmark-dark.png")}>
                     PNG
@@ -250,7 +251,7 @@ export default function Brand() {
                 </div>
               </div>
               <div>
-                <img src={previewWordmarkSimpleLight} alt="OpenCode brand guidelines" />
+                <img src={previewWordmarkSimpleLight} alt={alt} />
                 <div data-component="actions">
                   <button onClick={() => downloadFile(wordmarkSimpleLightPng, "opencode-wordmark-simple-light.png")}>
                     PNG
@@ -277,7 +278,7 @@ export default function Brand() {
                 </div>
               </div>
               <div>
-                <img src={previewWordmarkSimpleDark} alt="OpenCode brand guidelines" />
+                <img src={previewWordmarkSimpleDark} alt={alt} />
                 <div data-component="actions">
                   <button onClick={() => downloadFile(wordmarkSimpleDarkPng, "opencode-wordmark-simple-dark.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) {
   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(
     `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)
               .set({
                 reload: false,
-                reloadError: errorMessage ?? "Payment failed.",
+                reloadError: errorMessage ?? "workspace.reload.error.paymentFailed",
                 timeReloadError: sql`now()`,
               })
               .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">
         <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>
           <div data-slot="login">
             <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 { useI18n } from "~/context/i18n"
 import { useLanguage } from "~/context/language"
+import { formError } from "~/lib/form-error"
 
 const queryLiteSubscription = query(async (workspaceID: string) => {
   "use server"
@@ -114,7 +115,7 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) =
 const setLiteUseBalance = action(async (form: FormData) => {
   "use server"
   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"
 
   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",
                 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")}
             </p>
             <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 { LiteData } from "@opencode-ai/console-core/lite.js"
 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 RetryOptions = {
@@ -43,6 +45,15 @@ type RetryOptions = {
 }
 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(
   input: APIEvent,
   opts: {
@@ -60,6 +71,8 @@ export async function handler(
 
   const MAX_FAILOVER_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 = [
     "wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
     "wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
@@ -84,9 +97,9 @@ export async function handler(
     const zenData = ZenData.list(opts.modelList)
     const modelInfo = validateModel(zenData, model)
     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()
     const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
     const stickyProvider = await stickyTracker?.get()
@@ -101,7 +114,7 @@ export async function handler(
         authInfo,
         modelInfo,
         sessionId,
-        isTrial ?? false,
+        trialProvider,
         retry,
         stickyProvider,
       )
@@ -131,9 +144,6 @@ export async function handler(
           Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
             headers.set(k, headers.get(v)!)
           })
-          Object.entries(providerInfo.headers ?? {}).forEach(([k, v]) => {
-            headers.set(k, v)
-          })
           headers.delete("host")
           headers.delete("content-length")
           headers.delete("x-opencode-request")
@@ -282,18 +292,13 @@ export async function handler(
                 part = part.trim()
                 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)
                   c.enqueue(encoder.encode(part + "\n\n"))
                 }
               }
 
-              if (!providerInfo.responseModifier && providerInfo.format === opts.format) {
+              if (providerInfo.format === opts.format) {
                 c.enqueue(value)
               }
 
@@ -359,14 +364,20 @@ export async function handler(
   }
 
   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 modelData = Array.isArray(zenData.models[modelId])
       ? zenData.models[modelId].find((model) => opts.format === model.formatFilter)
       : 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 })
 
@@ -379,7 +390,7 @@ export async function handler(
     authInfo: AuthInfo,
     modelInfo: ModelInfo,
     sessionId: string,
-    isTrial: boolean,
+    trialProvider: string | undefined,
     retry: RetryOptions,
     stickyProvider: string | undefined,
   ) {
@@ -388,8 +399,8 @@ export async function handler(
         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) {
@@ -418,8 +429,9 @@ export async function handler(
       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 {
       ...modelProvider,
@@ -439,7 +451,7 @@ export async function handler(
     const apiKey = opts.parseApiKey(input.request.headers)
     if (!apiKey || apiKey === "public") {
       if (modelInfo.allowAnonymous) return
-      throw new AuthError("Missing API key.")
+      throw new AuthError(t("zen.api.error.missingApiKey"))
     }
 
     const data = await Database.use((tx) =>
@@ -520,13 +532,13 @@ export async function handler(
         .then((rows) => rows[0]),
     )
 
-    if (!data) throw new AuthError("Invalid API key.")
+    if (!data) throw new AuthError(t("zen.api.error.invalidApiKey"))
     if (
       modelInfo.id.startsWith("alpha-") &&
       Resource.App.stage === "production" &&
       !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({
       api_key: data.apiKey,
@@ -590,7 +602,9 @@ export async function handler(
           })
           if (result.status === "rate-limited")
             throw new SubscriptionUsageLimitError(
-              `Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
+              t("zen.api.error.subscriptionQuotaExceeded", {
+                retryIn: formatRetryTime(result.resetInSec),
+              }),
               result.resetInSec,
             )
         }
@@ -606,7 +620,9 @@ export async function handler(
           })
           if (result.status === "rate-limited")
             throw new SubscriptionUsageLimitError(
-              `Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
+              t("zen.api.error.subscriptionQuotaExceeded", {
+                retryIn: formatRetryTime(result.resetInSec),
+              }),
               result.resetInSec,
             )
         }
@@ -632,7 +648,7 @@ export async function handler(
           })
           if (result.status === "rate-limited")
             throw new SubscriptionUsageLimitError(
-              `Subscription quota exceeded. You can continue using free models.`,
+              t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
               result.resetInSec,
             )
         }
@@ -647,7 +663,7 @@ export async function handler(
           })
           if (result.status === "rate-limited")
             throw new SubscriptionUsageLimitError(
-              `Subscription quota exceeded. You can continue using free models.`,
+              t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
               result.resetInSec,
             )
         }
@@ -662,7 +678,7 @@ export async function handler(
           })
           if (result.status === "rate-limited")
             throw new SubscriptionUsageLimitError(
-              `Subscription quota exceeded. You can continue using free models.`,
+              t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
               result.resetInSec,
             )
         }
@@ -675,14 +691,10 @@ export async function handler(
 
     // Validate pay as you go 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 currentYear = now.getUTCFullYear()
@@ -696,7 +708,10 @@ export async function handler(
       currentMonth === billing.timeMonthlyUsageUpdated.getUTCMonth()
     )
       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 (
@@ -708,7 +723,10 @@ export async function handler(
       currentMonth === authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
     )
       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"
@@ -716,7 +734,7 @@ export async function handler(
 
   function validateModelSettings(authInfo: AuthInfo) {
     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) {

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

@@ -43,7 +43,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
       ...(isBedrock
         ? {
             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,
             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 { FreeUsageLimitError } from "./error"
 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 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 {
     track: async () => {
       await Database.use((tx) =>
         tx
           .insert(IpRateLimitTable)
-          .values({ ip, interval: intervals[0], count: 1 })
+          .values({ ip, interval, count: 1 })
           .onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
       )
     },
@@ -30,15 +32,12 @@ export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: s
         tx
           .select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
           .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)
       logger.debug(`rate limit total: ${total}`)
       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)
 }
 
-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) {
   return new Date(timestamp)
     .toISOString()
     .replace(/[^0-9]/g, "")
     .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 { IpTable } from "@opencode-ai/console-core/schema/ip.sql.js"
 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
 
-  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
 
   return {
-    isTrial: async () => {
+    check: async () => {
       const data = await Database.use((tx) =>
         tx
           .select({
@@ -27,7 +24,7 @@ export function createTrialLimiter(trial: ZenData.Trial | undefined, ip: string,
       )
 
       _isTrial = (data?.usage ?? 0) < limit
-      return _isTrial
+      return _isTrial ? trialProvider : undefined
     },
     track: async (usageInfo: UsageInfo) => {
       if (!_isTrial) return

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

@@ -1,5 +1,5 @@
 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", () => {
   test("returns full day at midnight UTC", () => {
@@ -17,76 +17,3 @@ describe("getRetryAfterDay", () => {
     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",
   "name": "@opencode-ai/console-core",
-  "version": "1.2.15",
+  "version": "1.2.16",
   "private": true,
   "type": "module",
   "license": "MIT",
@@ -34,12 +34,9 @@
     "promote-models-to-prod": "script/promote-models.ts production",
     "pull-models-from-dev": "script/pull-models.ts dev",
     "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"
   },
   "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 path from "path"
-import { LiteData } from "../src/lite"
+import { Subscription } from "../src/subscription"
 
 const stage = process.argv[2]
 if (!stage) throw new Error("Stage is required")
@@ -12,11 +12,11 @@ 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_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
-LiteData.validate(JSON.parse(value))
+Subscription.validate(JSON.parse(value))
 
 // 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 path from "path"
 import os from "os"
-import { LiteData } from "../src/lite"
+import { Subscription } from "../src/subscription"
 
 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_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
-const filename = `lite-${Date.now()}.json`
+const filename = `limits-${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)
@@ -22,7 +22,7 @@ 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()))
-LiteData.validate(JSON.parse(newValue))
+Subscription.validate(JSON.parse(newValue))
 
 // 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 { Resource } from "@opencode-ai/console-resource"
 import { BlackPlans } from "./schema/billing.sql"
+import { Subscription } from "./subscription"
 
 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(
     z.object({
       plan: z.enum(BlackPlans),
     }),
     ({ 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 { fn } from "./util/fn"
 import { Resource } from "@opencode-ai/console-resource"
+import { Subscription } from "./subscription"
 
 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(), () => {
-    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)

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

@@ -9,24 +9,7 @@ import { Resource } from "@opencode-ai/console-resource"
 
 export namespace ZenData {
   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 Trial = z.infer<typeof TrialSchema>
-  export type RateLimit = z.infer<typeof RateLimitSchema>
 
   const ModelCostSchema = z.object({
     input: z.number(),
@@ -43,8 +26,7 @@ export namespace ZenData {
     allowAnonymous: z.boolean().optional(),
     byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
     stickyProvider: z.enum(["strict", "prefer"]).optional(),
-    trial: TrialSchema.optional(),
-    rateLimit: RateLimitSchema.optional(),
+    trialProvider: z.string().optional(),
     fallbackProvider: z.string().optional(),
     providers: z.array(
       z.object({
@@ -63,19 +45,12 @@ export namespace ZenData {
     format: FormatSchema.optional(),
     headerMappings: z.record(z.string(), z.string()).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({
     models: z.record(z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))])),
     liteModels: z.record(z.string(), ModelSchema),
     providers: z.record(z.string(), ProviderSchema),
-    providerFamilies: z.record(z.string(), ProviderFamilySchema),
   })
 
   export const validate = fn(ModelsSchema, (input) => {
@@ -115,15 +90,10 @@ export namespace ZenData {
         Resource.ZEN_MODELS29.value +
         Resource.ZEN_MODELS30.value,
     )
-    const { models, liteModels, providers, providerFamilies } = ModelsSchema.parse(json)
+    const { models, liteModels, providers } = ModelsSchema.parse(json)
     return {
       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 { centsToMicroCents } from "./util/price"
 import { getWeekBounds, getMonthlyBounds } from "./util/date"
+import { Resource } from "@opencode-ai/console-resource"
 
 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(
     z.object({
       limit: z.number().int(),

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

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

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

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

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

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

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

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/console-mail",
-  "version": "1.2.15",
+  "version": "1.2.16",
   "dependencies": {
     "@jsx-email/all": "2.2.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"
       "url": string
     }
-    "ZEN_BLACK_LIMITS": {
-      "type": "sst.sst.Secret"
-      "value": string
-    }
     "ZEN_BLACK_PRICE": {
       "plan100": string
       "plan20": string
@@ -130,7 +126,7 @@ declare module "sst" {
       "product": string
       "type": "sst.sst.Linkable"
     }
-    "ZEN_LITE_LIMITS": {
+    "ZEN_LIMITS": {
       "type": "sst.sst.Secret"
       "value": string
     }

Некоторые файлы не были показаны из-за большого количества измененных файлов