Ver código fonte

Merge origin/dev into opencode-2-0

Dax Raad 4 meses atrás
pai
commit
63af295a17
100 arquivos alterados com 4934 adições e 2285 exclusões
  1. 1 0
      .github/VOUCHED.td
  2. 22 9
      .github/actions/setup-bun/action.yml
  3. 7 0
      .github/workflows/publish.yml
  4. 3 1
      .github/workflows/test.yml
  5. 1 1
      .gitignore
  6. 1 1
      .opencode/agent/translator.md
  7. 36 29
      bun.lock
  8. 14 0
      infra/console.ts
  9. 4 4
      nix/hashes.json
  10. 5 3
      package.json
  11. 38 0
      packages/app/e2e/AGENTS.md
  12. 171 4
      packages/app/e2e/actions.ts
  13. 3 0
      packages/app/e2e/app/home.spec.ts
  14. 15 0
      packages/app/e2e/fixtures.ts
  15. 11 5
      packages/app/e2e/projects/projects-switch.spec.ts
  16. 34 26
      packages/app/e2e/projects/workspace-new-session.spec.ts
  17. 16 14
      packages/app/e2e/projects/workspaces.spec.ts
  18. 12 6
      packages/app/e2e/prompt/prompt-multiline.spec.ts
  19. 4 7
      packages/app/e2e/prompt/prompt-slash-terminal.spec.ts
  20. 3 0
      packages/app/e2e/selectors.ts
  21. 176 71
      packages/app/e2e/session/session-composer-dock.spec.ts
  22. 371 0
      packages/app/e2e/session/session-model-persistence.spec.ts
  23. 132 6
      packages/app/e2e/session/session-review.spec.ts
  24. 2 2
      packages/app/e2e/settings/settings-keybinds.spec.ts
  25. 3 3
      packages/app/e2e/terminal/terminal-init.spec.ts
  26. 46 0
      packages/app/e2e/terminal/terminal-reconnect.spec.ts
  27. 7 14
      packages/app/e2e/terminal/terminal-tabs.spec.ts
  28. 2 1
      packages/app/e2e/terminal/terminal.spec.ts
  29. 2 1
      packages/app/e2e/tsconfig.json
  30. 3 2
      packages/app/package.json
  31. 2 0
      packages/app/playwright.config.ts
  32. 1 0
      packages/app/script/e2e-local.ts
  33. 165 35
      packages/app/src/app.tsx
  34. 38 32
      packages/app/src/components/debug-bar.tsx
  35. 134 16
      packages/app/src/components/dialog-connect-provider.tsx
  36. 159 0
      packages/app/src/components/dialog-custom-provider-form.ts
  37. 82 0
      packages/app/src/components/dialog-custom-provider.test.ts
  38. 79 189
      packages/app/src/components/dialog-custom-provider.tsx
  39. 3 4
      packages/app/src/components/dialog-fork.tsx
  40. 12 8
      packages/app/src/components/dialog-select-file.tsx
  41. 7 5
      packages/app/src/components/dialog-select-model-unpaid.tsx
  42. 12 7
      packages/app/src/components/dialog-select-model.tsx
  43. 40 34
      packages/app/src/components/dialog-select-server.tsx
  44. 307 247
      packages/app/src/components/prompt-input.tsx
  45. 44 0
      packages/app/src/components/prompt-input/attachments.test.ts
  46. 74 57
      packages/app/src/components/prompt-input/attachments.ts
  47. 119 0
      packages/app/src/components/prompt-input/files.ts
  48. 24 0
      packages/app/src/components/prompt-input/paste.ts
  49. 75 3
      packages/app/src/components/prompt-input/submit.test.ts
  50. 203 52
      packages/app/src/components/prompt-input/submit.ts
  51. 12 6
      packages/app/src/components/server/server-row.tsx
  52. 11 6
      packages/app/src/components/session-context-usage.tsx
  53. 2 6
      packages/app/src/components/session/session-context-tab.tsx
  54. 58 302
      packages/app/src/components/session/session-header.tsx
  55. 0 1
      packages/app/src/components/session/session-new-view.tsx
  56. 5 20
      packages/app/src/components/session/session-sortable-terminal-tab.tsx
  57. 0 16
      packages/app/src/components/settings-agents.tsx
  58. 0 16
      packages/app/src/components/settings-commands.tsx
  59. 89 66
      packages/app/src/components/settings-general.tsx
  60. 4 3
      packages/app/src/components/settings-keybinds.tsx
  61. 5 0
      packages/app/src/components/settings-list.tsx
  62. 0 16
      packages/app/src/components/settings-mcp.tsx
  63. 3 2
      packages/app/src/components/settings-models.tsx
  64. 0 230
      packages/app/src/components/settings-permissions.tsx
  65. 5 4
      packages/app/src/components/settings-providers.tsx
  66. 26 18
      packages/app/src/components/status-popover.tsx
  67. 148 70
      packages/app/src/components/terminal.tsx
  68. 73 52
      packages/app/src/components/titlebar.tsx
  69. 69 24
      packages/app/src/context/command.tsx
  70. 3 3
      packages/app/src/context/file.tsx
  71. 4 2
      packages/app/src/context/global-sdk.tsx
  72. 2 6
      packages/app/src/context/global-sync.test.ts
  73. 3 3
      packages/app/src/context/global-sync.tsx
  74. 1 1
      packages/app/src/context/global-sync/bootstrap.ts
  75. 1 0
      packages/app/src/context/global-sync/child-store.test.ts
  76. 5 4
      packages/app/src/context/global-sync/child-store.ts
  77. 96 0
      packages/app/src/context/global-sync/session-prefetch.test.ts
  78. 100 0
      packages/app/src/context/global-sync/session-prefetch.ts
  79. 8 7
      packages/app/src/context/highlights.tsx
  80. 51 4
      packages/app/src/context/layout.tsx
  81. 361 191
      packages/app/src/context/local.tsx
  82. 20 0
      packages/app/src/context/model-variant.test.ts
  83. 3 1
      packages/app/src/context/model-variant.ts
  84. 0 66
      packages/app/src/context/notification-index.ts
  85. 0 73
      packages/app/src/context/notification.test.ts
  86. 3 2
      packages/app/src/context/platform.tsx
  87. 19 9
      packages/app/src/context/prompt.tsx
  88. 3 5
      packages/app/src/context/server.tsx
  89. 6 0
      packages/app/src/context/settings.tsx
  90. 69 2
      packages/app/src/context/sync-optimistic.test.ts
  91. 224 48
      packages/app/src/context/sync.tsx
  92. 51 0
      packages/app/src/context/terminal-title.ts
  93. 77 56
      packages/app/src/context/terminal.tsx
  94. 24 14
      packages/app/src/entry.tsx
  95. 18 16
      packages/app/src/hooks/use-providers.ts
  96. 106 3
      packages/app/src/i18n/ar.ts
  97. 110 3
      packages/app/src/i18n/br.ts
  98. 109 3
      packages/app/src/i18n/bs.ts
  99. 109 3
      packages/app/src/i18n/da.ts
  100. 113 3
      packages/app/src/i18n/de.ts

+ 1 - 0
.github/VOUCHED.td

@@ -21,3 +21,4 @@ r44vc0rp
 rekram1-node
 -spider-yamet clawdbot/llm psychosis, spam pinging the team
 thdxr
+-OpenCode2026

+ 22 - 9
.github/actions/setup-bun/action.yml

@@ -3,14 +3,6 @@ description: "Setup Bun with caching and install dependencies"
 runs:
   using: "composite"
   steps:
-    - name: Cache Bun dependencies
-      uses: actions/cache@v4
-      with:
-        path: ~/.bun/install/cache
-        key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
-        restore-keys: |
-          ${{ runner.os }}-bun-
-
     - name: Get baseline download URL
       id: bun-url
       shell: bash
@@ -31,10 +23,31 @@ runs:
         bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
         bun-download-url: ${{ steps.bun-url.outputs.url }}
 
+    - name: Get cache directory
+      id: cache
+      shell: bash
+      run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
+
+    - name: Cache Bun dependencies
+      uses: actions/cache@v4
+      with:
+        path: ${{ steps.cache.outputs.dir }}
+        key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
+        restore-keys: |
+          ${{ runner.os }}-bun-
+
     - name: Install setuptools for distutils compatibility
       run: python3 -m pip install setuptools || pip install setuptools || true
       shell: bash
 
     - name: Install dependencies
-      run: bun install
+      run: |
+        # Workaround for patched peer variants
+        # e.g. ./patches/ for standard-openapi
+        # https://github.com/oven-sh/bun/issues/28147
+        if [ "$RUNNER_OS" = "Windows" ]; then
+          bun install --linker hoisted
+        else
+          bun install
+        fi
       shell: bash

+ 7 - 0
.github/workflows/publish.yml

@@ -115,6 +115,9 @@ jobs:
             target: x86_64-apple-darwin
           - host: macos-latest
             target: aarch64-apple-darwin
+          # github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
+          - host: windows-2025
+            target: aarch64-pc-windows-msvc
           - host: blacksmith-4vcpu-windows-2025
             target: x86_64-pc-windows-msvc
           - host: blacksmith-4vcpu-ubuntu-2404
@@ -258,6 +261,10 @@ jobs:
           - host: macos-latest
             target: aarch64-apple-darwin
             platform_flag: --mac --arm64
+          # github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
+          - host: "windows-2025"
+            target: aarch64-pc-windows-msvc
+            platform_flag: --win --arm64
           - host: "blacksmith-4vcpu-windows-2025"
             target: x86_64-pc-windows-msvc
             platform_flag: --win

+ 3 - 1
.github/workflows/test.yml

@@ -8,7 +8,9 @@ on:
   workflow_dispatch:
 
 concurrency:
-  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+  # Keep every run on dev so cancelled checks do not pollute the default branch
+  # commit history. PRs and other branches still share a group and cancel stale runs.
+  group: ${{ case(github.ref == 'refs/heads/dev', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }}
   cancel-in-progress: true
 
 permissions:

+ 1 - 1
.gitignore

@@ -17,7 +17,7 @@ ts-dist
 /result
 refs
 Session.vim
-opencode.json
+/opencode.json
 a.out
 target
 .scripts

+ 1 - 1
.opencode/agent/translator.md

@@ -1,7 +1,7 @@
 ---
 description: Translate content for a specified locale while preserving technical terms
 mode: subagent
-model: opencode/gemini-3-pro
+model: opencode/gemini-3.1-pro
 ---
 
 You are a professional translator and localization specialist.

Diferenças do arquivo suprimidas por serem muito extensas
+ 36 - 29
bun.lock


+ 14 - 0
infra/console.ts

@@ -103,6 +103,12 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
 const zenLiteProduct = new stripe.Product("ZenLite", {
   name: "OpenCode Go",
 })
+const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50", {
+  name: "First month 50% off",
+  percentOff: 50,
+  appliesToProducts: [zenLiteProduct.id],
+  duration: "once",
+})
 const zenLitePrice = new stripe.Price("ZenLitePrice", {
   product: zenLiteProduct.id,
   currency: "usd",
@@ -116,6 +122,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
   properties: {
     product: zenLiteProduct.id,
     price: zenLitePrice.id,
+    firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
   },
 })
 
@@ -194,6 +201,10 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
 const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
 const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
 
+const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
+const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
+const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
+
 const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
   handler: "packages/console/function/src/log-processor.ts",
   link: [new sst.Secret("HONEYCOMB_API_KEY")],
@@ -212,6 +223,9 @@ new sst.cloudflare.x.SolidStart("Console", {
     EMAILOCTOPUS_API_KEY,
     AWS_SES_ACCESS_KEY_ID,
     AWS_SES_SECRET_ACCESS_KEY,
+    SALESFORCE_CLIENT_ID,
+    SALESFORCE_CLIENT_SECRET,
+    SALESFORCE_INSTANCE_URL,
     ZEN_BLACK_PRICE,
     ZEN_LITE_PRICE,
     new sst.Secret("ZEN_LIMITS"),

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-dhL4YeSi4Lm9yDp919Fx7N2hyLUbZQa2qWoCf/50ce8=",
-    "aarch64-linux": "sha256-//YxCsrvYlxuvd0MtFFO+pLxjmuemyrvGzSIPxzO+rA=",
-    "aarch64-darwin": "sha256-c65kSWteQNaBcQUsjbXNqT61vt98JPNYo9yMNvUygCw=",
-    "x86_64-darwin": "sha256-hlTzEFv3nZHwlDXU65LfMC+NaqYjjyZqagdJ366CNxY="
+    "x86_64-linux": "sha256-yfA50QKqylmaioxi+6d++W8Xv4Wix1hl3hEF6Zz7Ue0=",
+    "aarch64-linux": "sha256-b5sO7V+/zzJClHHKjkSz+9AUBYC8cb7S3m5ab1kpAyk=",
+    "aarch64-darwin": "sha256-V66nmRX6kAjrc41ARVeuTElWK7KD8qG/DVk9K7Fu+J8=",
+    "x86_64-darwin": "sha256-cFyh60WESiqZ5XWZi1+g3F/beSDL1+UPG8KhRivhK8w="
   }
 }

+ 5 - 3
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:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
     "dev:storybook": "bun --cwd packages/storybook storybook",
     "typecheck": "bun turbo typecheck",
     "prepare": "husky",
@@ -24,6 +25,7 @@
       "packages/slack"
     ],
     "catalog": {
+      "@effect/platform-node": "4.0.0-beta.35",
       "@types/bun": "1.3.9",
       "@octokit/rest": "22.0.0",
       "@hono/zod-validator": "0.4.2",
@@ -41,9 +43,9 @@
       "@tailwindcss/vite": "4.1.11",
       "diff": "8.0.2",
       "dompurify": "3.3.1",
-      "effect": "4.0.0-beta.29",
-      "drizzle-kit": "1.0.0-beta.16-c2458b2",
-      "drizzle-orm": "1.0.0-beta.16-c2458b2",
+      "drizzle-kit": "1.0.0-beta.16-ea816b6",
+      "drizzle-orm": "1.0.0-beta.16-ea816b6",
+      "effect": "4.0.0-beta.35",
       "ai": "5.0.124",
       "hono": "4.10.7",
       "hono-openapi": "1.1.2",

+ 38 - 0
packages/app/e2e/AGENTS.md

@@ -70,6 +70,8 @@ test("test description", async ({ page, sdk, gotoSession }) => {
 - `openSettings(page)` - Open settings dialog
 - `closeDialog(page, dialog)` - Close any dialog
 - `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
+- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
+- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
 - `withSession(sdk, title, callback)` - Create temp session
 - `withProject(...)` - Create temp project/workspace
 - `sessionIDFromUrl(url)` - Read session ID from URL
@@ -167,6 +169,42 @@ await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
 await page.keyboard.press(`${modKey}+Comma`) // Open settings
 ```
 
+### Terminal Tests
+
+- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
+- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
+- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
+- After opening the terminal, use `waitTerminalFocusIdle(...)` before the next keyboard action when prompt focus or keyboard routing matters.
+- This avoids racing terminal mount, focus handoff, and prompt readiness when the next step types or sends shortcuts.
+- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
+
+### Wait on state
+
+- Never use wall-clock waits like `page.waitForTimeout(...)` to make a test pass
+- Avoid race-prone flows that assume work is finished after an action
+- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
+- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
+- Prefer semantic app state over transient DOM visibility when behavior depends on active selection, focus ownership, or async retry loops
+- Do not treat a visible element as proof that the app will route the next action to it
+- When fixing a flake, validate with `--repeat-each` and multiple workers when practical
+
+### Add hooks
+
+- If required state is not observable from the UI, add a small test-only driver or probe in app code instead of sleeps or fragile DOM checks
+- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
+- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
+- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
+- Add minimal test-only probes for semantic state like the active list item or selected command when DOM intermediates are unstable
+- Prefer probing committed app state over asserting on transient highlight, visibility, or animation states
+
+### Prefer helpers
+
+- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
+- Use direct locators when the interaction is simple and a helper would not add clarity
+- Prefer helpers that both perform an action and verify the app consumed it
+- Avoid composing helpers redundantly when one already includes the other or already waits for the resulting state
+- If a helper already covers the required wait or verification, use it directly instead of layering extra clicks, keypresses, or assertions
+
 ## Writing New Tests
 
 1. Choose appropriate folder or create new one

+ 171 - 4
packages/app/e2e/actions.ts

@@ -1,8 +1,10 @@
+import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
 import { expect, type Locator, type Page } from "@playwright/test"
 import fs from "node:fs/promises"
 import os from "node:os"
 import path from "node:path"
 import { execSync } from "node:child_process"
+import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
 import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
 import {
   dropdownMenuTriggerSelector,
@@ -15,6 +17,8 @@ import {
   listItemSelector,
   listItemKeySelector,
   listItemKeyStartsWithSelector,
+  promptSelector,
+  terminalSelector,
   workspaceItemSelector,
   workspaceMenuTriggerSelector,
 } from "./selectors"
@@ -28,6 +32,138 @@ export async function defocus(page: Page) {
     .catch(() => undefined)
 }
 
+async function terminalID(term: Locator) {
+  const id = await term.getAttribute(terminalAttr)
+  if (id) return id
+  throw new Error(`Active terminal missing ${terminalAttr}`)
+}
+
+export async function terminalConnects(page: Page, input?: { term?: Locator }) {
+  const term = input?.term ?? page.locator(terminalSelector).first()
+  const id = await terminalID(term)
+  return page.evaluate((id) => {
+    return (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]?.connects ?? 0
+  }, id)
+}
+
+export async function disconnectTerminal(page: Page, input?: { term?: Locator }) {
+  const term = input?.term ?? page.locator(terminalSelector).first()
+  const id = await terminalID(term)
+  await page.evaluate((id) => {
+    ;(window as E2EWindow).__opencode_e2e?.terminal?.controls?.[id]?.disconnect?.()
+  }, id)
+}
+
+async function terminalReady(page: Page, term?: Locator) {
+  const next = term ?? page.locator(terminalSelector).first()
+  const id = await terminalID(next)
+  return page.evaluate((id) => {
+    const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
+    return !!state?.connected && (state.settled ?? 0) > 0
+  }, id)
+}
+
+async function terminalFocusIdle(page: Page, term?: Locator) {
+  const next = term ?? page.locator(terminalSelector).first()
+  const id = await terminalID(next)
+  return page.evaluate((id) => {
+    const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
+    return (state?.focusing ?? 0) === 0
+  }, id)
+}
+
+async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
+  const next = input.term ?? page.locator(terminalSelector).first()
+  const id = await terminalID(next)
+  return page.evaluate(
+    (input) => {
+      const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id]
+      return state?.rendered.includes(input.token) ?? false
+    },
+    { id, token: input.token },
+  )
+}
+
+async function promptSlashActive(page: Page, id: string) {
+  return page.evaluate((id) => {
+    const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
+    if (state?.popover !== "slash") return false
+    if (!state.slash.ids.includes(id)) return false
+    return state.slash.active === id
+  }, id)
+}
+
+async function promptSlashSelects(page: Page) {
+  return page.evaluate(() => {
+    return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0
+  })
+}
+
+async function promptSlashSelected(page: Page, input: { id: string; count: number }) {
+  return page.evaluate((input) => {
+    const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
+    if (!state) return false
+    return state.selected === input.id && state.selects >= input.count
+  }, input)
+}
+
+export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
+  const term = input?.term ?? page.locator(terminalSelector).first()
+  const timeout = input?.timeout ?? 10_000
+  await expect(term).toBeVisible()
+  await expect(term.locator("textarea")).toHaveCount(1)
+  await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
+}
+
+export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) {
+  const term = input?.term ?? page.locator(terminalSelector).first()
+  const timeout = input?.timeout ?? 10_000
+  await waitTerminalReady(page, { term, timeout })
+  await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true)
+}
+
+export async function showPromptSlash(
+  page: Page,
+  input: { id: string; text: string; prompt?: Locator; timeout?: number },
+) {
+  const prompt = input.prompt ?? page.locator(promptSelector)
+  const timeout = input.timeout ?? 10_000
+  await expect
+    .poll(
+      async () => {
+        await prompt.click().catch(() => false)
+        await prompt.fill(input.text).catch(() => false)
+        return promptSlashActive(page, input.id).catch(() => false)
+      },
+      { timeout },
+    )
+    .toBe(true)
+}
+
+export async function runPromptSlash(
+  page: Page,
+  input: { id: string; text: string; prompt?: Locator; timeout?: number },
+) {
+  const prompt = input.prompt ?? page.locator(promptSelector)
+  const timeout = input.timeout ?? 10_000
+  const count = await promptSlashSelects(page)
+  await showPromptSlash(page, input)
+  await prompt.press("Enter")
+  await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true)
+}
+
+export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
+  const term = input.term ?? page.locator(terminalSelector).first()
+  const timeout = input.timeout ?? 10_000
+  await waitTerminalReady(page, { term, timeout })
+  const textarea = term.locator("textarea")
+  await term.click()
+  await expect(textarea).toBeFocused()
+  await page.keyboard.type(input.cmd)
+  await page.keyboard.press("Enter")
+  await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
+}
+
 export async function openPalette(page: Page) {
   await defocus(page)
   await page.keyboard.press(`${modKey}+P`)
@@ -226,6 +362,30 @@ export async function waitSlug(page: Page, skip: string[] = []) {
   return next
 }
 
+export async function resolveSlug(slug: string) {
+  const directory = base64Decode(slug)
+  if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
+  const resolved = await resolveDirectory(directory)
+  return { directory: resolved, slug: base64Encode(resolved), raw: slug }
+}
+
+export async function waitDir(page: Page, directory: string) {
+  const target = await resolveDirectory(directory)
+  await expect
+    .poll(
+      async () => {
+        const slug = slugFromUrl(page.url())
+        if (!slug) return ""
+        return resolveSlug(slug)
+          .then((item) => item.directory)
+          .catch(() => "")
+      },
+      { timeout: 45_000 },
+    )
+    .toBe(target)
+  return { directory: target, slug: base64Encode(target) }
+}
+
 export function sessionIDFromUrl(url: string) {
   const match = /\/session\/([^/?#]+)/.exec(url)
   return match?.[1]
@@ -539,12 +699,19 @@ export async function seedSessionTask(
         .flatMap((message) => message.parts)
         .find((part) => {
           if (part.type !== "tool" || part.tool !== "task") return false
-          if (part.state.input?.description !== input.description) return false
-          return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0
+          if (!("state" in part) || !part.state || typeof part.state !== "object") return false
+          if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false
+          if (!("description" in part.state.input) || part.state.input.description !== input.description) return false
+          if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object")
+            return false
+          if (!("sessionId" in part.state.metadata)) return false
+          return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0
         })
 
-      if (!part) return
-      const id = part.state.metadata?.sessionId
+      if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return
+      if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
+      if (!("sessionId" in part.state.metadata)) return
+      const id = part.state.metadata.sessionId
       if (typeof id !== "string" || !id) return
       const child = await sdk.session
         .get({ sessionID: id })

+ 3 - 0
packages/app/e2e/app/home.spec.ts

@@ -3,8 +3,11 @@ import { serverNamePattern } from "../utils"
 
 test("home renders and shows core entrypoints", async ({ page }) => {
   await page.goto("/")
+  const nav = page.locator('[data-component="sidebar-nav-desktop"]')
 
   await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
+  await expect(nav.getByText("No projects open")).toBeVisible()
+  await expect(nav.getByText("Open a project to get started")).toBeVisible()
   await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
 })
 

+ 15 - 0
packages/app/e2e/fixtures.ts

@@ -1,4 +1,5 @@
 import { test as base, expect, type Page } from "@playwright/test"
+import type { E2EWindow } from "../src/testing/terminal"
 import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
 import { promptSelector } from "./selectors"
 import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
@@ -91,6 +92,20 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
 async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
   await seedProjects(page, input)
   await page.addInitScript(() => {
+    const win = window as E2EWindow
+    win.__opencode_e2e = {
+      ...win.__opencode_e2e,
+      model: {
+        enabled: true,
+      },
+      prompt: {
+        enabled: true,
+      },
+      terminal: {
+        enabled: true,
+        terminals: {},
+      },
+    }
     localStorage.setItem(
       "opencode.global.dat:model",
       JSON.stringify({

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

@@ -1,7 +1,15 @@
 import { base64Decode } from "@opencode-ai/util/encode"
 import type { Page } from "@playwright/test"
 import { test, expect } from "../fixtures"
-import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
+import {
+  defocus,
+  createTestProject,
+  cleanupTestProject,
+  openSidebar,
+  sessionIDFromUrl,
+  waitDir,
+  waitSlug,
+} from "../actions"
 import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
 import { dirSlug, resolveDirectory } from "../utils"
 
@@ -100,11 +108,8 @@ test("switching back to a project opens the latest workspace session", async ({
         await expect(btn).toBeVisible()
         await btn.click({ force: true })
 
-        // A new workspace can be discovered via a transient slug before the route and sidebar
-        // settle to the canonical workspace path on Windows, so interact with either and assert
-        // against the resolved workspace slug.
         await waitSlug(page)
-        await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
+        await waitDir(page, space)
 
         // Create a session by sending a prompt
         const prompt = page.locator(promptSelector)
@@ -132,6 +137,7 @@ test("switching back to a project opens the latest workspace session", async ({
         await expect(rootButton).toBeVisible()
         await rootButton.click()
 
+        await waitDir(page, space)
         await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
         await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
       },

+ 34 - 26
packages/app/e2e/projects/workspace-new-session.spec.ts

@@ -1,18 +1,25 @@
-import { base64Decode } from "@opencode-ai/util/encode"
 import type { Page } from "@playwright/test"
 import { test, expect } from "../fixtures"
-import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
+import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions"
 import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
 import { createSdk } from "../utils"
 
-async function waitWorkspaceReady(page: Page, slug: string) {
+function item(space: { slug: string; raw: string }) {
+  return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
+}
+
+function button(space: { slug: string; raw: string }) {
+  return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}`
+}
+
+async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
   await openSidebar(page)
   await expect
     .poll(
       async () => {
-        const item = page.locator(workspaceItemSelector(slug)).first()
+        const row = page.locator(item(space)).first()
         try {
-          await item.hover({ timeout: 500 })
+          await row.hover({ timeout: 500 })
           return true
         } catch {
           return false
@@ -27,29 +34,30 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
   await openSidebar(page)
   await page.getByRole("button", { name: "New workspace" }).first().click()
 
-  const slug = await waitSlug(page, [root, ...seen])
-  const directory = base64Decode(slug)
-  if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
-  return { slug, directory }
+  const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
+  await waitDir(page, next.directory)
+  return next
 }
 
-async function openWorkspaceNewSession(page: Page, slug: string) {
-  await waitWorkspaceReady(page, slug)
+async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) {
+  await waitWorkspaceReady(page, space)
 
-  const item = page.locator(workspaceItemSelector(slug)).first()
-  await item.hover()
+  const row = page.locator(item(space)).first()
+  await row.hover()
 
-  const button = page.locator(workspaceNewSessionSelector(slug)).first()
-  await expect(button).toBeVisible()
-  await button.click({ force: true })
+  const next = page.locator(button(space)).first()
+  await expect(next).toBeVisible()
+  await next.click({ force: true })
 
-  const next = await waitSlug(page)
-  await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
-  return next
+  return waitDir(page, space.directory)
 }
 
-async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
-  const next = await openWorkspaceNewSession(page, slug)
+async function createSessionFromWorkspace(
+  page: Page,
+  space: { slug: string; raw: string; directory: string },
+  text: string,
+) {
+  const next = await openWorkspaceNewSession(page, space)
 
   const prompt = page.locator(promptSelector)
   await expect(prompt).toBeVisible()
@@ -60,13 +68,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
   await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
   await prompt.press("Enter")
 
-  await expect.poll(() => slugFromUrl(page.url())).toBe(next)
+  await waitDir(page, next.directory)
   await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
 
   const sessionID = sessionIDFromUrl(page.url())
   if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
-  await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
-  return { sessionID, slug: next }
+  await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
+  return { sessionID, slug: next.slug }
 }
 
 async function sessionDirectory(directory: string, sessionID: string) {
@@ -87,11 +95,11 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
 
     const first = await createWorkspace(page, root, [])
     trackDirectory(first.directory)
-    await waitWorkspaceReady(page, first.slug)
+    await waitWorkspaceReady(page, first)
 
     const second = await createWorkspace(page, root, [first.slug])
     trackDirectory(second.directory)
-    await waitWorkspaceReady(page, second.slug)
+    await waitWorkspaceReady(page, second)
 
     const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
     trackSession(firstSession.sessionID, first.directory)

+ 16 - 14
packages/app/e2e/projects/workspaces.spec.ts

@@ -1,7 +1,7 @@
-import { base64Decode } from "@opencode-ai/util/encode"
 import fs from "node:fs/promises"
 import os from "node:os"
 import path from "node:path"
+import { base64Decode } from "@opencode-ai/util/encode"
 import type { Page } from "@playwright/test"
 
 import { test, expect } from "../fixtures"
@@ -13,8 +13,10 @@ import {
   confirmDialog,
   openSidebar,
   openWorkspaceMenu,
+  resolveSlug,
   setWorkspacesEnabled,
   slugFromUrl,
+  waitDir,
   waitSlug,
 } from "../actions"
 import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
@@ -27,15 +29,15 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
   await setWorkspacesEnabled(page, rootSlug, true)
 
   await page.getByRole("button", { name: "New workspace" }).first().click()
-  const slug = await waitSlug(page, [rootSlug])
-  const dir = base64Decode(slug)
+  const next = await resolveSlug(await waitSlug(page, [rootSlug]))
+  await waitDir(page, next.directory)
 
   await openSidebar(page)
 
   await expect
     .poll(
       async () => {
-        const item = page.locator(workspaceItemSelector(slug)).first()
+        const item = page.locator(workspaceItemSelector(next.slug)).first()
         try {
           await item.hover({ timeout: 500 })
           return true
@@ -47,7 +49,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
     )
     .toBe(true)
 
-  return { rootSlug, slug, directory: dir }
+  return { rootSlug, slug: next.slug, directory: next.directory }
 }
 
 test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
@@ -79,15 +81,15 @@ test("can create a workspace", async ({ page, withProject }) => {
     await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
 
     await page.getByRole("button", { name: "New workspace" }).first().click()
-    const workspaceSlug = await waitSlug(page, [slug])
-    const workspaceDir = base64Decode(workspaceSlug)
+    const next = await resolveSlug(await waitSlug(page, [slug]))
+    await waitDir(page, next.directory)
 
     await openSidebar(page)
 
     await expect
       .poll(
         async () => {
-          const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
+          const item = page.locator(workspaceItemSelector(next.slug)).first()
           try {
             await item.hover({ timeout: 500 })
             return true
@@ -99,9 +101,9 @@ test("can create a workspace", async ({ page, withProject }) => {
       )
       .toBe(true)
 
-    await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
+    await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
 
-    await cleanupTestProject(workspaceDir)
+    await cleanupTestProject(next.directory)
   })
 })
 
@@ -119,7 +121,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
 
       await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
 
-      const activeDir = base64Decode(slugFromUrl(page.url()))
+      const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
       expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
 
       await openSidebar(page)
@@ -331,9 +333,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
       for (const _ of [0, 1]) {
         const prev = slugFromUrl(page.url())
         await page.getByRole("button", { name: "New workspace" }).first().click()
-        const slug = await waitSlug(page, [rootSlug, prev])
-        const dir = base64Decode(slug)
-        workspaces.push({ slug, directory: dir })
+        const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
+        await waitDir(page, next.directory)
+        workspaces.push(next)
 
         await openSidebar(page)
       }

+ 12 - 6
packages/app/e2e/prompt/prompt-multiline.spec.ts

@@ -7,12 +7,18 @@ test("shift+enter inserts a newline without submitting", async ({ page, gotoSess
   await expect(page).toHaveURL(/\/session\/?$/)
 
   const prompt = page.locator(promptSelector)
-  await prompt.click()
-  await page.keyboard.type("line one")
-  await page.keyboard.press("Shift+Enter")
-  await page.keyboard.type("line two")
+  await prompt.focus()
+  await expect(prompt).toBeFocused()
+
+  await prompt.pressSequentially("line one")
+  await expect(prompt).toBeFocused()
+
+  await prompt.press("Shift+Enter")
+  await expect(page).toHaveURL(/\/session\/?$/)
+  await expect(prompt).toBeFocused()
+
+  await prompt.pressSequentially("line two")
 
   await expect(page).toHaveURL(/\/session\/?$/)
-  await expect(prompt).toContainText("line one")
-  await expect(prompt).toContainText("line two")
+  await expect.poll(() => prompt.evaluate((el) => el.innerText)).toBe("line one\nline two")
 })

+ 4 - 7
packages/app/e2e/prompt/prompt-slash-terminal.spec.ts

@@ -1,4 +1,5 @@
 import { test, expect } from "../fixtures"
+import { runPromptSlash, waitTerminalFocusIdle } from "../actions"
 import { promptSelector, terminalSelector } from "../selectors"
 
 test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
@@ -9,13 +10,9 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
 
   await expect(terminal).not.toBeVisible()
 
-  await prompt.fill("/terminal")
-  await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
-  await page.keyboard.press("Enter")
-  await expect(terminal).toBeVisible()
+  await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
+  await waitTerminalFocusIdle(page, { term: terminal })
 
-  await prompt.fill("/terminal")
-  await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
-  await page.keyboard.press("Enter")
+  await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
   await expect(terminal).not.toBeVisible()
 })

+ 3 - 0
packages/app/e2e/selectors.ts

@@ -13,6 +13,9 @@ export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggl
 export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
 
 export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
+export const promptAgentSelector = '[data-component="prompt-agent-control"]'
+export const promptModelSelector = '[data-component="prompt-model-control"]'
+export const promptVariantSelector = '[data-component="prompt-variant-control"]'
 export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
 export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
 export const settingsThemeSelector = '[data-action="settings-theme"]'

+ 176 - 71
packages/app/e2e/session/session-composer-dock.spec.ts

@@ -1,12 +1,16 @@
 import { test, expect } from "../fixtures"
-import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
+import {
+  composerEvent,
+  type ComposerDriverState,
+  type ComposerProbeState,
+  type ComposerWindow,
+} from "../../src/testing/session-composer"
+import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions"
 import {
   permissionDockSelector,
   promptSelector,
   questionDockSelector,
   sessionComposerDockSelector,
-  sessionTodoDockSelector,
-  sessionTodoListSelector,
   sessionTodoToggleButtonSelector,
 } from "../selectors"
 
@@ -42,12 +46,8 @@ async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>
 
 async function clearPermissionDock(page: any, label: RegExp) {
   const dock = page.locator(permissionDockSelector)
-  for (let i = 0; i < 3; i++) {
-    const count = await dock.count()
-    if (count === 0) return
-    await dock.getByRole("button", { name: label }).click()
-    await page.waitForTimeout(150)
-  }
+  await expect(dock).toBeVisible()
+  await dock.getByRole("button", { name: label }).click()
 }
 
 async function setAutoAccept(page: any, enabled: boolean) {
@@ -59,6 +59,120 @@ async function setAutoAccept(page: any, enabled: boolean) {
   await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
 }
 
+async function expectQuestionBlocked(page: any) {
+  await expect(page.locator(questionDockSelector)).toBeVisible()
+  await expect(page.locator(promptSelector)).toHaveCount(0)
+}
+
+async function expectQuestionOpen(page: any) {
+  await expect(page.locator(questionDockSelector)).toHaveCount(0)
+  await expect(page.locator(promptSelector)).toBeVisible()
+}
+
+async function expectPermissionBlocked(page: any) {
+  await expect(page.locator(permissionDockSelector)).toBeVisible()
+  await expect(page.locator(promptSelector)).toHaveCount(0)
+}
+
+async function expectPermissionOpen(page: any) {
+  await expect(page.locator(permissionDockSelector)).toHaveCount(0)
+  await expect(page.locator(promptSelector)).toBeVisible()
+}
+
+async function todoDock(page: any, sessionID: string) {
+  await page.addInitScript(() => {
+    const win = window as ComposerWindow
+    win.__opencode_e2e = {
+      ...win.__opencode_e2e,
+      composer: {
+        enabled: true,
+        sessions: {},
+      },
+    }
+  })
+
+  const write = async (driver: ComposerDriverState | undefined) => {
+    await page.evaluate(
+      (input) => {
+        const win = window as ComposerWindow
+        const composer = win.__opencode_e2e?.composer
+        if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
+        composer.sessions ??= {}
+        const prev = composer.sessions[input.sessionID] ?? {}
+        if (!input.driver) {
+          if (!prev.probe) {
+            delete composer.sessions[input.sessionID]
+          } else {
+            composer.sessions[input.sessionID] = { probe: prev.probe }
+          }
+        } else {
+          composer.sessions[input.sessionID] = {
+            ...prev,
+            driver: input.driver,
+          }
+        }
+        window.dispatchEvent(new CustomEvent(input.event, { detail: { sessionID: input.sessionID } }))
+      },
+      { event: composerEvent, sessionID, driver },
+    )
+  }
+
+  const read = () =>
+    page.evaluate((sessionID) => {
+      const win = window as ComposerWindow
+      return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
+    }, sessionID) as Promise<ComposerProbeState | null>
+
+  const api = {
+    async clear() {
+      await write(undefined)
+      return api
+    },
+    async open(todos: NonNullable<ComposerDriverState["todos"]>) {
+      await write({ live: true, todos })
+      return api
+    },
+    async finish(todos: NonNullable<ComposerDriverState["todos"]>) {
+      await write({ live: false, todos })
+      return api
+    },
+    async expectOpen(states: ComposerProbeState["states"]) {
+      await expect.poll(read, { timeout: 10_000 }).toMatchObject({
+        mounted: true,
+        collapsed: false,
+        hidden: false,
+        count: states.length,
+        states,
+      })
+      return api
+    },
+    async expectCollapsed(states: ComposerProbeState["states"]) {
+      await expect.poll(read, { timeout: 10_000 }).toMatchObject({
+        mounted: true,
+        collapsed: true,
+        hidden: true,
+        count: states.length,
+        states,
+      })
+      return api
+    },
+    async expectClosed() {
+      await expect.poll(read, { timeout: 10_000 }).toMatchObject({ mounted: false })
+      return api
+    },
+    async collapse() {
+      await page.locator(sessionTodoToggleButtonSelector).click()
+      return api
+    },
+    async expand() {
+      await page.locator(sessionTodoToggleButtonSelector).click()
+      return api
+    },
+  }
+
+  return api
+}
+
 async function withMockPermission<T>(
   page: any,
   request: {
@@ -70,7 +184,7 @@ async function withMockPermission<T>(
     always?: string[]
   },
   opts: { child?: any } | undefined,
-  fn: () => Promise<T>,
+  fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
 ) {
   let pending = [
     {
@@ -119,8 +233,14 @@ async function withMockPermission<T>(
 
   if (sessionList) await page.route("**/session?*", sessionList)
 
+  const state = {
+    async resolved() {
+      await expect.poll(() => pending.length, { timeout: 10_000 }).toBe(0)
+    },
+  }
+
   try {
-    return await fn()
+    return await fn(state)
   } finally {
     await page.unroute("**/permission", list)
     await page.unroute("**/session/*/permissions/*", reply)
@@ -173,14 +293,12 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
       })
 
       const dock = page.locator(questionDockSelector)
-      await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
-      await expect(page.locator(promptSelector)).toHaveCount(0)
+      await expectQuestionBlocked(page)
 
       await dock.locator('[data-slot="question-option"]').first().click()
       await dock.getByRole("button", { name: /submit/i }).click()
 
-      await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
-      await expect(page.locator(promptSelector)).toBeVisible()
+      await expectQuestionOpen(page)
     })
   })
 })
@@ -199,15 +317,14 @@ test("blocked permission flow supports allow once", async ({ page, sdk, gotoSess
         metadata: { description: "Need permission for command" },
       },
       undefined,
-      async () => {
+      async (state) => {
         await page.goto(page.url())
-        await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
-        await expect(page.locator(promptSelector)).toHaveCount(0)
+        await expectPermissionBlocked(page)
 
         await clearPermissionDock(page, /allow once/i)
+        await state.resolved()
         await page.goto(page.url())
-        await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
-        await expect(page.locator(promptSelector)).toBeVisible()
+        await expectPermissionOpen(page)
       },
     )
   })
@@ -226,15 +343,14 @@ test("blocked permission flow supports reject", async ({ page, sdk, gotoSession
         patterns: ["/tmp/opencode-e2e-perm-reject"],
       },
       undefined,
-      async () => {
+      async (state) => {
         await page.goto(page.url())
-        await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
-        await expect(page.locator(promptSelector)).toHaveCount(0)
+        await expectPermissionBlocked(page)
 
         await clearPermissionDock(page, /deny/i)
+        await state.resolved()
         await page.goto(page.url())
-        await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
-        await expect(page.locator(promptSelector)).toBeVisible()
+        await expectPermissionOpen(page)
       },
     )
   })
@@ -254,15 +370,14 @@ test("blocked permission flow supports allow always", async ({ page, sdk, gotoSe
         metadata: { description: "Need permission for command" },
       },
       undefined,
-      async () => {
+      async (state) => {
         await page.goto(page.url())
-        await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
-        await expect(page.locator(promptSelector)).toHaveCount(0)
+        await expectPermissionBlocked(page)
 
         await clearPermissionDock(page, /allow always/i)
+        await state.resolved()
         await page.goto(page.url())
-        await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
-        await expect(page.locator(promptSelector)).toBeVisible()
+        await expectPermissionOpen(page)
       },
     )
   })
@@ -301,14 +416,12 @@ test("child session question request blocks parent dock and unblocks after submi
         })
 
         const dock = page.locator(questionDockSelector)
-        await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
-        await expect(page.locator(promptSelector)).toHaveCount(0)
+        await expectQuestionBlocked(page)
 
         await dock.locator('[data-slot="question-option"]').first().click()
         await dock.getByRole("button", { name: /submit/i }).click()
 
-        await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
-        await expect(page.locator(promptSelector)).toBeVisible()
+        await expectQuestionOpen(page)
       })
     } finally {
       await cleanupSession({ sdk, sessionID: child.id })
@@ -344,17 +457,15 @@ test("child session permission request blocks parent dock and supports allow onc
           metadata: { description: "Need child permission" },
         },
         { child },
-        async () => {
+        async (state) => {
           await page.goto(page.url())
-          const dock = page.locator(permissionDockSelector)
-          await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
-          await expect(page.locator(promptSelector)).toHaveCount(0)
+          await expectPermissionBlocked(page)
 
           await clearPermissionDock(page, /allow once/i)
+          await state.resolved()
           await page.goto(page.url())
 
-          await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
-          await expect(page.locator(promptSelector)).toBeVisible()
+          await expectPermissionOpen(page)
         },
       )
     } finally {
@@ -365,36 +476,31 @@ test("child session permission request blocks parent dock and supports allow onc
 
 test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
   await withDockSession(sdk, "e2e composer dock todo", async (session) => {
-    await withDockSeed(sdk, session.id, async () => {
-      await gotoSession(session.id)
-
-      await seedSessionTodos(sdk, {
-        sessionID: session.id,
-        todos: [
-          { content: "first task", status: "pending", priority: "high" },
-          { content: "second task", status: "in_progress", priority: "medium" },
-        ],
-      })
-
-      await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
-      await expect(page.locator(sessionTodoListSelector)).toBeVisible()
-
-      await page.locator(sessionTodoToggleButtonSelector).click()
-      await expect(page.locator(sessionTodoListSelector)).toBeHidden()
-
-      await page.locator(sessionTodoToggleButtonSelector).click()
-      await expect(page.locator(sessionTodoListSelector)).toBeVisible()
-
-      await seedSessionTodos(sdk, {
-        sessionID: session.id,
-        todos: [
-          { content: "first task", status: "completed", priority: "high" },
-          { content: "second task", status: "cancelled", priority: "medium" },
-        ],
-      })
+    const dock = await todoDock(page, session.id)
+    await gotoSession(session.id)
+    await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
 
-      await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
-    })
+    try {
+      await dock.open([
+        { content: "first task", status: "pending", priority: "high" },
+        { content: "second task", status: "in_progress", priority: "medium" },
+      ])
+      await dock.expectOpen(["pending", "in_progress"])
+
+      await dock.collapse()
+      await dock.expectCollapsed(["pending", "in_progress"])
+
+      await dock.expand()
+      await dock.expectOpen(["pending", "in_progress"])
+
+      await dock.finish([
+        { content: "first task", status: "completed", priority: "high" },
+        { content: "second task", status: "cancelled", priority: "medium" },
+      ])
+      await dock.expectClosed()
+    } finally {
+      await dock.clear()
+    }
   })
 })
 
@@ -414,8 +520,7 @@ test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSe
         ],
       })
 
-      await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
-      await expect(page.locator(promptSelector)).toHaveCount(0)
+      await expectQuestionBlocked(page)
 
       await page.locator("main").click({ position: { x: 5, y: 5 } })
       await page.keyboard.type("abc")

+ 371 - 0
packages/app/e2e/session/session-model-persistence.spec.ts

@@ -0,0 +1,371 @@
+import type { Locator, Page } from "@playwright/test"
+import { test, expect } from "../fixtures"
+import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
+import {
+  promptAgentSelector,
+  promptModelSelector,
+  promptSelector,
+  promptVariantSelector,
+  workspaceItemSelector,
+  workspaceNewSessionSelector,
+} from "../selectors"
+import { createSdk, sessionPath } from "../utils"
+
+type Footer = {
+  agent: string
+  model: string
+  variant: string
+}
+
+type Probe = {
+  dir?: string
+  sessionID?: string
+  model?: { providerID: string; modelID: string }
+}
+
+const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+
+const text = async (locator: Locator) => ((await locator.textContent()) ?? "").trim()
+
+const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
+
+const dirKey = (state: Probe | null) => state?.dir ?? ""
+
+async function probe(page: Page): Promise<Probe | null> {
+  return page.evaluate(() => {
+    const win = window as Window & {
+      __opencode_e2e?: {
+        model?: {
+          current?: Probe
+        }
+      }
+    }
+    return win.__opencode_e2e?.model?.current ?? null
+  })
+}
+
+async function currentDir(page: Page) {
+  let hit = ""
+  await expect
+    .poll(
+      async () => {
+        const next = dirKey(await probe(page))
+        if (next) hit = next
+        return next
+      },
+      { timeout: 30_000 },
+    )
+    .not.toBe("")
+  return hit
+}
+
+async function read(page: Page): Promise<Footer> {
+  return {
+    agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
+    model: await text(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()),
+    variant: await text(page.locator(`${promptVariantSelector} [data-slot="select-select-trigger-value"]`).first()),
+  }
+}
+
+async function waitFooter(page: Page, expected: Partial<Footer>) {
+  let hit: Footer | null = null
+  await expect
+    .poll(
+      async () => {
+        const state = await read(page)
+        const ok = Object.entries(expected).every(([key, value]) => state[key as keyof Footer] === value)
+        if (ok) hit = state
+        return ok
+      },
+      { timeout: 30_000 },
+    )
+    .toBe(true)
+  if (!hit) throw new Error("Failed to resolve prompt footer state")
+  return hit
+}
+
+async function waitModel(page: Page, value: string) {
+  await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).toBe(value)
+}
+
+async function choose(page: Page, root: string, value: string) {
+  const select = page.locator(root)
+  await expect(select).toBeVisible()
+  await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
+  const item = page
+    .locator('[data-slot="select-select-item"]')
+    .filter({ hasText: new RegExp(`^\\s*${escape(value)}\\s*$`) })
+    .first()
+  await expect(item).toBeVisible()
+  await item.click()
+}
+
+async function variantCount(page: Page) {
+  const select = page.locator(promptVariantSelector)
+  await expect(select).toBeVisible()
+  await select.locator('[data-slot="select-select-trigger"]').click()
+  const count = await page.locator('[data-slot="select-select-item"]').count()
+  await page.keyboard.press("Escape")
+  return count
+}
+
+async function agents(page: Page) {
+  const select = page.locator(promptAgentSelector)
+  await expect(select).toBeVisible()
+  await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
+  const labels = await page.locator('[data-slot="select-select-item-label"]').allTextContents()
+  await page.keyboard.press("Escape")
+  return labels.map((item) => item.trim()).filter(Boolean)
+}
+
+async function ensureVariant(page: Page, directory: string): Promise<Footer> {
+  const current = await read(page)
+  if ((await variantCount(page)) >= 2) return current
+
+  const cfg = await createSdk(directory)
+    .config.get()
+    .then((x) => x.data)
+  const visible = new Set(await agents(page))
+  const entry = Object.entries(cfg?.agent ?? {}).find((item) => {
+    const value = item[1]
+    return !!value && typeof value === "object" && "variant" in value && "model" in value && visible.has(item[0])
+  })
+  const name = entry?.[0]
+  test.skip(!name, "no agent with alternate variants available")
+  if (!name) return current
+
+  await choose(page, promptAgentSelector, name)
+  await expect.poll(() => variantCount(page), { timeout: 30_000 }).toBeGreaterThanOrEqual(2)
+  return waitFooter(page, { agent: name })
+}
+
+async function chooseDifferentVariant(page: Page): Promise<Footer> {
+  const current = await read(page)
+  const select = page.locator(promptVariantSelector)
+  await expect(select).toBeVisible()
+  await select.locator('[data-slot="select-select-trigger"]').click()
+
+  const items = page.locator('[data-slot="select-select-item"]')
+  const count = await items.count()
+  if (count < 2) throw new Error("Current model has no alternate variant to select")
+
+  for (let i = 0; i < count; i++) {
+    const item = items.nth(i)
+    const next = await text(item.locator('[data-slot="select-select-item-label"]').first())
+    if (!next || next === current.variant) continue
+    await item.click()
+    return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
+  }
+
+  throw new Error("Failed to choose a different variant")
+}
+
+async function chooseOtherModel(page: Page): Promise<Footer> {
+  const current = await read(page)
+  const button = page.locator(`${promptModelSelector} [data-action="prompt-model"]`)
+  await expect(button).toBeVisible()
+  await button.click()
+
+  const dialog = page.getByRole("dialog")
+  await expect(dialog).toBeVisible()
+  const items = dialog.locator('[data-slot="list-item"]')
+  const count = await items.count()
+  expect(count).toBeGreaterThan(1)
+
+  for (let i = 0; i < count; i++) {
+    const item = items.nth(i)
+    const selected = (await item.getAttribute("data-selected")) === "true"
+    if (selected) continue
+    await item.click()
+    await expect(dialog).toHaveCount(0)
+    await expect.poll(async () => (await read(page)).model !== current.model, { timeout: 30_000 }).toBe(true)
+    return read(page)
+  }
+
+  throw new Error("Failed to choose a different model")
+}
+
+async function goto(page: Page, directory: string, sessionID?: string) {
+  await page.goto(sessionPath(directory, sessionID))
+  await expect(page.locator(promptSelector)).toBeVisible()
+  await expect.poll(async () => dirKey(await probe(page)), { timeout: 30_000 }).toBe(directory)
+}
+
+async function submit(page: Page, value: string) {
+  const prompt = page.locator(promptSelector)
+  await expect(prompt).toBeVisible()
+  await prompt.click()
+  await prompt.fill(value)
+  await prompt.press("Enter")
+
+  await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
+  const id = sessionIDFromUrl(page.url())
+  if (!id) throw new Error(`Failed to resolve session id from ${page.url()}`)
+  return id
+}
+
+async function waitUser(directory: string, sessionID: string) {
+  const sdk = createSdk(directory)
+  await expect
+    .poll(
+      async () => {
+        const items = await sdk.session.messages({ sessionID, limit: 20 }).then((x) => x.data ?? [])
+        return items.some((item) => item.info.role === "user")
+      },
+      { timeout: 30_000 },
+    )
+    .toBe(true)
+  await sdk.session.abort({ sessionID }).catch(() => undefined)
+  await waitSessionIdle(sdk, sessionID, 30_000).catch(() => undefined)
+}
+
+async function createWorkspace(page: Page, root: string, seen: string[]) {
+  await openSidebar(page)
+  await page.getByRole("button", { name: "New workspace" }).first().click()
+
+  const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
+  await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
+  return next
+}
+
+async function waitWorkspace(page: Page, slug: string) {
+  await openSidebar(page)
+  await expect
+    .poll(
+      async () => {
+        const item = page.locator(workspaceItemSelector(slug)).first()
+        try {
+          await item.hover({ timeout: 500 })
+          return true
+        } catch {
+          return false
+        }
+      },
+      { timeout: 60_000 },
+    )
+    .toBe(true)
+}
+
+async function newWorkspaceSession(page: Page, slug: string) {
+  await waitWorkspace(page, slug)
+  const item = page.locator(workspaceItemSelector(slug)).first()
+  await item.hover()
+
+  const button = page.locator(workspaceNewSessionSelector(slug)).first()
+  await expect(button).toBeVisible()
+  await button.click({ force: true })
+
+  const next = await resolveSlug(await waitSlug(page))
+  await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
+  await expect(page.locator(promptSelector)).toBeVisible()
+  return currentDir(page)
+}
+
+test("session model and variant restore per session without leaking into new sessions", async ({
+  page,
+  withProject,
+}) => {
+  await page.setViewportSize({ width: 1440, height: 900 })
+
+  await withProject(async ({ directory, gotoSession, trackSession }) => {
+    await gotoSession()
+
+    await ensureVariant(page, directory)
+    const firstState = await chooseDifferentVariant(page)
+    const first = await submit(page, `session variant ${Date.now()}`)
+    trackSession(first)
+    await waitUser(directory, first)
+
+    await page.reload()
+    await expect(page.locator(promptSelector)).toBeVisible()
+    await waitFooter(page, firstState)
+
+    await gotoSession()
+    const fresh = await ensureVariant(page, directory)
+    expect(fresh.variant).not.toBe(firstState.variant)
+
+    const secondState = await chooseOtherModel(page)
+    const second = await submit(page, `session model ${Date.now()}`)
+    trackSession(second)
+    await waitUser(directory, second)
+
+    await goto(page, directory, first)
+    await waitFooter(page, firstState)
+
+    await goto(page, directory, second)
+    await waitFooter(page, secondState)
+
+    await gotoSession()
+    await waitFooter(page, fresh)
+  })
+})
+
+test("session model restore across workspaces", async ({ page, withProject }) => {
+  await page.setViewportSize({ width: 1440, height: 900 })
+
+  await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
+    await gotoSession()
+
+    await ensureVariant(page, root)
+    const firstState = await chooseDifferentVariant(page)
+    const first = await submit(page, `root session ${Date.now()}`)
+    trackSession(first, root)
+    await waitUser(root, first)
+
+    await openSidebar(page)
+    await setWorkspacesEnabled(page, slug, true)
+
+    const one = await createWorkspace(page, slug, [])
+    const oneDir = await newWorkspaceSession(page, one.slug)
+    trackDirectory(oneDir)
+
+    const secondState = await chooseOtherModel(page)
+    const second = await submit(page, `workspace one ${Date.now()}`)
+    trackSession(second, oneDir)
+    await waitUser(oneDir, second)
+
+    const two = await createWorkspace(page, slug, [one.slug])
+    const twoDir = await newWorkspaceSession(page, two.slug)
+    trackDirectory(twoDir)
+
+    await ensureVariant(page, twoDir)
+    const thirdState = await chooseDifferentVariant(page)
+    const third = await submit(page, `workspace two ${Date.now()}`)
+    trackSession(third, twoDir)
+    await waitUser(twoDir, third)
+
+    await goto(page, root, first)
+    await waitFooter(page, firstState)
+
+    await goto(page, oneDir, second)
+    await waitFooter(page, secondState)
+
+    await goto(page, twoDir, third)
+    await waitFooter(page, thirdState)
+
+    await goto(page, root, first)
+    await waitFooter(page, firstState)
+  })
+})
+
+test("variant preserved when switching agent modes", async ({ page, withProject }) => {
+  await page.setViewportSize({ width: 1440, height: 900 })
+
+  await withProject(async ({ directory, gotoSession }) => {
+    await gotoSession()
+
+    await ensureVariant(page, directory)
+    const updated = await chooseDifferentVariant(page)
+
+    const available = await agents(page)
+    const other = available.find((name) => name !== updated.agent)
+    test.skip(!other, "only one agent available")
+    if (!other) return
+
+    await choose(page, promptAgentSelector, other)
+    await waitFooter(page, { agent: other, variant: updated.variant })
+
+    await choose(page, promptAgentSelector, updated.agent)
+    await waitFooter(page, { agent: updated.agent, variant: updated.variant })
+  })
+})

+ 132 - 6
packages/app/e2e/session/session-review.spec.ts

@@ -85,7 +85,10 @@ async function expand(page: Parameters<typeof test>[0]["page"]) {
 async function waitMark(page: Parameters<typeof test>[0]["page"], file: string, mark: string) {
   await page.waitForFunction(
     ({ file, mark }) => {
-      const head = Array.from(document.querySelectorAll("h3")).find(
+      const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
+      if (!(view instanceof HTMLElement)) return false
+
+      const head = Array.from(view.querySelectorAll("h3")).find(
         (node) => node instanceof HTMLElement && node.textContent?.includes(file),
       )
       if (!(head instanceof HTMLElement)) return false
@@ -101,12 +104,127 @@ async function waitMark(page: Parameters<typeof test>[0]["page"], file: string,
   )
 }
 
+async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
+  return page.evaluate((file) => {
+    const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
+    if (!(view instanceof HTMLElement)) return null
+
+    const row = Array.from(view.querySelectorAll("h3")).find(
+      (node) => node instanceof HTMLElement && node.textContent?.includes(file),
+    )
+    if (!(row instanceof HTMLElement)) return null
+
+    const a = row.getBoundingClientRect()
+    const b = view.getBoundingClientRect()
+    return {
+      top: a.top - b.top,
+      y: view.scrollTop,
+    }
+  }, file)
+}
+
+async function comment(page: Parameters<typeof test>[0]["page"], file: string, note: string) {
+  const row = page.locator(`[data-file="${file}"]`).first()
+  await expect(row).toBeVisible()
+
+  const line = row.locator('diffs-container [data-line="2"]').first()
+  await expect(line).toBeVisible()
+  await line.hover()
+
+  const add = row.getByRole("button", { name: /^Comment$/ }).first()
+  await expect(add).toBeVisible()
+  await add.click()
+
+  const area = row.locator('[data-slot="line-comment-textarea"]').first()
+  await expect(area).toBeVisible()
+  await area.fill(note)
+
+  const submit = row.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
+  await expect(submit).toBeEnabled()
+  await submit.click()
+
+  await expect(row.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
+  await expect(row.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
+}
+
+async function overflow(page: Parameters<typeof test>[0]["page"], file: string) {
+  const row = page.locator(`[data-file="${file}"]`).first()
+  const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
+  const pop = row.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
+  const tools = row.locator('[data-slot="line-comment-tools"]').first()
+
+  const [width, viewBox, popBox, toolsBox] = await Promise.all([
+    view.evaluate((el) => el.scrollWidth - el.clientWidth),
+    view.boundingBox(),
+    pop.boundingBox(),
+    tools.boundingBox(),
+  ])
+
+  if (!viewBox || !popBox || !toolsBox) return null
+
+  return {
+    width,
+    pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
+    tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
+  }
+}
+
+test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
+  test.setTimeout(180_000)
+
+  const tag = `review-comment-${Date.now()}`
+  const file = `review-comment-${tag}.txt`
+  const note = `comment ${tag}`
+
+  await page.setViewportSize({ width: 1280, height: 900 })
+
+  await withProject(async (project) => {
+    const sdk = createSdk(project.directory)
+
+    await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
+      await patch(sdk, session.id, seed([{ file, mark: tag }]))
+
+      await expect
+        .poll(
+          async () => {
+            const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
+            return diff.length
+          },
+          { timeout: 60_000 },
+        )
+        .toBe(1)
+
+      await project.gotoSession(session.id)
+      await show(page)
+
+      const tab = page.getByRole("tab", { name: /Review/i }).first()
+      await expect(tab).toBeVisible()
+      await tab.click()
+
+      await expand(page)
+      await waitMark(page, file, tag)
+      await comment(page, file, note)
+
+      await expect
+        .poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
+        .toBeLessThanOrEqual(1)
+      await expect
+        .poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
+        .toBeLessThanOrEqual(1)
+      await expect
+        .poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
+        .toBeLessThanOrEqual(1)
+    })
+  })
+})
+
 test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
+  test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
   test.setTimeout(180_000)
 
   const tag = `review-${Date.now()}`
   const list = files(tag)
-  const hit = list[list.length - 2]!
+  const hit = list[list.length - 4]!
   const next = `${tag}-live`
 
   await page.setViewportSize({ width: 1600, height: 1000 })
@@ -160,8 +278,9 @@ test("review keeps scroll position after a live diff update", async ({ page, wit
       await expect(row).toBeVisible()
       await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
 
-      await expect.poll(() => view.evaluate((el) => el.scrollTop)).toBeGreaterThan(200)
-      const prev = await view.evaluate((el) => el.scrollTop)
+      await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
+      const prev = await spot(page, hit.file)
+      if (!prev) throw new Error(`missing review row for ${hit.file}`)
 
       await patch(sdk, session.id, edit(hit.file, hit.mark, next))
 
@@ -179,8 +298,15 @@ test("review keeps scroll position after a live diff update", async ({ page, wit
       await waitMark(page, hit.file, next)
 
       await expect
-        .poll(async () => Math.abs((await view.evaluate((el) => el.scrollTop)) - prev), { timeout: 60_000 })
-        .toBeLessThanOrEqual(16)
+        .poll(
+          async () => {
+            const next = await spot(page, hit.file)
+            if (!next) return Number.POSITIVE_INFINITY
+            return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
+          },
+          { timeout: 60_000 },
+        )
+        .toBeLessThanOrEqual(32)
     })
   })
 })

+ 2 - 2
packages/app/e2e/settings/settings-keybinds.spec.ts

@@ -1,5 +1,5 @@
 import { test, expect } from "../fixtures"
-import { openSettings, closeDialog, withSession } from "../actions"
+import { openSettings, closeDialog, waitTerminalFocusIdle, withSession } from "../actions"
 import { keybindButtonSelector, terminalSelector } from "../selectors"
 import { modKey } from "../utils"
 
@@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
   await expect(terminal).not.toBeVisible()
 
   await page.keyboard.press(`${modKey}+Y`)
-  await expect(terminal).toBeVisible()
+  await waitTerminalFocusIdle(page, { term: terminal })
 
   await page.keyboard.press(`${modKey}+Y`)
   await expect(terminal).not.toBeVisible()

+ 3 - 3
packages/app/e2e/terminal/terminal-init.spec.ts

@@ -1,4 +1,5 @@
 import { test, expect } from "../fixtures"
+import { waitTerminalFocusIdle, waitTerminalReady } from "../actions"
 import { promptSelector, terminalSelector } from "../selectors"
 import { terminalToggleKey } from "../utils"
 
@@ -13,8 +14,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
     await page.keyboard.press(terminalToggleKey)
   }
 
-  await expect(terminals.first()).toBeVisible()
-  await expect(terminals.first().locator("textarea")).toHaveCount(1)
+  await waitTerminalFocusIdle(page, { term: terminals.first() })
   await expect(terminals).toHaveCount(1)
 
   // Ghostty captures a lot of keybinds when focused; move focus back
@@ -24,5 +24,5 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
 
   await expect(tabs).toHaveCount(2)
   await expect(terminals).toHaveCount(1)
-  await expect(terminals.first().locator("textarea")).toHaveCount(1)
+  await waitTerminalReady(page, { term: terminals.first() })
 })

+ 46 - 0
packages/app/e2e/terminal/terminal-reconnect.spec.ts

@@ -0,0 +1,46 @@
+import type { Page } from "@playwright/test"
+import { disconnectTerminal, runTerminal, terminalConnects, waitTerminalReady } from "../actions"
+import { test, expect } from "../fixtures"
+import { terminalSelector } from "../selectors"
+import { terminalToggleKey } from "../utils"
+
+async function open(page: Page) {
+  const term = page.locator(terminalSelector).first()
+  const visible = await term.isVisible().catch(() => false)
+  if (!visible) await page.keyboard.press(terminalToggleKey)
+  await waitTerminalReady(page, { term })
+  return term
+}
+
+test("terminal reconnects without replacing the pty", async ({ page, withProject }) => {
+  await withProject(async ({ gotoSession }) => {
+    const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
+    const token = `E2E_RECONNECT_${Date.now()}`
+
+    await gotoSession()
+
+    const term = await open(page)
+    const id = await term.getAttribute("data-pty-id")
+    if (!id) throw new Error("Active terminal missing data-pty-id")
+
+    const prev = await terminalConnects(page, { term })
+
+    await runTerminal(page, {
+      term,
+      cmd: `export ${name}=${token}; echo ${token}`,
+      token,
+    })
+
+    await disconnectTerminal(page, { term })
+
+    await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
+    await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
+
+    await runTerminal(page, {
+      term,
+      cmd: `echo $${name}`,
+      token,
+      timeout: 15_000,
+    })
+  })
+})

+ 7 - 14
packages/app/e2e/terminal/terminal-tabs.spec.ts

@@ -1,4 +1,5 @@
 import type { Page } from "@playwright/test"
+import { runTerminal, waitTerminalReady } from "../actions"
 import { test, expect } from "../fixtures"
 import { terminalSelector } from "../selectors"
 import { terminalToggleKey, workspacePersistKey } from "../utils"
@@ -17,16 +18,7 @@ async function open(page: Page) {
   const terminal = page.locator(terminalSelector)
   const visible = await terminal.isVisible().catch(() => false)
   if (!visible) await page.keyboard.press(terminalToggleKey)
-  await expect(terminal).toBeVisible()
-  await expect(terminal.locator("textarea")).toHaveCount(1)
-}
-
-async function run(page: Page, cmd: string) {
-  const terminal = page.locator(terminalSelector)
-  await expect(terminal).toBeVisible()
-  await terminal.click()
-  await page.keyboard.type(cmd)
-  await page.keyboard.press("Enter")
+  await waitTerminalReady(page, { term: terminal })
 }
 
 async function store(page: Page, key: string) {
@@ -56,15 +48,16 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
     await gotoSession()
     await open(page)
 
-    await run(page, `echo ${one}`)
+    await runTerminal(page, { cmd: `echo ${one}`, token: one })
 
     await page.getByRole("button", { name: /new terminal/i }).click()
     await expect(tabs).toHaveCount(2)
 
-    await run(page, `echo ${two}`)
+    await runTerminal(page, { cmd: `echo ${two}`, token: two })
 
     await first.click()
     await expect(first).toHaveAttribute("aria-selected", "true")
+
     await expect
       .poll(
         async () => {
@@ -76,7 +69,7 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
             second: second.includes(two),
           }
         },
-        { timeout: 30_000 },
+        { timeout: 5_000 },
       )
       .toEqual({ first: false, second: true })
 
@@ -93,7 +86,7 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
             second: second.includes(two),
           }
         },
-        { timeout: 30_000 },
+        { timeout: 5_000 },
       )
       .toEqual({ first: true, second: false })
   })

+ 2 - 1
packages/app/e2e/terminal/terminal.spec.ts

@@ -1,4 +1,5 @@
 import { test, expect } from "../fixtures"
+import { waitTerminalReady } from "../actions"
 import { terminalSelector } from "../selectors"
 import { terminalToggleKey } from "../utils"
 
@@ -13,5 +14,5 @@ test("terminal panel can be toggled", async ({ page, gotoSession }) => {
   }
 
   await page.keyboard.press(terminalToggleKey)
-  await expect(terminal).toBeVisible()
+  await waitTerminalReady(page, { term: terminal })
 })

+ 2 - 1
packages/app/e2e/tsconfig.json

@@ -2,7 +2,8 @@
   "extends": "../tsconfig.json",
   "compilerOptions": {
     "noEmit": true,
+    "rootDir": "..",
     "types": ["node", "bun"]
   },
-  "include": ["./**/*.ts"]
+  "include": ["./**/*.ts", "../src/testing/terminal.ts"]
 }

+ 3 - 2
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/app",
-  "version": "1.2.24",
+  "version": "1.2.27",
   "description": "",
   "type": "module",
   "exports": {
@@ -45,8 +45,8 @@
     "@shikijs/transformers": "3.9.2",
     "@solid-primitives/active-element": "2.1.3",
     "@solid-primitives/audio": "1.4.2",
-    "@solid-primitives/i18n": "2.2.1",
     "@solid-primitives/event-bus": "1.1.2",
+    "@solid-primitives/i18n": "2.2.1",
     "@solid-primitives/media": "2.3.3",
     "@solid-primitives/resize-observer": "2.1.3",
     "@solid-primitives/scroll": "2.1.3",
@@ -56,6 +56,7 @@
     "@solidjs/router": "catalog:",
     "@thisbeyond/solid-dnd": "0.7.5",
     "diff": "catalog:",
+    "effect": "catalog:",
     "fuzzysort": "catalog:",
     "ghostty-web": "github:anomalyco/ghostty-web#main",
     "luxon": "catalog:",

+ 2 - 0
packages/app/playwright.config.ts

@@ -6,6 +6,7 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
 const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
 const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
 const reuse = !process.env.CI
+const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
 
 export default defineConfig({
   testDir: "./e2e",
@@ -17,6 +18,7 @@ export default defineConfig({
   fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1",
   forbidOnly: !!process.env.CI,
   retries: process.env.CI ? 2 : 0,
+  workers,
   reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
   webServer: {
     command,

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

@@ -73,6 +73,7 @@ const serverEnv = {
   OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
   OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
   OPENCODE_CLIENT: "app",
+  OPENCODE_STRICT_CONFIG_DEPS: "true",
 } satisfies Record<string, string>
 
 const runnerEnv = {

+ 165 - 35
packages/app/src/app.tsx

@@ -1,14 +1,30 @@
 import "@/index.css"
-import { File } from "@opencode-ai/ui/file"
 import { I18nProvider } from "@opencode-ai/ui/context"
 import { DialogProvider } from "@opencode-ai/ui/context/dialog"
 import { FileComponentProvider } from "@opencode-ai/ui/context/file"
 import { MarkedProvider } from "@opencode-ai/ui/context/marked"
+import { File } from "@opencode-ai/ui/file"
 import { Font } from "@opencode-ai/ui/font"
+import { Splash } from "@opencode-ai/ui/logo"
 import { ThemeProvider } from "@opencode-ai/ui/theme"
 import { MetaProvider } from "@solidjs/meta"
-import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
-import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
+import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
+import { type Duration, Effect } from "effect"
+import {
+  type Component,
+  createMemo,
+  createResource,
+  createSignal,
+  ErrorBoundary,
+  For,
+  type JSX,
+  lazy,
+  onCleanup,
+  type ParentProps,
+  Show,
+  Suspense,
+} from "solid-js"
+import { Dynamic } from "solid-js/web"
 import { CommandProvider } from "@/context/command"
 import { CommentsProvider } from "@/context/comments"
 import { FileProvider } from "@/context/file"
@@ -22,29 +38,21 @@ import { NotificationProvider } from "@/context/notification"
 import { PermissionProvider } from "@/context/permission"
 import { usePlatform } from "@/context/platform"
 import { PromptProvider } from "@/context/prompt"
-import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
+import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
 import { SettingsProvider } from "@/context/settings"
 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"
+import { useCheckServerHealth } from "./utils/server-health"
 
-const Home = lazy(() => import("@/pages/home"))
+const HomeRoute = lazy(() => import("@/pages/home"))
 const Session = lazy(() => import("@/pages/session"))
 const Loading = () => <div class="size-full" />
 
-const HomeRoute = () => (
-  <Suspense fallback={<Loading />}>
-    <Home />
-  </Suspense>
-)
-
 const SessionRoute = () => (
   <SessionProviders>
-    <Suspense fallback={<Loading />}>
-      <Session />
-    </Suspense>
+    <Session />
   </SessionProviders>
 )
 
@@ -52,7 +60,7 @@ const SessionIndexRoute = () => <Navigate href="session" />
 
 function UiI18nBridge(props: ParentProps) {
   const language = useLanguage()
-  return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
+  return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
 }
 
 declare global {
@@ -62,6 +70,9 @@ declare global {
       deepLinks?: string[]
       wsl?: boolean
     }
+    api?: {
+      setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
+    }
   }
 }
 
@@ -105,8 +116,10 @@ function SessionProviders(props: ParentProps) {
 function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
   return (
     <AppShellProviders>
-      {props.appChildren}
-      {props.children}
+      <Suspense fallback={<Loading />}>
+        {props.appChildren}
+        {props.children}
+      </Suspense>
     </AppShellProviders>
   )
 }
@@ -115,7 +128,11 @@ export function AppBaseProviders(props: ParentProps) {
   return (
     <MetaProvider>
       <Font />
-      <ThemeProvider>
+      <ThemeProvider
+        onThemeApplied={(_, mode) => {
+          void window.api?.setTitlebar?.({ mode })
+        }}
+      >
         <LanguageProvider>
           <UiI18nBridge>
             <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
@@ -132,6 +149,116 @@ export function AppBaseProviders(props: ParentProps) {
   )
 }
 
+const effectMinDuration =
+  (duration: Duration.Input) =>
+  <A, E, R>(e: Effect.Effect<A, E, R>) =>
+    Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
+
+function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
+  const server = useServer()
+  const checkServerHealth = useCheckServerHealth()
+
+  const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
+
+  // performs repeated health check with a grace period for
+  // non-http connections, otherwise fails instantly
+  const [startupHealthCheck, healthCheckActions] = createResource(() =>
+    props.disableHealthCheck
+      ? true
+      : Effect.gen(function* () {
+          if (!server.current) return true
+          const { http, type } = server.current
+
+          while (true) {
+            const res = yield* Effect.promise(() => checkServerHealth(http))
+            if (res.healthy) return true
+            if (checkMode() === "background" || type === "http") return false
+          }
+        }).pipe(
+          effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
+          Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
+          Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
+          Effect.runPromise,
+        ),
+  )
+
+  return (
+    <Show
+      when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
+      fallback={
+        <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
+          <Splash class="w-16 h-20 opacity-50 animate-pulse" />
+        </div>
+      }
+    >
+      <Show
+        when={startupHealthCheck()}
+        fallback={
+          <ConnectionError
+            onRetry={() => {
+              if (checkMode() === "background") healthCheckActions.refetch()
+            }}
+            onServerSelected={(key) => {
+              setCheckMode("blocking")
+              server.setActive(key)
+              healthCheckActions.refetch()
+            }}
+          />
+        }
+      >
+        {props.children}
+      </Show>
+    </Show>
+  )
+}
+
+function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
+  const language = useLanguage()
+  const server = useServer()
+  const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
+  const name = createMemo(() => server.name || server.key)
+  const serverToken = "\u0000server\u0000"
+  const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken))
+
+  const timer = setInterval(() => props.onRetry?.(), 1000)
+  onCleanup(() => clearInterval(timer))
+
+  return (
+    <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
+      <div class="flex flex-col items-center max-w-md text-center">
+        <Splash class="w-12 h-15 mb-4" />
+        <p class="text-14-regular text-text-base">
+          {unreachable()[0]}
+          <span class="text-text-strong font-medium">{name()}</span>
+          {unreachable()[1]}
+        </p>
+        <p class="mt-1 text-12-regular text-text-weak">{language.t("app.server.retrying")}</p>
+      </div>
+      <Show when={others().length > 0}>
+        <div class="flex flex-col gap-2 w-full max-w-sm">
+          <span class="text-12-regular text-text-base text-center">{language.t("app.server.otherServers")}</span>
+          <div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
+            <For each={others()}>
+              {(conn) => {
+                const key = ServerConnection.key(conn)
+                return (
+                  <button
+                    type="button"
+                    class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
+                    onClick={() => props.onServerSelected?.(key)}
+                  >
+                    <span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
+                  </button>
+                )
+              }}
+            </For>
+          </div>
+        </div>
+      </Show>
+    </div>
+  )
+}
+
 function ServerKey(props: ParentProps) {
   const server = useServer()
   return (
@@ -146,25 +273,28 @@ export function AppInterface(props: {
   defaultServer: ServerConnection.Key
   servers?: Array<ServerConnection.Any>
   router?: Component<BaseRouterProps>
+  disableHealthCheck?: boolean
 }) {
   return (
     <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
-      <ServerKey>
-        <GlobalSDKProvider>
-          <GlobalSyncProvider>
-            <Dynamic
-              component={props.router ?? Router}
-              root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
-            >
-              <Route path="/" component={HomeRoute} />
-              <Route path="/:dir" component={DirectoryLayout}>
-                <Route path="/" component={SessionIndexRoute} />
-                <Route path="/session/:id?" component={SessionRoute} />
-              </Route>
-            </Dynamic>
-          </GlobalSyncProvider>
-        </GlobalSDKProvider>
-      </ServerKey>
+      <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
+        <ServerKey>
+          <GlobalSDKProvider>
+            <GlobalSyncProvider>
+              <Dynamic
+                component={props.router ?? Router}
+                root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
+              >
+                <Route path="/" component={HomeRoute} />
+                <Route path="/:dir" component={DirectoryLayout}>
+                  <Route path="/" component={SessionIndexRoute} />
+                  <Route path="/session/:id?" component={SessionRoute} />
+                </Route>
+              </Dynamic>
+            </GlobalSyncProvider>
+          </GlobalSDKProvider>
+        </ServerKey>
+      </ConnectionGate>
     </ServerProvider>
   )
 }

+ 38 - 32
packages/app/src/components/debug-bar.tsx

@@ -2,6 +2,7 @@ import { useIsRouting, useLocation } from "@solidjs/router"
 import { batch, createEffect, onCleanup, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { useLanguage } from "@/context/language"
 
 type Mem = Performance & {
   memory?: {
@@ -27,17 +28,17 @@ type Obs = PerformanceObserverInit & {
 const span = 5000
 
 const ms = (n?: number, d = 0) => {
-  if (n === undefined || Number.isNaN(n)) return "n/a"
+  if (n === undefined || Number.isNaN(n)) return
   return `${n.toFixed(d)}ms`
 }
 
 const time = (n?: number) => {
-  if (n === undefined || Number.isNaN(n)) return "n/a"
+  if (n === undefined || Number.isNaN(n)) return
   return `${Math.round(n)}`
 }
 
 const mb = (n?: number) => {
-  if (n === undefined || Number.isNaN(n)) return "n/a"
+  if (n === undefined || Number.isNaN(n)) return
   const v = n / 1024 / 1024
   return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
 }
@@ -74,6 +75,7 @@ function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string;
 }
 
 export function DebugBar() {
+  const language = useLanguage()
   const location = useLocation()
   const routing = useIsRouting()
   const [state, setState] = createStore({
@@ -98,14 +100,15 @@ export function DebugBar() {
     },
   })
 
+  const na = () => language.t("debugBar.na")
   const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
   const heapv = () => {
     const value = heap()
-    if (value === undefined) return "n/a"
+    if (value === undefined) return na()
     return `${Math.round(value * 100)}%`
   }
-  const longv = () => (state.long.count === undefined ? "n/a" : `${time(state.long.block)}/${state.long.count}`)
-  const navv = () => (state.nav.pending ? "..." : time(state.nav.dur))
+  const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
+  const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
 
   let prev = ""
   let start = 0
@@ -359,7 +362,7 @@ export function DebugBar() {
 
   return (
     <aside
-      aria-label="Development performance diagnostics"
+      aria-label={language.t("debugBar.ariaLabel")}
       class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
       style={{
         "background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)",
@@ -368,67 +371,70 @@ export function DebugBar() {
     >
       <div class="grid grid-cols-5 gap-px font-mono">
         <Cell
-          label="NAV"
-          tip="Last completed route transition touching a session page, measured from router start until the first paint after it settles."
+          label={language.t("debugBar.nav.label")}
+          tip={language.t("debugBar.nav.tip")}
           value={navv()}
           bad={bad(state.nav.dur, 400)}
           dim={state.nav.dur === undefined && !state.nav.pending}
         />
         <Cell
-          label="FPS"
-          tip="Rolling frames per second over the last 5 seconds."
-          value={state.fps === undefined ? "n/a" : `${Math.round(state.fps)}`}
+          label={language.t("debugBar.fps.label")}
+          tip={language.t("debugBar.fps.tip")}
+          value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
           bad={bad(state.fps, 50, true)}
           dim={state.fps === undefined}
         />
         <Cell
-          label="FRM"
-          tip="Worst frame time over the last 5 seconds."
-          value={time(state.gap)}
+          label={language.t("debugBar.frame.label")}
+          tip={language.t("debugBar.frame.tip")}
+          value={time(state.gap) ?? na()}
           bad={bad(state.gap, 50)}
           dim={state.gap === undefined}
         />
         <Cell
-          label="JNK"
-          tip="Frames over 32ms in the last 5 seconds."
-          value={state.jank === undefined ? "n/a" : `${state.jank}`}
+          label={language.t("debugBar.jank.label")}
+          tip={language.t("debugBar.jank.tip")}
+          value={state.jank === undefined ? na() : `${state.jank}`}
           bad={bad(state.jank, 8)}
           dim={state.jank === undefined}
         />
         <Cell
-          label="LNG"
-          tip={`Blocked time and long-task count in the last 5 seconds. Max task: ${ms(state.long.max)}.`}
+          label={language.t("debugBar.long.label")}
+          tip={language.t("debugBar.long.tip", { max: ms(state.long.max) ?? na() })}
           value={longv()}
           bad={bad(state.long.block, 200)}
           dim={state.long.count === undefined}
         />
         <Cell
-          label="DLY"
-          tip="Worst observed input delay in the last 5 seconds."
-          value={time(state.delay)}
+          label={language.t("debugBar.delay.label")}
+          tip={language.t("debugBar.delay.tip")}
+          value={time(state.delay) ?? na()}
           bad={bad(state.delay, 100)}
           dim={state.delay === undefined}
         />
         <Cell
-          label="INP"
-          tip="Approximate interaction duration over the last 5 seconds. This is INP-like, not the official Web Vitals INP."
-          value={time(state.inp)}
+          label={language.t("debugBar.inp.label")}
+          tip={language.t("debugBar.inp.tip")}
+          value={time(state.inp) ?? na()}
           bad={bad(state.inp, 200)}
           dim={state.inp === undefined}
         />
         <Cell
-          label="CLS"
-          tip="Cumulative layout shift for the current app lifetime."
-          value={state.cls === undefined ? "n/a" : state.cls.toFixed(2)}
+          label={language.t("debugBar.cls.label")}
+          tip={language.t("debugBar.cls.tip")}
+          value={state.cls === undefined ? na() : state.cls.toFixed(2)}
           bad={bad(state.cls, 0.1)}
           dim={state.cls === undefined}
         />
         <Cell
-          label="MEM"
+          label={language.t("debugBar.mem.label")}
           tip={
             state.heap.used === undefined
-              ? "Used JS heap vs heap limit. Chromium only."
-              : `Used JS heap vs heap limit. ${mb(state.heap.used)} of ${mb(state.heap.limit)}.`
+              ? language.t("debugBar.mem.tipUnavailable")
+              : language.t("debugBar.mem.tip", {
+                  used: mb(state.heap.used) ?? na(),
+                  limit: mb(state.heap.limit) ?? na(),
+                })
           }
           value={heapv()}
           bad={bad(heap(), 0.8)}

+ 134 - 16
packages/app/src/components/dialog-connect-provider.tsx

@@ -15,7 +15,6 @@ import { Link } from "@/components/link"
 import { useLanguage } from "@/context/language"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { useGlobalSync } from "@/context/global-sync"
-import { usePlatform } from "@/context/platform"
 import { DialogSelectModel } from "./dialog-select-model"
 import { DialogSelectProvider } from "./dialog-select-provider"
 
@@ -23,7 +22,6 @@ export function DialogConnectProvider(props: { provider: string }) {
   const dialog = useDialog()
   const globalSync = useGlobalSync()
   const globalSDK = useGlobalSDK()
-  const platform = usePlatform()
   const language = useLanguage()
 
   const alive = { value: true }
@@ -49,13 +47,14 @@ export function DialogConnectProvider(props: { provider: string }) {
   const [store, setStore] = createStore({
     methodIndex: undefined as undefined | number,
     authorization: undefined as undefined | ProviderAuthAuthorization,
-    state: "pending" as undefined | "pending" | "complete" | "error",
+    state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
     error: undefined as string | undefined,
   })
 
   type Action =
     | { type: "method.select"; index: number }
     | { type: "method.reset" }
+    | { type: "auth.prompt" }
     | { type: "auth.pending" }
     | { type: "auth.complete"; authorization: ProviderAuthAuthorization }
     | { type: "auth.error"; error: string }
@@ -77,6 +76,11 @@ export function DialogConnectProvider(props: { provider: string }) {
           draft.error = undefined
           return
         }
+        if (action.type === "auth.prompt") {
+          draft.state = "prompt"
+          draft.error = undefined
+          return
+        }
         if (action.type === "auth.pending") {
           draft.state = "pending"
           draft.error = undefined
@@ -120,7 +124,7 @@ export function DialogConnectProvider(props: { provider: string }) {
     return fallback
   }
 
-  async function selectMethod(index: number) {
+  async function selectMethod(index: number, inputs?: Record<string, string>) {
     if (timer.current !== undefined) {
       clearTimeout(timer.current)
       timer.current = undefined
@@ -130,6 +134,10 @@ export function DialogConnectProvider(props: { provider: string }) {
     dispatch({ type: "method.select", index })
 
     if (method.type === "oauth") {
+      if (method.prompts?.length && !inputs) {
+        dispatch({ type: "auth.prompt" })
+        return
+      }
       dispatch({ type: "auth.pending" })
       const start = Date.now()
       await globalSDK.client.provider.oauth
@@ -137,6 +145,7 @@ export function DialogConnectProvider(props: { provider: string }) {
           {
             providerID: props.provider,
             method: index,
+            inputs,
           },
           { throwOnError: true },
         )
@@ -163,6 +172,122 @@ export function DialogConnectProvider(props: { provider: string }) {
     }
   }
 
+  function OAuthPromptsView() {
+    const [formStore, setFormStore] = createStore({
+      value: {} as Record<string, string>,
+      index: 0,
+    })
+
+    const prompts = createMemo(() => method()?.prompts ?? [])
+    const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
+      if (!prompt.when) return true
+      const actual = value[prompt.when.key]
+      if (actual === undefined) return false
+      return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value
+    }
+    const current = createMemo(() => {
+      const all = prompts()
+      const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
+      if (index === -1) return
+      return {
+        index,
+        prompt: all[index],
+      }
+    })
+    const valid = createMemo(() => {
+      const item = current()
+      if (!item || item.prompt.type !== "text") return false
+      const value = formStore.value[item.prompt.key] ?? ""
+      return value.trim().length > 0
+    })
+
+    async function next(index: number, value: Record<string, string>) {
+      if (store.methodIndex === undefined) return
+      const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
+      if (next !== -1) {
+        setFormStore("index", next)
+        return
+      }
+      await selectMethod(store.methodIndex, value)
+    }
+
+    async function handleSubmit(e: SubmitEvent) {
+      e.preventDefault()
+      const item = current()
+      if (!item || item.prompt.type !== "text") return
+      if (!valid()) return
+      await next(item.index, formStore.value)
+    }
+
+    const item = () => current()
+    const text = createMemo(() => {
+      const prompt = item()?.prompt
+      if (!prompt || prompt.type !== "text") return
+      return prompt
+    })
+    const select = createMemo(() => {
+      const prompt = item()?.prompt
+      if (!prompt || prompt.type !== "select") return
+      return prompt
+    })
+
+    return (
+      <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
+        <Switch>
+          <Match when={item()?.prompt.type === "text"}>
+            <TextField
+              type="text"
+              label={text()?.message ?? ""}
+              placeholder={text()?.placeholder}
+              value={text() ? (formStore.value[text()!.key] ?? "") : ""}
+              onChange={(value) => {
+                const prompt = text()
+                if (!prompt) return
+                setFormStore("value", prompt.key, value)
+              }}
+            />
+            <Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}>
+              {language.t("common.continue")}
+            </Button>
+          </Match>
+          <Match when={item()?.prompt.type === "select"}>
+            <div class="w-full flex flex-col gap-1.5">
+              <div class="text-14-regular text-text-base">{select()?.message}</div>
+              <div>
+                <List
+                  items={select()?.options ?? []}
+                  key={(x) => x.value}
+                  current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
+                  onSelect={(value) => {
+                    if (!value) return
+                    const prompt = select()
+                    if (!prompt) return
+                    const nextValue = {
+                      ...formStore.value,
+                      [prompt.key]: value.value,
+                    }
+                    setFormStore("value", prompt.key, value.value)
+                    void next(item()!.index, nextValue)
+                  }}
+                >
+                  {(option) => (
+                    <div class="w-full flex items-center gap-x-2">
+                      <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
+                        <div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
+                      </div>
+                      <span>{option.label}</span>
+                      <span class="text-14-regular text-text-weak">{option.hint}</span>
+                    </div>
+                  )}
+                </List>
+              </div>
+            </div>
+          </Match>
+        </Switch>
+      </form>
+    )
+  }
+
   let listRef: ListRef | undefined
   function handleKey(e: KeyboardEvent) {
     if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
@@ -301,7 +426,7 @@ export function DialogConnectProvider(props: { provider: string }) {
             error={formStore.error}
           />
           <Button class="w-auto" type="submit" size="large" variant="primary">
-            {language.t("common.submit")}
+            {language.t("common.continue")}
           </Button>
         </form>
       </div>
@@ -314,12 +439,6 @@ export function DialogConnectProvider(props: { provider: string }) {
       error: undefined as string | undefined,
     })
 
-    onMount(() => {
-      if (store.authorization?.method === "code" && store.authorization?.url) {
-        platform.openLink(store.authorization.url)
-      }
-    })
-
     async function handleSubmit(e: SubmitEvent) {
       e.preventDefault()
 
@@ -368,7 +487,7 @@ export function DialogConnectProvider(props: { provider: string }) {
             error={formStore.error}
           />
           <Button class="w-auto" type="submit" size="large" variant="primary">
-            {language.t("common.submit")}
+            {language.t("common.continue")}
           </Button>
         </form>
       </div>
@@ -386,10 +505,6 @@ export function DialogConnectProvider(props: { provider: string }) {
 
     onMount(() => {
       void (async () => {
-        if (store.authorization?.url) {
-          platform.openLink(store.authorization.url)
-        }
-
         const result = await globalSDK.client.provider.oauth
           .callback({
             providerID: props.provider,
@@ -470,6 +585,9 @@ export function DialogConnectProvider(props: { provider: string }) {
                   </div>
                 </div>
               </Match>
+              <Match when={store.state === "prompt"}>
+                <OAuthPromptsView />
+              </Match>
               <Match when={store.state === "error"}>
                 <div class="text-14-regular text-text-base">
                   <div class="flex items-center gap-x-2">

+ 159 - 0
packages/app/src/components/dialog-custom-provider-form.ts

@@ -0,0 +1,159 @@
+const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
+const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
+
+type Translator = (key: string, vars?: Record<string, string | number | boolean>) => string
+
+export type ModelErr = {
+  id?: string
+  name?: string
+}
+
+export type HeaderErr = {
+  key?: string
+  value?: string
+}
+
+export type ModelRow = {
+  row: string
+  id: string
+  name: string
+  err: ModelErr
+}
+
+export type HeaderRow = {
+  row: string
+  key: string
+  value: string
+  err: HeaderErr
+}
+
+export type FormState = {
+  providerID: string
+  name: string
+  baseURL: string
+  apiKey: string
+  models: ModelRow[]
+  headers: HeaderRow[]
+  saving: boolean
+  err: {
+    providerID?: string
+    name?: string
+    baseURL?: string
+  }
+}
+
+type ValidateArgs = {
+  form: FormState
+  t: Translator
+  disabledProviders: string[]
+  existingProviderIDs: Set<string>
+}
+
+export function validateCustomProvider(input: ValidateArgs) {
+  const providerID = input.form.providerID.trim()
+  const name = input.form.name.trim()
+  const baseURL = input.form.baseURL.trim()
+  const apiKey = input.form.apiKey.trim()
+
+  const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
+  const key = apiKey && !env ? apiKey : undefined
+
+  const idError = !providerID
+    ? input.t("provider.custom.error.providerID.required")
+    : !PROVIDER_ID.test(providerID)
+      ? input.t("provider.custom.error.providerID.format")
+      : undefined
+
+  const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
+  const urlError = !baseURL
+    ? input.t("provider.custom.error.baseURL.required")
+    : !/^https?:\/\//.test(baseURL)
+      ? input.t("provider.custom.error.baseURL.format")
+      : undefined
+
+  const disabled = input.disabledProviders.includes(providerID)
+  const existsError = idError
+    ? undefined
+    : input.existingProviderIDs.has(providerID) && !disabled
+      ? input.t("provider.custom.error.providerID.exists")
+      : undefined
+
+  const seenModels = new Set<string>()
+  const models = input.form.models.map((m) => {
+    const id = m.id.trim()
+    const idError = !id
+      ? input.t("provider.custom.error.required")
+      : seenModels.has(id)
+        ? input.t("provider.custom.error.duplicate")
+        : (() => {
+            seenModels.add(id)
+            return undefined
+          })()
+    const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
+    return { id: idError, name: nameError }
+  })
+  const modelsValid = models.every((m) => !m.id && !m.name)
+  const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
+
+  const seenHeaders = new Set<string>()
+  const headers = input.form.headers.map((h) => {
+    const key = h.key.trim()
+    const value = h.value.trim()
+
+    if (!key && !value) return {}
+    const keyError = !key
+      ? input.t("provider.custom.error.required")
+      : seenHeaders.has(key.toLowerCase())
+        ? input.t("provider.custom.error.duplicate")
+        : (() => {
+            seenHeaders.add(key.toLowerCase())
+            return undefined
+          })()
+    const valueError = !value ? input.t("provider.custom.error.required") : undefined
+    return { key: keyError, value: valueError }
+  })
+  const headersValid = headers.every((h) => !h.key && !h.value)
+  const headerConfig = Object.fromEntries(
+    input.form.headers
+      .map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
+      .filter((h) => !!h.key && !!h.value)
+      .map((h) => [h.key, h.value]),
+  )
+
+  const err = {
+    providerID: idError ?? existsError,
+    name: nameError,
+    baseURL: urlError,
+  }
+
+  const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
+  if (!ok) return { err, models, headers }
+
+  return {
+    err,
+    models,
+    headers,
+    result: {
+      providerID,
+      name,
+      key,
+      config: {
+        npm: OPENAI_COMPATIBLE,
+        name,
+        ...(env ? { env: [env] } : {}),
+        options: {
+          baseURL,
+          ...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
+        },
+        models: modelConfig,
+      },
+    },
+  }
+}
+
+let row = 0
+
+const nextRow = () => `row-${row++}`
+
+export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} })
+export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} })

+ 82 - 0
packages/app/src/components/dialog-custom-provider.test.ts

@@ -0,0 +1,82 @@
+import { describe, expect, test } from "bun:test"
+import { validateCustomProvider } from "./dialog-custom-provider-form"
+
+const t = (key: string) => key
+
+describe("validateCustomProvider", () => {
+  test("builds trimmed config payload", () => {
+    const result = validateCustomProvider({
+      form: {
+        providerID: "custom-provider",
+        name: " Custom Provider ",
+        baseURL: "https://api.example.com ",
+        apiKey: " {env: CUSTOM_PROVIDER_KEY} ",
+        models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }],
+        headers: [
+          { row: "h0", key: " X-Test ", value: " enabled ", err: {} },
+          { row: "h1", key: "", value: "", err: {} },
+        ],
+        saving: false,
+        err: {},
+      },
+      t,
+      disabledProviders: [],
+      existingProviderIDs: new Set(),
+    })
+
+    expect(result.result).toEqual({
+      providerID: "custom-provider",
+      name: "Custom Provider",
+      key: undefined,
+      config: {
+        npm: "@ai-sdk/openai-compatible",
+        name: "Custom Provider",
+        env: ["CUSTOM_PROVIDER_KEY"],
+        options: {
+          baseURL: "https://api.example.com",
+          headers: {
+            "X-Test": "enabled",
+          },
+        },
+        models: {
+          "model-a": { name: "Model A" },
+        },
+      },
+    })
+  })
+
+  test("flags duplicate rows and allows reconnecting disabled providers", () => {
+    const result = validateCustomProvider({
+      form: {
+        providerID: "custom-provider",
+        name: "Provider",
+        baseURL: "https://api.example.com",
+        apiKey: "secret",
+        models: [
+          { row: "m0", id: "model-a", name: "Model A", err: {} },
+          { row: "m1", id: "model-a", name: "Model A 2", err: {} },
+        ],
+        headers: [
+          { row: "h0", key: "Authorization", value: "one", err: {} },
+          { row: "h1", key: "authorization", value: "two", err: {} },
+        ],
+        saving: false,
+        err: {},
+      },
+      t,
+      disabledProviders: ["custom-provider"],
+      existingProviderIDs: new Set(["custom-provider"]),
+    })
+
+    expect(result.result).toBeUndefined()
+    expect(result.err.providerID).toBeUndefined()
+    expect(result.models[1]).toEqual({
+      id: "provider.custom.error.duplicate",
+      name: undefined,
+    })
+    expect(result.headers[1]).toEqual({
+      key: "provider.custom.error.duplicate",
+      value: undefined,
+    })
+  })
+})

+ 79 - 189
packages/app/src/components/dialog-custom-provider.tsx

@@ -5,158 +5,15 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast } from "@opencode-ai/ui/toast"
-import { For } from "solid-js"
-import { createStore } from "solid-js/store"
+import { batch, For } from "solid-js"
+import { createStore, produce } from "solid-js/store"
 import { Link } from "@/components/link"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { useGlobalSync } from "@/context/global-sync"
 import { useLanguage } from "@/context/language"
+import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
 import { DialogSelectProvider } from "./dialog-select-provider"
 
-const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
-const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
-
-type Translator = ReturnType<typeof useLanguage>["t"]
-
-type ModelRow = {
-  id: string
-  name: string
-}
-
-type HeaderRow = {
-  key: string
-  value: string
-}
-
-type FormState = {
-  providerID: string
-  name: string
-  baseURL: string
-  apiKey: string
-  models: ModelRow[]
-  headers: HeaderRow[]
-  saving: boolean
-}
-
-type FormErrors = {
-  providerID: string | undefined
-  name: string | undefined
-  baseURL: string | undefined
-  models: Array<{ id?: string; name?: string }>
-  headers: Array<{ key?: string; value?: string }>
-}
-
-type ValidateArgs = {
-  form: FormState
-  t: Translator
-  disabledProviders: string[]
-  existingProviderIDs: Set<string>
-}
-
-function validateCustomProvider(input: ValidateArgs) {
-  const providerID = input.form.providerID.trim()
-  const name = input.form.name.trim()
-  const baseURL = input.form.baseURL.trim()
-  const apiKey = input.form.apiKey.trim()
-
-  const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
-  const key = apiKey && !env ? apiKey : undefined
-
-  const idError = !providerID
-    ? input.t("provider.custom.error.providerID.required")
-    : !PROVIDER_ID.test(providerID)
-      ? input.t("provider.custom.error.providerID.format")
-      : undefined
-
-  const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
-  const urlError = !baseURL
-    ? input.t("provider.custom.error.baseURL.required")
-    : !/^https?:\/\//.test(baseURL)
-      ? input.t("provider.custom.error.baseURL.format")
-      : undefined
-
-  const disabled = input.disabledProviders.includes(providerID)
-  const existsError = idError
-    ? undefined
-    : input.existingProviderIDs.has(providerID) && !disabled
-      ? input.t("provider.custom.error.providerID.exists")
-      : undefined
-
-  const seenModels = new Set<string>()
-  const modelErrors = input.form.models.map((m) => {
-    const id = m.id.trim()
-    const modelIdError = !id
-      ? input.t("provider.custom.error.required")
-      : seenModels.has(id)
-        ? input.t("provider.custom.error.duplicate")
-        : (() => {
-            seenModels.add(id)
-            return undefined
-          })()
-    const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
-    return { id: modelIdError, name: modelNameError }
-  })
-  const modelsValid = modelErrors.every((m) => !m.id && !m.name)
-  const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
-
-  const seenHeaders = new Set<string>()
-  const headerErrors = input.form.headers.map((h) => {
-    const key = h.key.trim()
-    const value = h.value.trim()
-
-    if (!key && !value) return {}
-    const keyError = !key
-      ? input.t("provider.custom.error.required")
-      : seenHeaders.has(key.toLowerCase())
-        ? input.t("provider.custom.error.duplicate")
-        : (() => {
-            seenHeaders.add(key.toLowerCase())
-            return undefined
-          })()
-    const valueError = !value ? input.t("provider.custom.error.required") : undefined
-    return { key: keyError, value: valueError }
-  })
-  const headersValid = headerErrors.every((h) => !h.key && !h.value)
-  const headers = Object.fromEntries(
-    input.form.headers
-      .map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
-      .filter((h) => !!h.key && !!h.value)
-      .map((h) => [h.key, h.value]),
-  )
-
-  const errors: FormErrors = {
-    providerID: idError ?? existsError,
-    name: nameError,
-    baseURL: urlError,
-    models: modelErrors,
-    headers: headerErrors,
-  }
-
-  const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
-  if (!ok) return { errors }
-
-  const options = {
-    baseURL,
-    ...(Object.keys(headers).length ? { headers } : {}),
-  }
-
-  return {
-    errors,
-    result: {
-      providerID,
-      name,
-      key,
-      config: {
-        npm: OPENAI_COMPATIBLE,
-        name,
-        ...(env ? { env: [env] } : {}),
-        options,
-        models,
-      },
-    },
-  }
-}
-
 type Props = {
   back?: "providers" | "close"
 }
@@ -172,17 +29,10 @@ export function DialogCustomProvider(props: Props) {
     name: "",
     baseURL: "",
     apiKey: "",
-    models: [{ id: "", name: "" }],
-    headers: [{ key: "", value: "" }],
+    models: [modelRow()],
+    headers: [headerRow()],
     saving: false,
-  })
-
-  const [errors, setErrors] = createStore<FormErrors>({
-    providerID: undefined,
-    name: undefined,
-    baseURL: undefined,
-    models: [{}],
-    headers: [{}],
+    err: {},
   })
 
   const goBack = () => {
@@ -194,25 +44,61 @@ export function DialogCustomProvider(props: Props) {
   }
 
   const addModel = () => {
-    setForm("models", (v) => [...v, { id: "", name: "" }])
-    setErrors("models", (v) => [...v, {}])
+    setForm(
+      "models",
+      produce((rows) => {
+        rows.push(modelRow())
+      }),
+    )
   }
 
   const removeModel = (index: number) => {
     if (form.models.length <= 1) return
-    setForm("models", (v) => v.filter((_, i) => i !== index))
-    setErrors("models", (v) => v.filter((_, i) => i !== index))
+    setForm(
+      "models",
+      produce((rows) => {
+        rows.splice(index, 1)
+      }),
+    )
   }
 
   const addHeader = () => {
-    setForm("headers", (v) => [...v, { key: "", value: "" }])
-    setErrors("headers", (v) => [...v, {}])
+    setForm(
+      "headers",
+      produce((rows) => {
+        rows.push(headerRow())
+      }),
+    )
   }
 
   const removeHeader = (index: number) => {
     if (form.headers.length <= 1) return
-    setForm("headers", (v) => v.filter((_, i) => i !== index))
-    setErrors("headers", (v) => v.filter((_, i) => i !== index))
+    setForm(
+      "headers",
+      produce((rows) => {
+        rows.splice(index, 1)
+      }),
+    )
+  }
+
+  const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => {
+    setForm(key, value)
+    if (key === "apiKey") return
+    setForm("err", key, undefined)
+  }
+
+  const setModel = (index: number, key: "id" | "name", value: string) => {
+    batch(() => {
+      setForm("models", index, key, value)
+      setForm("models", index, "err", key, undefined)
+    })
+  }
+
+  const setHeader = (index: number, key: "key" | "value", value: string) => {
+    batch(() => {
+      setForm("headers", index, key, value)
+      setForm("headers", index, "err", key, undefined)
+    })
   }
 
   const validate = () => {
@@ -222,7 +108,11 @@ export function DialogCustomProvider(props: Props) {
       disabledProviders: globalSync.data.config.disabled_providers ?? [],
       existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
     })
-    setErrors(output.errors)
+    batch(() => {
+      setForm("err", output.err)
+      output.models.forEach((err, index) => setForm("models", index, "err", err))
+      output.headers.forEach((err, index) => setForm("headers", index, "err", err))
+    })
     return output.result
   }
 
@@ -305,32 +195,32 @@ export function DialogCustomProvider(props: Props) {
               placeholder={language.t("provider.custom.field.providerID.placeholder")}
               description={language.t("provider.custom.field.providerID.description")}
               value={form.providerID}
-              onChange={(v) => setForm("providerID", v)}
-              validationState={errors.providerID ? "invalid" : undefined}
-              error={errors.providerID}
+              onChange={(v) => setField("providerID", v)}
+              validationState={form.err.providerID ? "invalid" : undefined}
+              error={form.err.providerID}
             />
             <TextField
               label={language.t("provider.custom.field.name.label")}
               placeholder={language.t("provider.custom.field.name.placeholder")}
               value={form.name}
-              onChange={(v) => setForm("name", v)}
-              validationState={errors.name ? "invalid" : undefined}
-              error={errors.name}
+              onChange={(v) => setField("name", v)}
+              validationState={form.err.name ? "invalid" : undefined}
+              error={form.err.name}
             />
             <TextField
               label={language.t("provider.custom.field.baseURL.label")}
               placeholder={language.t("provider.custom.field.baseURL.placeholder")}
               value={form.baseURL}
-              onChange={(v) => setForm("baseURL", v)}
-              validationState={errors.baseURL ? "invalid" : undefined}
-              error={errors.baseURL}
+              onChange={(v) => setField("baseURL", v)}
+              validationState={form.err.baseURL ? "invalid" : undefined}
+              error={form.err.baseURL}
             />
             <TextField
               label={language.t("provider.custom.field.apiKey.label")}
               placeholder={language.t("provider.custom.field.apiKey.placeholder")}
               description={language.t("provider.custom.field.apiKey.description")}
               value={form.apiKey}
-              onChange={(v) => setForm("apiKey", v)}
+              onChange={(v) => setField("apiKey", v)}
             />
           </div>
 
@@ -338,16 +228,16 @@ export function DialogCustomProvider(props: Props) {
             <label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
             <For each={form.models}>
               {(m, i) => (
-                <div class="flex gap-2 items-start">
+                <div class="flex gap-2 items-start" data-row={m.row}>
                   <div class="flex-1">
                     <TextField
                       label={language.t("provider.custom.models.id.label")}
                       hideLabel
                       placeholder={language.t("provider.custom.models.id.placeholder")}
                       value={m.id}
-                      onChange={(v) => setForm("models", i(), "id", v)}
-                      validationState={errors.models[i()]?.id ? "invalid" : undefined}
-                      error={errors.models[i()]?.id}
+                      onChange={(v) => setModel(i(), "id", v)}
+                      validationState={m.err.id ? "invalid" : undefined}
+                      error={m.err.id}
                     />
                   </div>
                   <div class="flex-1">
@@ -356,9 +246,9 @@ export function DialogCustomProvider(props: Props) {
                       hideLabel
                       placeholder={language.t("provider.custom.models.name.placeholder")}
                       value={m.name}
-                      onChange={(v) => setForm("models", i(), "name", v)}
-                      validationState={errors.models[i()]?.name ? "invalid" : undefined}
-                      error={errors.models[i()]?.name}
+                      onChange={(v) => setModel(i(), "name", v)}
+                      validationState={m.err.name ? "invalid" : undefined}
+                      error={m.err.name}
                     />
                   </div>
                   <IconButton
@@ -382,16 +272,16 @@ export function DialogCustomProvider(props: Props) {
             <label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
             <For each={form.headers}>
               {(h, i) => (
-                <div class="flex gap-2 items-start">
+                <div class="flex gap-2 items-start" data-row={h.row}>
                   <div class="flex-1">
                     <TextField
                       label={language.t("provider.custom.headers.key.label")}
                       hideLabel
                       placeholder={language.t("provider.custom.headers.key.placeholder")}
                       value={h.key}
-                      onChange={(v) => setForm("headers", i(), "key", v)}
-                      validationState={errors.headers[i()]?.key ? "invalid" : undefined}
-                      error={errors.headers[i()]?.key}
+                      onChange={(v) => setHeader(i(), "key", v)}
+                      validationState={h.err.key ? "invalid" : undefined}
+                      error={h.err.key}
                     />
                   </div>
                   <div class="flex-1">
@@ -400,9 +290,9 @@ export function DialogCustomProvider(props: Props) {
                       hideLabel
                       placeholder={language.t("provider.custom.headers.value.placeholder")}
                       value={h.value}
-                      onChange={(v) => setForm("headers", i(), "value", v)}
-                      validationState={errors.headers[i()]?.value ? "invalid" : undefined}
-                      error={errors.headers[i()]?.value}
+                      onChange={(v) => setHeader(i(), "value", v)}
+                      validationState={h.err.value ? "invalid" : undefined}
+                      error={h.err.value}
                     />
                   </div>
                   <IconButton

+ 3 - 4
packages/app/src/components/dialog-fork.tsx

@@ -66,6 +66,7 @@ export const DialogFork: Component = () => {
       directory: sdk.directory,
       attachmentName: language.t("common.attachment"),
     })
+    const dir = base64Encode(sdk.directory)
 
     sdk.client.session
       .fork({ sessionID, messageID: item.id })
@@ -75,10 +76,8 @@ export const DialogFork: Component = () => {
           return
         }
         dialog.close()
-        navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
-        requestAnimationFrame(() => {
-          prompt.set(restored)
-        })
+        prompt.set(restored, undefined, { dir, id: forked.data.id })
+        navigate(`/${dir}/session/${forked.data.id}`)
       })
       .catch((err: unknown) => {
         const message = err instanceof Error ? err.message : String(err)

+ 12 - 8
packages/app/src/components/dialog-select-file.tsx

@@ -6,7 +6,7 @@ import { Keybind } from "@opencode-ai/ui/keybind"
 import { List } from "@opencode-ai/ui/list"
 import { base64Encode } from "@opencode-ai/util/encode"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { useNavigate, useParams } from "@solidjs/router"
+import { useNavigate } from "@solidjs/router"
 import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
 import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
 import { useGlobalSDK } from "@/context/global-sdk"
@@ -14,6 +14,8 @@ import { useGlobalSync } from "@/context/global-sync"
 import { useLayout } from "@/context/layout"
 import { useFile } from "@/context/file"
 import { useLanguage } from "@/context/language"
+import { useSessionLayout } from "@/pages/session/session-layout"
+import { createSessionTabs } from "@/pages/session/helpers"
 import { decode64 } from "@/utils/base64"
 import { getRelativeTime } from "@/utils/time"
 
@@ -132,9 +134,14 @@ function createFileEntries(props: {
   tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
   language: ReturnType<typeof useLanguage>
 }) {
+  const tabState = createSessionTabs({
+    tabs: props.tabs,
+    pathFromTab: props.file.pathFromTab,
+    normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
+  })
   const recent = createMemo(() => {
-    const all = props.tabs().all()
-    const active = props.tabs().active()
+    const all = tabState.openedTabs()
+    const active = tabState.activeFileTab()
     const order = active ? [active, ...all.filter((item) => item !== active)] : all
     const seen = new Set<string>()
     const category = props.language.t("palette.group.files")
@@ -259,14 +266,11 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
   const layout = useLayout()
   const file = useFile()
   const dialog = useDialog()
-  const params = useParams()
   const navigate = useNavigate()
   const globalSDK = useGlobalSDK()
   const globalSync = useGlobalSync()
+  const { params, tabs, view } = useSessionLayout()
   const filesOnly = () => props.mode === "files"
-  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
-  const tabs = createMemo(() => layout.tabs(sessionKey))
-  const view = createMemo(() => layout.view(sessionKey))
   const state = { cleanup: undefined as (() => void) | void, committed: false }
   const [grouped, setGrouped] = createSignal(false)
   const commandEntries = createCommandEntries({ filesOnly, command, language })
@@ -422,7 +426,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
                   </Show>
                 </div>
                 <Show when={item.keybind}>
-                  <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
+                  <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", language.t)}</Keybind>
                 </Show>
               </div>
             </Match>

+ 7 - 5
packages/app/src/components/dialog-select-model-unpaid.tsx

@@ -13,8 +13,10 @@ import { DialogSelectProvider } from "./dialog-select-provider"
 import { ModelTooltip } from "./model-tooltip"
 import { useLanguage } from "@/context/language"
 
-export const DialogSelectModelUnpaid: Component = () => {
-  const local = useLocal()
+type ModelState = ReturnType<typeof useLocal>["model"]
+
+export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
+  const model = props.model ?? useLocal().model
   const dialog = useDialog()
   const providers = useProviders()
   const language = useLanguage()
@@ -35,8 +37,8 @@ export const DialogSelectModelUnpaid: Component = () => {
         <List
           class="[&_[data-slot=list-scroll]]:overflow-visible"
           ref={(ref) => (listRef = ref)}
-          items={local.model.list}
-          current={local.model.current()}
+          items={model.list}
+          current={model.current()}
           key={(x) => `${x.provider.id}:${x.id}`}
           itemWrapper={(item, node) => (
             <Tooltip
@@ -55,7 +57,7 @@ export const DialogSelectModelUnpaid: Component = () => {
             </Tooltip>
           )}
           onSelect={(x) => {
-            local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
+            model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
               recent: true,
             })
             dialog.close()

+ 12 - 7
packages/app/src/components/dialog-select-model.tsx

@@ -18,19 +18,22 @@ import { useLanguage } from "@/context/language"
 const isFree = (provider: string, cost: { input: number } | undefined) =>
   provider === "opencode" && (!cost || cost.input === 0)
 
+type ModelState = ReturnType<typeof useLocal>["model"]
+
 const ModelList: Component<{
   provider?: string
   class?: string
   onSelect: () => void
   action?: JSX.Element
+  model?: ModelState
 }> = (props) => {
-  const local = useLocal()
+  const model = props.model ?? useLocal().model
   const language = useLanguage()
 
   const models = createMemo(() =>
-    local.model
+    model
       .list()
-      .filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id }))
+      .filter((m) => model.visible({ modelID: m.id, providerID: m.provider.id }))
       .filter((m) => (props.provider ? m.provider.id === props.provider : true)),
   )
 
@@ -41,7 +44,7 @@ const ModelList: Component<{
       emptyMessage={language.t("dialog.model.empty")}
       key={(x) => `${x.provider.id}:${x.id}`}
       items={models}
-      current={local.model.current()}
+      current={model.current()}
       filterKeys={["provider.name", "name", "id"]}
       sortBy={(a, b) => a.name.localeCompare(b.name)}
       groupBy={(x) => x.provider.name}
@@ -63,7 +66,7 @@ const ModelList: Component<{
         </Tooltip>
       )}
       onSelect={(x) => {
-        local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
+        model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
           recent: true,
         })
         props.onSelect()
@@ -88,6 +91,7 @@ type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "a
 
 export function ModelSelectorPopover(props: {
   provider?: string
+  model?: ModelState
   children?: JSX.Element
   triggerAs?: ValidComponent
   triggerProps?: ModelSelectorTriggerProps
@@ -151,6 +155,7 @@ export function ModelSelectorPopover(props: {
           <Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
           <ModelList
             provider={props.provider}
+            model={props.model}
             onSelect={() => setStore("open", false)}
             class="p-1"
             action={
@@ -184,7 +189,7 @@ export function ModelSelectorPopover(props: {
   )
 }
 
-export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
+export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
   const dialog = useDialog()
   const language = useLanguage()
 
@@ -202,7 +207,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
         </Button>
       }
     >
-      <ModelList provider={props.provider} onSelect={() => dialog.close()} />
+      <ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
       <Button
         variant="ghost"
         class="ml-3 mt-5 mb-6 text-text-base self-start"

+ 40 - 34
packages/app/src/components/dialog-select-server.tsx

@@ -14,7 +14,9 @@ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
-import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
+import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
+
+const DEFAULT_USERNAME = "opencode"
 
 interface ServerFormProps {
   value: string
@@ -41,13 +43,15 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
   })
 }
 
-function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
-  const [defaultUrl, defaultUrlActions] = createResource(
+function useDefaultServer() {
+  const language = useLanguage()
+  const platform = usePlatform()
+  const [defaultKey, defaultUrlActions] = createResource(
     async () => {
       try {
-        const url = await platform.getDefaultServerUrl?.()
-        if (!url) return null
-        return normalizeServerUrl(url) ?? null
+        const key = await platform.getDefaultServer?.()
+        if (!key) return null
+        return key
       } catch (err) {
         showRequestError(language, err)
         return null
@@ -56,20 +60,22 @@ function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: Re
     { initialValue: null },
   )
 
-  const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
-  const setDefault = async (url: string | null) => {
+  const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
+  const setDefault = async (key: ServerConnection.Key | null) => {
     try {
-      await platform.setDefaultServerUrl?.(url)
-      defaultUrlActions.mutate(url)
+      await platform.setDefaultServer?.(key)
+      defaultUrlActions.mutate(key)
     } catch (err) {
       showRequestError(language, err)
     }
   }
 
-  return { defaultUrl, canDefault, setDefault }
+  return { defaultKey, canDefault, setDefault }
 }
 
-function useServerPreview(fetcher: typeof fetch) {
+function useServerPreview() {
+  const checkServerHealth = useCheckServerHealth()
+
   const looksComplete = (value: string) => {
     const normalized = normalizeServerUrl(value)
     if (!normalized) return false
@@ -92,7 +98,7 @@ function useServerPreview(fetcher: typeof fetch) {
     const http: ServerConnection.HttpBase = { url: normalized }
     if (username) http.username = username
     if (password) http.password = password
-    const result = await checkServerHealth(http, fetcher)
+    const result = await checkServerHealth(http)
     setStatus(result.healthy)
   }
 
@@ -115,7 +121,7 @@ function ServerForm(props: ServerFormProps) {
 
   return (
     <div class="px-5">
-      <div class="bg-surface-raised-base rounded-md p-5 flex flex-col gap-3">
+      <div class="bg-surface-base rounded-md p-5 flex flex-col gap-3">
         <div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
           <TextField
             type="text"
@@ -143,7 +149,7 @@ function ServerForm(props: ServerFormProps) {
           <TextField
             type="text"
             label={language.t("dialog.server.add.username")}
-            placeholder="username"
+            placeholder={language.t("dialog.server.add.usernamePlaceholder")}
             value={props.username}
             disabled={props.busy}
             onChange={props.onUsernameChange}
@@ -152,7 +158,7 @@ function ServerForm(props: ServerFormProps) {
           <TextField
             type="password"
             label={language.t("dialog.server.add.password")}
-            placeholder="password"
+            placeholder={language.t("dialog.server.add.passwordPlaceholder")}
             value={props.password}
             disabled={props.busy}
             onChange={props.onPasswordChange}
@@ -170,15 +176,15 @@ export function DialogSelectServer() {
   const server = useServer()
   const platform = usePlatform()
   const language = useLanguage()
-  const fetcher = platform.fetch ?? globalThis.fetch
-  const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
-  const { previewStatus } = useServerPreview(fetcher)
+  const { defaultKey, canDefault, setDefault } = useDefaultServer()
+  const { previewStatus } = useServerPreview()
+  const checkServerHealth = useCheckServerHealth()
   const [store, setStore] = createStore({
     status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
     addServer: {
       url: "",
       name: "",
-      username: "",
+      username: DEFAULT_USERNAME,
       password: "",
       adding: false,
       error: "",
@@ -201,7 +207,7 @@ export function DialogSelectServer() {
     setStore("addServer", {
       url: "",
       name: "",
-      username: "",
+      username: DEFAULT_USERNAME,
       password: "",
       adding: false,
       error: "",
@@ -264,7 +270,7 @@ export function DialogSelectServer() {
     const results: Record<ServerConnection.Key, ServerHealth> = {}
     await Promise.all(
       items().map(async (conn) => {
-        results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
+        results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
       }),
     )
     setStore("status", reconcile(results))
@@ -285,8 +291,8 @@ export function DialogSelectServer() {
       navigate("/")
       return
     }
-    server.setActive(ServerConnection.key(conn))
     navigate("/")
+    queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
   }
 
   const handleAddChange = (value: string) => {
@@ -362,9 +368,9 @@ export function DialogSelectServer() {
       http: { url: normalized },
     }
     if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
-    if (store.addServer.username) conn.http.username = store.addServer.username
     if (store.addServer.password) conn.http.password = store.addServer.password
-    const result = await checkServerHealth(conn.http, fetcher)
+    if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
+    const result = await checkServerHealth(conn.http)
     setStore("addServer", { adding: false })
     if (!result.healthy) {
       setStore("addServer", { error: language.t("dialog.server.add.error") })
@@ -404,7 +410,7 @@ export function DialogSelectServer() {
       displayName: name,
       http: { url: normalized, username, password },
     }
-    const result = await checkServerHealth(conn.http, fetcher)
+    const result = await checkServerHealth(conn.http)
     setStore("editServer", { busy: false })
     if (!result.healthy) {
       setStore("editServer", { error: language.t("dialog.server.add.error") })
@@ -441,7 +447,7 @@ export function DialogSelectServer() {
       showForm: true,
       url: "",
       name: "",
-      username: "",
+      username: DEFAULT_USERNAME,
       password: "",
       error: "",
       status: undefined,
@@ -494,8 +500,8 @@ export function DialogSelectServer() {
 
   async function handleRemove(url: ServerConnection.Key) {
     server.remove(url)
-    if ((await platform.getDefaultServerUrl?.()) === url) {
-      platform.setDefaultServerUrl?.(null)
+    if ((await platform.getDefaultServer?.()) === url) {
+      platform.setDefaultServer?.(null)
     }
   }
 
@@ -536,7 +542,7 @@ export function DialogSelectServer() {
               if (x) select(x)
             }}
             divider={true}
-            class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
+            class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
           >
             {(i) => {
               const key = ServerConnection.key(i)
@@ -551,7 +557,7 @@ export function DialogSelectServer() {
                     status={store.status[key]}
                     class="flex items-center gap-3 min-w-0 flex-1"
                     badge={
-                      <Show when={defaultUrl() === i.http.url}>
+                      <Show when={defaultKey() === ServerConnection.key(i)}>
                         <span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
                           {language.t("dialog.server.status.default")}
                         </span>
@@ -584,14 +590,14 @@ export function DialogSelectServer() {
                             >
                               <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
                             </DropdownMenu.Item>
-                            <Show when={canDefault() && defaultUrl() !== i.http.url}>
-                              <DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
+                            <Show when={canDefault() && defaultKey() !== key}>
+                              <DropdownMenu.Item onSelect={() => setDefault(key)}>
                                 <DropdownMenu.ItemLabel>
                                   {language.t("dialog.server.menu.default")}
                                 </DropdownMenu.ItemLabel>
                               </DropdownMenu.Item>
                             </Show>
-                            <Show when={canDefault() && defaultUrl() === i.http.url}>
+                            <Show when={canDefault() && defaultKey() === key}>
                               <DropdownMenu.Item onSelect={() => setDefault(null)}>
                                 <DropdownMenu.ItemLabel>
                                   {language.t("dialog.server.menu.defaultRemove")}

+ 307 - 247
packages/app/src/components/prompt-input.tsx

@@ -1,8 +1,7 @@
 import { useFilteredList } from "@opencode-ai/ui/hooks"
 import { useSpring } from "@opencode-ai/ui/motion-spring"
-import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
+import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal } from "solid-js"
 import { createStore } from "solid-js/store"
-import { createFocusSignal } from "@solid-primitives/active-element"
 import { useLocal } from "@/context/local"
 import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
 import {
@@ -17,7 +16,6 @@ import {
 } from "@/context/prompt"
 import { useLayout } from "@/context/layout"
 import { useSDK } from "@/context/sdk"
-import { useParams } from "@solidjs/router"
 import { useSync } from "@/context/sync"
 import { useComments } from "@/context/comments"
 import { Button } from "@opencode-ai/ui/button"
@@ -27,7 +25,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Select } from "@opencode-ai/ui/select"
-import { RadioGroup } from "@opencode-ai/ui/radio-group"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { ModelSelectorPopover } from "@/components/dialog-select-model"
 import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
@@ -37,8 +34,12 @@ import { Persist, persisted } from "@/utils/persist"
 import { usePermission } from "@/context/permission"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
+import { useSessionLayout } from "@/pages/session/session-layout"
+import { createSessionTabs } from "@/pages/session/helpers"
+import { promptEnabled, promptProbe } from "@/testing/prompt"
 import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
-import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
+import { createPromptAttachments } from "./prompt-input/attachments"
+import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
 import {
   canNavigateHistoryAtCursor,
   navigatePromptHistory,
@@ -48,7 +49,7 @@ import {
   type PromptHistoryStoredEntry,
   promptLength,
 } from "./prompt-input/history"
-import { createPromptSubmit } from "./prompt-input/submit"
+import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
 import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
 import { PromptContextItems } from "./prompt-input/context-items"
 import { PromptImageAttachments } from "./prompt-input/image-attachments"
@@ -61,6 +62,11 @@ interface PromptInputProps {
   ref?: (el: HTMLDivElement) => void
   newSessionWorktree?: string
   onNewSessionWorktreeReset?: () => void
+  edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
+  onEditLoaded?: () => void
+  shouldQueue?: () => boolean
+  onQueue?: (draft: FollowupDraft) => void
+  onAbort?: () => void
   onSubmit?: () => void
 }
 
@@ -102,20 +108,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const prompt = usePrompt()
   const layout = useLayout()
   const comments = useComments()
-  const params = useParams()
   const dialog = useDialog()
   const providers = useProviders()
   const command = useCommand()
   const permission = usePermission()
   const language = useLanguage()
   const platform = usePlatform()
+  const { params, tabs, view } = useSessionLayout()
   let editorRef!: HTMLDivElement
   let fileInputRef: HTMLInputElement | undefined
   let scrollRef!: HTMLDivElement
   let slashPopoverRef!: HTMLDivElement
 
   const mirror = { input: false }
-  const inset = 44
+  const inset = 56
+  const space = `${inset}px`
 
   const scrollCursorIntoView = () => {
     const container = scrollRef
@@ -150,13 +157,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
   }
 
-  const queueScroll = () => {
-    requestAnimationFrame(scrollCursorIntoView)
+  const queueScroll = (count = 2) => {
+    requestAnimationFrame(() => {
+      scrollCursorIntoView()
+      if (count > 1) queueScroll(count - 1)
+    })
   }
 
-  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
-  const tabs = createMemo(() => layout.tabs(sessionKey))
-  const view = createMemo(() => layout.view(sessionKey))
+  const activeFileTab = createSessionTabs({
+    tabs,
+    pathFromTab: files.pathFromTab,
+    normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
+  }).activeFileTab
 
   const commentInReview = (path: string) => {
     const sessionID = params.id
@@ -209,7 +221,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   const recent = createMemo(() => {
     const all = tabs().all()
-    const active = tabs().active()
+    const active = activeFileTab()
     const order = active ? [active, ...all.filter((x) => x !== active)] : all
     const seen = new Set<string>()
     const paths: string[] = []
@@ -232,6 +244,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       },
   )
   const working = createMemo(() => status()?.type !== "idle")
+  const tip = () => {
+    if (working()) {
+      return (
+        <div class="flex items-center gap-2">
+          <span>{language.t("prompt.action.stop")}</span>
+          <span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
+        </div>
+      )
+    }
+
+    return (
+      <div class="flex items-center gap-2">
+        <span>{language.t("prompt.action.send")}</span>
+        <Icon name="enter" size="small" class="text-icon-base" />
+      </div>
+    )
+  }
   const imageAttachments = createMemo(() =>
     prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
   )
@@ -255,6 +284,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   })
 
   const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
+  const motion = (value: number) => ({
+    opacity: value,
+    transform: `scale(${0.95 + value * 0.05})`,
+    filter: `blur(${(1 - value) * 2}px)`,
+    "pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const),
+  })
+  const buttons = createMemo(() => motion(buttonsSpring()))
+  const shell = createMemo(() => motion(1 - buttonsSpring()))
+  const control = createMemo(() => ({ height: "28px", ...buttons() }))
 
   const commentCount = createMemo(() => {
     if (store.mode === "shell") return 0
@@ -390,7 +428,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
   }
 
-  const isFocused = createFocusSignal(() => editorRef)
   const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
 
   const pick = () => fileInputRef?.click()
@@ -490,6 +527,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     setComposing(false)
   }
 
+  const handleCompositionStart = () => {
+    setComposing(true)
+  }
+
+  const handleCompositionEnd = () => {
+    setComposing(false)
+    requestAnimationFrame(() => {
+      if (composing()) return
+      reconcile(prompt.current().filter((part) => part.type !== "image"))
+    })
+  }
+
   const agentList = createMemo(() =>
     sync.data.agent
       .filter((agent) => !agent.hidden && agent.mode !== "primary")
@@ -573,6 +622,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   const handleSlashSelect = (cmd: SlashCommand | undefined) => {
     if (!cmd) return
+    promptProbe.select(cmd.id)
     closePopover()
 
     if (cmd.type === "custom") {
@@ -661,6 +711,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     })
   })
 
+  if (promptEnabled()) {
+    createEffect(() => {
+      promptProbe.set({
+        popover: store.popover,
+        slash: {
+          active: slashActive() ?? null,
+          ids: slashFlat().map((cmd) => cmd.id),
+        },
+      })
+    })
+
+    onCleanup(() => promptProbe.clear())
+  }
+
   const selectPopoverActive = () => {
     if (store.popover === "at") {
       const items = atFlat()
@@ -680,24 +744,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
   }
 
-  createEffect(
-    on(
-      () => prompt.current(),
-      (currentParts) => {
-        const inputParts = currentParts.filter((part) => part.type !== "image")
+  const reconcile = (input: Prompt) => {
+    if (mirror.input) {
+      mirror.input = false
+      if (isNormalizedEditor()) return
 
-        if (mirror.input) {
-          mirror.input = false
-          if (isNormalizedEditor()) return
+      renderEditorWithCursor(input)
+      return
+    }
 
-          renderEditorWithCursor(inputParts)
-          return
-        }
+    const dom = parseFromDOM()
+    if (isNormalizedEditor() && isPromptEqual(input, dom)) return
 
-        const domParts = parseFromDOM()
-        if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
+    renderEditorWithCursor(input)
+  }
 
-        renderEditorWithCursor(inputParts)
+  createEffect(
+    on(
+      () => prompt.current(),
+      (parts) => {
+        if (composing()) return
+        reconcile(parts.filter((part) => part.type !== "image"))
       },
     ),
   )
@@ -921,6 +988,45 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     setCurrentHistory("entries", next)
   }
 
+  createEffect(
+    on(
+      () => props.edit?.id,
+      (id) => {
+        const edit = props.edit
+        if (!id || !edit) return
+
+        for (const item of prompt.context.items()) {
+          prompt.context.remove(item.key)
+        }
+
+        for (const item of edit.context) {
+          prompt.context.add({
+            type: item.type,
+            path: item.path,
+            selection: item.selection,
+            comment: item.comment,
+            commentID: item.commentID,
+            commentOrigin: item.commentOrigin,
+            preview: item.preview,
+          })
+        }
+
+        setStore("mode", "normal")
+        setStore("popover", null)
+        setStore("historyIndex", -1)
+        setStore("savedPrompt", null)
+        prompt.set(edit.prompt, promptLength(edit.prompt))
+        requestAnimationFrame(() => {
+          editorRef.focus()
+          setCursorPosition(editorRef, promptLength(edit.prompt))
+          queueScroll()
+        })
+        props.onEditLoaded?.()
+      },
+      { defer: true },
+    ),
+  )
+
   const navigateHistory = (direction: "up" | "down") => {
     const result = navigatePromptHistory({
       direction,
@@ -937,9 +1043,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     return true
   }
 
-  const { addImageAttachment, removeImageAttachment, handlePaste } = createPromptAttachments({
+  const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
     editor: () => editorRef,
-    isFocused,
     isDialogActive: () => !!dialog.active,
     setDraggingType: (type) => setStore("draggingType", type),
     focusEditor: () => {
@@ -956,6 +1061,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
     return permission.isAutoAccepting(id, sdk.directory)
   })
+  const acceptLabel = createMemo(() =>
+    language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"),
+  )
+  const toggleAccept = () => {
+    if (!params.id) {
+      permission.toggleAutoAcceptDirectory(sdk.directory)
+      return
+    }
+
+    permission.toggleAutoAccept(params.id, sdk.directory)
+  }
 
   const { abort, handleSubmit } = createPromptSubmit({
     info,
@@ -975,6 +1091,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     setPopover: (popover) => setStore("popover", popover),
     newSessionWorktree: () => props.newSessionWorktree,
     onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
+    shouldQueue: props.shouldQueue,
+    onQueue: props.onQueue,
+    onAbort: props.onAbort,
     onSubmit: props.onSubmit,
   })
 
@@ -1122,6 +1241,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
     // Note: Shift+Enter is handled earlier, before IME check
     if (event.key === "Enter" && !event.shiftKey) {
+      event.preventDefault()
+      if (event.repeat) return
+      if (
+        working() &&
+        prompt
+          .current()
+          .map((part) => ("content" in part ? part.content : ""))
+          .join("")
+          .trim().length === 0 &&
+        imageAttachments().length === 0 &&
+        commentCount() === 0
+      ) {
+        return
+      }
       handleSubmit(event)
     }
   }
@@ -1174,7 +1307,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           onOpen={(attachment) =>
             dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
           }
-          onRemove={removeImageAttachment}
+          onRemove={removeAttachment}
           removeLabel={language.t("prompt.attachment.remove")}
         />
         <div
@@ -1192,7 +1325,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             editorRef?.focus()
           }}
         >
-          <div class="relative max-h-[240px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
+          <div
+            class="relative max-h-[240px] overflow-y-auto no-scrollbar"
+            ref={(el) => (scrollRef = el)}
+            style={{ "scroll-padding-bottom": space }}
+          >
             <div
               data-component="prompt-input"
               ref={(el) => {
@@ -1208,28 +1345,40 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               spellcheck={store.mode === "normal"}
               onInput={handleInput}
               onPaste={handlePaste}
-              onCompositionStart={() => setComposing(true)}
-              onCompositionEnd={() => setComposing(false)}
+              onCompositionStart={handleCompositionStart}
+              onCompositionEnd={handleCompositionEnd}
               onBlur={handleBlur}
               onKeyDown={handleKeyDown}
               classList={{
                 "select-text": true,
-                "w-full pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
+                "w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
                 "[&_[data-type=file]]:text-syntax-property": true,
                 "[&_[data-type=agent]]:text-syntax-type": true,
                 "font-mono!": store.mode === "shell",
               }}
+              style={{ "padding-bottom": space }}
             />
             <Show when={!prompt.dirty()}>
               <div
-                class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
+                class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
                 classList={{ "font-mono!": store.mode === "shell" }}
+                style={{ "padding-bottom": space }}
               >
                 {placeholder()}
               </div>
             </Show>
           </div>
 
+          <div
+            aria-hidden="true"
+            class="pointer-events-none absolute inset-x-0 bottom-0"
+            style={{
+              height: space,
+              background:
+                "linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
+            }}
+          />
+
           <div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
             <input
               ref={fileInputRef}
@@ -1238,14 +1387,32 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               class="hidden"
               onChange={(e) => {
                 const file = e.currentTarget.files?.[0]
-                if (file) addImageAttachment(file)
+                if (file) void addAttachment(file)
                 e.currentTarget.value = ""
               }}
             />
 
+            <div class="flex items-center gap-1 pointer-events-auto">
+              <Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
+                <IconButton
+                  data-action="prompt-submit"
+                  type="submit"
+                  disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
+                  tabIndex={store.mode === "normal" ? undefined : -1}
+                  icon={working() ? "stop" : "arrow-up"}
+                  variant="primary"
+                  class="size-8"
+                  style={buttons()}
+                  aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
+                />
+              </Tooltip>
+            </div>
+          </div>
+
+          <div class="pointer-events-none absolute bottom-2 left-2">
             <div
               aria-hidden={store.mode !== "normal"}
-              class="flex items-center gap-1"
+              class="pointer-events-auto"
               style={{
                 "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
               }}
@@ -1260,11 +1427,7 @@ 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)`,
-                  }}
+                  style={buttons()}
                   onClick={pick}
                   disabled={store.mode !== "normal"}
                   tabIndex={store.mode === "normal" ? undefined : -1}
@@ -1273,85 +1436,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   <Icon name="plus" class="size-4.5" />
                 </Button>
               </TooltipKeybind>
-
-              <Tooltip
-                placement="top"
-                inactive={!prompt.dirty() && !working()}
-                value={
-                  <Switch>
-                    <Match when={working()}>
-                      <div class="flex items-center gap-2">
-                        <span>{language.t("prompt.action.stop")}</span>
-                        <span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
-                      </div>
-                    </Match>
-                    <Match when={true}>
-                      <div class="flex items-center gap-2">
-                        <span>{language.t("prompt.action.send")}</span>
-                        <Icon name="enter" size="small" class="text-icon-base" />
-                      </div>
-                    </Match>
-                  </Switch>
-                }
-              >
-                <IconButton
-                  data-action="prompt-submit"
-                  type="submit"
-                  disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
-                  tabIndex={store.mode === "normal" ? undefined : -1}
-                  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>
-            </div>
-          </div>
-
-          <div class="pointer-events-none absolute bottom-2 left-2">
-            <div class="pointer-events-auto">
-              <TooltipKeybind
-                placement="top"
-                gutter={8}
-                title={language.t(
-                  accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable",
-                )}
-                keybind={command.keybind("permissions.autoaccept")}
-              >
-                <Button
-                  data-action="prompt-permissions"
-                  variant="ghost"
-                  onClick={() => {
-                    if (!params.id) {
-                      permission.toggleAutoAcceptDirectory(sdk.directory)
-                      return
-                    }
-                    permission.toggleAutoAccept(params.id, sdk.directory)
-                  }}
-                  classList={{
-                    "size-6 flex items-center justify-center": true,
-                    "text-text-base": !accepting(),
-                    "hover:bg-surface-success-base": accepting(),
-                  }}
-                  aria-label={
-                    accepting()
-                      ? language.t("command.permissions.autoaccept.disable")
-                      : language.t("command.permissions.autoaccept.enable")
-                  }
-                  aria-pressed={accepting()}
-                >
-                  <Icon
-                    name="chevron-double-right"
-                    size="small"
-                    classList={{ "text-icon-success-base": accepting() }}
-                  />
-                </Button>
-              </TooltipKeybind>
             </div>
           </div>
         </div>
@@ -1364,61 +1448,83 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 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",
+                  ...shell(),
                 }}
               >
                 <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}
-                  title={language.t("command.agent.cycle")}
-                  keybind={command.keybind("agent.cycle")}
-                >
-                  <Select
-                    size="normal"
-                    options={agentNames()}
-                    current={local.agent.current()?.name ?? ""}
-                    onSelect={local.agent.set}
-                    class="capitalize max-w-[160px]"
-                    valueClass="truncate text-13-regular"
-                    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
-                  when={providers.paid().length > 0}
-                  fallback={
+                <div data-component="prompt-agent-control">
+                  <TooltipKeybind
+                    placement="top"
+                    gutter={4}
+                    title={language.t("command.agent.cycle")}
+                    keybind={command.keybind("agent.cycle")}
+                  >
+                    <Select
+                      size="normal"
+                      options={agentNames()}
+                      current={local.agent.current()?.name ?? ""}
+                      onSelect={local.agent.set}
+                      class="capitalize max-w-[160px] text-text-base"
+                      valueClass="truncate text-13-regular text-text-base"
+                      triggerStyle={control()}
+                      triggerProps={{ "data-action": "prompt-agent" }}
+                      variant="ghost"
+                    />
+                  </TooltipKeybind>
+                </div>
+                <div data-component="prompt-model-control">
+                  <Show
+                    when={providers.paid().length > 0}
+                    fallback={
+                      <TooltipKeybind
+                        placement="top"
+                        gutter={4}
+                        title={language.t("command.model.choose")}
+                        keybind={command.keybind("model.choose")}
+                      >
+                        <Button
+                          data-action="prompt-model"
+                          as="div"
+                          variant="ghost"
+                          size="normal"
+                          class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
+                          style={control()}
+                          onClick={() => dialog.show(() => <DialogSelectModelUnpaid model={local.model} />)}
+                        >
+                          <Show when={local.model.current()?.provider?.id}>
+                            <ProviderIcon
+                              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)" }}
+                            />
+                          </Show>
+                          <span class="truncate">
+                            {local.model.current()?.name ?? language.t("dialog.model.select.title")}
+                          </span>
+                          <Icon name="chevron-down" size="small" class="shrink-0" />
+                        </Button>
+                      </TooltipKeybind>
+                    }
+                  >
                     <TooltipKeybind
                       placement="top"
                       gutter={4}
                       title={language.t("command.model.choose")}
                       keybind={command.keybind("model.choose")}
                     >
-                      <Button
-                        as="div"
-                        variant="ghost"
-                        size="normal"
-                        class="min-w-0 max-w-[320px] text-13-regular group"
-                        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",
+                      <ModelSelectorPopover
+                        model={local.model}
+                        triggerAs={Button}
+                        triggerProps={{
+                          variant: "ghost",
+                          size: "normal",
+                          style: control(),
+                          class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
+                          "data-action": "prompt-model",
                         }}
-                        onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
                       >
                         <Show when={local.model.current()?.provider?.id}>
                           <ProviderIcon
@@ -1431,101 +1537,55 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                           {local.model.current()?.name ?? language.t("dialog.model.select.title")}
                         </span>
                         <Icon name="chevron-down" size="small" class="shrink-0" />
-                      </Button>
+                      </ModelSelectorPopover>
                     </TooltipKeybind>
-                  }
-                >
+                  </Show>
+                </div>
+                <div data-component="prompt-variant-control">
                   <TooltipKeybind
                     placement="top"
                     gutter={4}
-                    title={language.t("command.model.choose")}
-                    keybind={command.keybind("model.choose")}
+                    title={language.t("command.model.variant.cycle")}
+                    keybind={command.keybind("model.variant.cycle")}
                   >
-                    <ModelSelectorPopover
-                      triggerAs={Button}
-                      triggerProps={{
-                        variant: "ghost",
-                        size: "normal",
-                        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}
-                          class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
-                          style={{ "will-change": "opacity", transform: "translateZ(0)" }}
-                        />
-                      </Show>
-                      <span class="truncate">
-                        {local.model.current()?.name ?? language.t("dialog.model.select.title")}
-                      </span>
-                      <Icon name="chevron-down" size="small" class="shrink-0" />
-                    </ModelSelectorPopover>
+                    <Select
+                      size="normal"
+                      options={variants()}
+                      current={local.model.variant.current() ?? "default"}
+                      label={(x) => (x === "default" ? language.t("common.default") : x)}
+                      onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
+                      class="capitalize max-w-[160px] text-text-base"
+                      valueClass="truncate text-13-regular text-text-base"
+                      triggerStyle={control()}
+                      triggerProps={{ "data-action": "prompt-model-variant" }}
+                      variant="ghost"
+                    />
                   </TooltipKeybind>
-                </Show>
+                </div>
                 <TooltipKeybind
                   placement="top"
-                  gutter={4}
-                  title={language.t("command.model.variant.cycle")}
-                  keybind={command.keybind("model.variant.cycle")}
+                  gutter={8}
+                  title={acceptLabel()}
+                  keybind={command.keybind("permissions.autoaccept")}
                 >
-                  <Select
-                    size="normal"
-                    options={variants()}
-                    current={local.model.variant.current() ?? "default"}
-                    label={(x) => (x === "default" ? language.t("common.default") : x)}
-                    onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
-                    class="capitalize max-w-[160px]"
-                    valueClass="truncate text-13-regular"
-                    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",
-                    }}
+                  <Button
+                    data-action="prompt-permissions"
                     variant="ghost"
-                  />
+                    onClick={toggleAccept}
+                    classList={{
+                      "h-7 w-7 p-0 shrink-0 flex items-center justify-center": true,
+                      "text-text-base": !accepting(),
+                      "hover:bg-surface-success-base": accepting(),
+                    }}
+                    style={control()}
+                    aria-label={acceptLabel()}
+                    aria-pressed={accepting()}
+                  >
+                    <Icon name="shield" size="small" classList={{ "text-icon-success-base": accepting() }} />
+                  </Button>
                 </TooltipKeybind>
               </div>
             </div>
-            <div class="shrink-0">
-              <RadioGroup
-                options={["shell", "normal"] as const}
-                current={store.mode}
-                value={(mode) => mode}
-                label={(mode) => (
-                  <TooltipKeybind
-                    placement="top"
-                    gutter={4}
-                    openDelay={2000}
-                    title={language.t(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
-                    keybind={command.keybind(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
-                    class="size-full flex items-center justify-center"
-                  >
-                    <Icon
-                      name={mode === "shell" ? "console" : "prompt"}
-                      class="size-[18px]"
-                      classList={{
-                        "text-icon-strong-base": store.mode === mode,
-                        "text-icon-weak": store.mode !== mode,
-                      }}
-                    />
-                  </TooltipKeybind>
-                )}
-                onSelect={(mode) => mode && setMode(mode)}
-                fill
-                pad="none"
-                class="w-[68px]"
-              />
-            </div>
           </div>
         </DockTray>
       </Show>

+ 44 - 0
packages/app/src/components/prompt-input/attachments.test.ts

@@ -0,0 +1,44 @@
+import { describe, expect, test } from "bun:test"
+import { attachmentMime } from "./files"
+import { pasteMode } from "./paste"
+
+describe("attachmentMime", () => {
+  test("keeps PDFs when the browser reports the mime", async () => {
+    const file = new File(["%PDF-1.7"], "guide.pdf", { type: "application/pdf" })
+    expect(await attachmentMime(file)).toBe("application/pdf")
+  })
+
+  test("normalizes structured text types to text/plain", async () => {
+    const file = new File(['{"ok":true}\n'], "data.json", { type: "application/json" })
+    expect(await attachmentMime(file)).toBe("text/plain")
+  })
+
+  test("accepts text files even with a misleading browser mime", async () => {
+    const file = new File(["export const x = 1\n"], "main.ts", { type: "video/mp2t" })
+    expect(await attachmentMime(file)).toBe("text/plain")
+  })
+
+  test("rejects binary files", async () => {
+    const file = new File([Uint8Array.of(0, 255, 1, 2)], "blob.bin", { type: "application/octet-stream" })
+    expect(await attachmentMime(file)).toBeUndefined()
+  })
+})
+
+describe("pasteMode", () => {
+  test("uses native paste for short single-line text", () => {
+    expect(pasteMode("hello world")).toBe("native")
+  })
+
+  test("uses manual paste for multiline text", () => {
+    expect(
+      pasteMode(`{
+  "ok": true
+}`),
+    ).toBe("manual")
+    expect(pasteMode("a\r\nb")).toBe("manual")
+  })
+
+  test("uses manual paste for large text", () => {
+    expect(pasteMode("x".repeat(8000))).toBe("manual")
+  })
+})

+ 74 - 57
packages/app/src/components/prompt-input/attachments.ts

@@ -4,26 +4,28 @@ import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context
 import { useLanguage } from "@/context/language"
 import { uuid } from "@/utils/uuid"
 import { getCursorPosition } from "./editor-dom"
+import { attachmentMime } from "./files"
+import { normalizePaste, pasteMode } from "./paste"
 
-export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
-export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
-const LARGE_PASTE_CHARS = 8000
-const LARGE_PASTE_BREAKS = 120
-
-function largePaste(text: string) {
-  if (text.length >= LARGE_PASTE_CHARS) return true
-  let breaks = 0
-  for (const char of text) {
-    if (char !== "\n") continue
-    breaks += 1
-    if (breaks >= LARGE_PASTE_BREAKS) return true
-  }
-  return false
+function dataUrl(file: File, mime: string) {
+  return new Promise<string>((resolve) => {
+    const reader = new FileReader()
+    reader.addEventListener("error", () => resolve(""))
+    reader.addEventListener("load", () => {
+      const value = typeof reader.result === "string" ? reader.result : ""
+      const idx = value.indexOf(",")
+      if (idx === -1) {
+        resolve(value)
+        return
+      }
+      resolve(`data:${mime};base64,${value.slice(idx + 1)}`)
+    })
+    reader.readAsDataURL(file)
+  })
 }
 
 type PromptAttachmentsInput = {
   editor: () => HTMLDivElement | undefined
-  isFocused: () => boolean
   isDialogActive: () => boolean
   setDraggingType: (type: "image" | "@mention" | null) => void
   focusEditor: () => void
@@ -35,35 +37,47 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
   const prompt = usePrompt()
   const language = useLanguage()
 
-  const addImageAttachment = async (file: File) => {
-    if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
+  const warn = () => {
+    showToast({
+      title: language.t("prompt.toast.pasteUnsupported.title"),
+      description: language.t("prompt.toast.pasteUnsupported.description"),
+    })
+  }
 
-    const reader = new FileReader()
-    reader.onload = () => {
-      const editor = input.editor()
-      if (!editor) return
-      const dataUrl = reader.result as string
-      const attachment: ImageAttachmentPart = {
-        type: "image",
-        id: uuid(),
-        filename: file.name,
-        mime: file.type,
-        dataUrl,
-      }
-      const cursorPosition = prompt.cursor() ?? getCursorPosition(editor)
-      prompt.set([...prompt.current(), attachment], cursorPosition)
+  const add = async (file: File, toast = true) => {
+    const mime = await attachmentMime(file)
+    if (!mime) {
+      if (toast) warn()
+      return false
     }
-    reader.readAsDataURL(file)
+
+    const editor = input.editor()
+    if (!editor) return false
+
+    const url = await dataUrl(file, mime)
+    if (!url) return false
+
+    const attachment: ImageAttachmentPart = {
+      type: "image",
+      id: uuid(),
+      filename: file.name,
+      mime,
+      dataUrl: url,
+    }
+    const cursor = prompt.cursor() ?? getCursorPosition(editor)
+    prompt.set([...prompt.current(), attachment], cursor)
+    return true
   }
 
-  const removeImageAttachment = (id: string) => {
+  const addAttachment = (file: File) => add(file)
+
+  const removeAttachment = (id: string) => {
     const current = prompt.current()
     const next = current.filter((part) => part.type !== "image" || part.id !== id)
     prompt.set(next, prompt.cursor())
   }
 
   const handlePaste = async (event: ClipboardEvent) => {
-    if (!input.isFocused()) return
     const clipboardData = event.clipboardData
     if (!clipboardData) return
 
@@ -72,21 +86,16 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
 
     const items = Array.from(clipboardData.items)
     const fileItems = items.filter((item) => item.kind === "file")
-    const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
 
-    if (imageItems.length > 0) {
-      for (const item of imageItems) {
+    if (fileItems.length > 0) {
+      let found = false
+      for (const item of fileItems) {
         const file = item.getAsFile()
-        if (file) await addImageAttachment(file)
+        if (!file) continue
+        const ok = await add(file, false)
+        if (ok) found = true
       }
-      return
-    }
-
-    if (fileItems.length > 0) {
-      showToast({
-        title: language.t("prompt.toast.pasteUnsupported.title"),
-        description: language.t("prompt.toast.pasteUnsupported.description"),
-      })
+      if (!found) warn()
       return
     }
 
@@ -96,23 +105,30 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
     if (input.readClipboardImage && !plainText) {
       const file = await input.readClipboardImage()
       if (file) {
-        await addImageAttachment(file)
+        await addAttachment(file)
         return
       }
     }
 
     if (!plainText) return
 
-    if (largePaste(plainText)) {
-      if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
+    const text = normalizePaste(plainText)
+
+    const put = () => {
+      if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
       input.focusEditor()
-      if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
+      return input.addPart({ type: "text", content: text, start: 0, end: 0 })
+    }
+
+    if (pasteMode(text) === "manual") {
+      put()
+      return
     }
 
-    const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
+    const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text)
     if (inserted) return
 
-    input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
+    put()
   }
 
   const handleGlobalDragOver = (event: DragEvent) => {
@@ -153,11 +169,12 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
     const dropped = event.dataTransfer?.files
     if (!dropped) return
 
+    let found = false
     for (const file of Array.from(dropped)) {
-      if (ACCEPTED_FILE_TYPES.includes(file.type)) {
-        await addImageAttachment(file)
-      }
+      const ok = await add(file, false)
+      if (ok) found = true
     }
+    if (!found && dropped.length > 0) warn()
   }
 
   onMount(() => {
@@ -173,8 +190,8 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
   })
 
   return {
-    addImageAttachment,
-    removeImageAttachment,
+    addAttachment,
+    removeAttachment,
     handlePaste,
   }
 }

+ 119 - 0
packages/app/src/components/prompt-input/files.ts

@@ -0,0 +1,119 @@
+export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
+
+const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
+const IMAGE_EXTS = new Map([
+  ["gif", "image/gif"],
+  ["jpeg", "image/jpeg"],
+  ["jpg", "image/jpeg"],
+  ["png", "image/png"],
+  ["webp", "image/webp"],
+])
+const TEXT_MIMES = new Set([
+  "application/json",
+  "application/ld+json",
+  "application/toml",
+  "application/x-toml",
+  "application/x-yaml",
+  "application/xml",
+  "application/yaml",
+])
+
+export const ACCEPTED_FILE_TYPES = [
+  ...ACCEPTED_IMAGE_TYPES,
+  "application/pdf",
+  "text/*",
+  "application/json",
+  "application/ld+json",
+  "application/toml",
+  "application/x-toml",
+  "application/x-yaml",
+  "application/xml",
+  "application/yaml",
+  ".c",
+  ".cc",
+  ".cjs",
+  ".conf",
+  ".cpp",
+  ".css",
+  ".csv",
+  ".cts",
+  ".env",
+  ".go",
+  ".gql",
+  ".graphql",
+  ".h",
+  ".hh",
+  ".hpp",
+  ".htm",
+  ".html",
+  ".ini",
+  ".java",
+  ".js",
+  ".json",
+  ".jsx",
+  ".log",
+  ".md",
+  ".mdx",
+  ".mjs",
+  ".mts",
+  ".py",
+  ".rb",
+  ".rs",
+  ".sass",
+  ".scss",
+  ".sh",
+  ".sql",
+  ".toml",
+  ".ts",
+  ".tsx",
+  ".txt",
+  ".xml",
+  ".yaml",
+  ".yml",
+  ".zsh",
+]
+
+const SAMPLE = 4096
+
+function kind(type: string) {
+  return type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
+}
+
+function ext(name: string) {
+  const idx = name.lastIndexOf(".")
+  if (idx === -1) return ""
+  return name.slice(idx + 1).toLowerCase()
+}
+
+function textMime(type: string) {
+  if (!type) return false
+  if (type.startsWith("text/")) return true
+  if (TEXT_MIMES.has(type)) return true
+  if (type.endsWith("+json")) return true
+  return type.endsWith("+xml")
+}
+
+function textBytes(bytes: Uint8Array) {
+  if (bytes.length === 0) return true
+  let count = 0
+  for (const byte of bytes) {
+    if (byte === 0) return false
+    if (byte < 9 || (byte > 13 && byte < 32)) count += 1
+  }
+  return count / bytes.length <= 0.3
+}
+
+export async function attachmentMime(file: File) {
+  const type = kind(file.type)
+  if (IMAGE_MIMES.has(type)) return type
+  if (type === "application/pdf") return type
+
+  const suffix = ext(file.name)
+  const fallback = IMAGE_EXTS.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined)
+  if ((!type || type === "application/octet-stream") && fallback) return fallback
+
+  if (textMime(type)) return "text/plain"
+  const bytes = new Uint8Array(await file.slice(0, SAMPLE).arrayBuffer())
+  if (!textBytes(bytes)) return
+  return "text/plain"
+}

+ 24 - 0
packages/app/src/components/prompt-input/paste.ts

@@ -0,0 +1,24 @@
+const LARGE_PASTE_CHARS = 8000
+const LARGE_PASTE_BREAKS = 120
+
+function largePaste(text: string) {
+  if (text.length >= LARGE_PASTE_CHARS) return true
+  let breaks = 0
+  for (const char of text) {
+    if (char !== "\n") continue
+    breaks += 1
+    if (breaks >= LARGE_PASTE_BREAKS) return true
+  }
+  return false
+}
+
+export function normalizePaste(text: string) {
+  if (!text.includes("\r")) return text
+  return text.replace(/\r\n?/g, "\n")
+}
+
+export function pasteMode(text: string) {
+  if (largePaste(text)) return "manual"
+  if (text.includes("\n") || text.includes("\r")) return "manual"
+  return "native"
+}

+ 75 - 3
packages/app/src/components/prompt-input/submit.test.ts

@@ -7,12 +7,17 @@ const createdClients: string[] = []
 const createdSessions: string[] = []
 const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
 const optimistic: Array<{
+  directory?: string
+  sessionID?: string
   message: {
     agent: string
     model: { providerID: string; modelID: string }
     variant?: string
   }
 }> = []
+const optimisticSeeded: boolean[] = []
+const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
+const promoted: Array<{ directory: string; sessionID: string }> = []
 const sentShell: string[] = []
 const syncedDirectories: string[] = []
 
@@ -28,7 +33,12 @@ const clientFor = (directory: string) => {
     session: {
       create: async () => {
         createdSessions.push(directory)
-        return { data: { id: `session-${createdSessions.length}` } }
+        return {
+          data: {
+            id: `session-${createdSessions.length}`,
+            title: `New session ${createdSessions.length}`,
+          },
+        }
       },
       shell: async () => {
         sentShell.push(directory)
@@ -77,6 +87,11 @@ beforeAll(async () => {
       agent: {
         current: () => ({ name: "agent" }),
       },
+      session: {
+        promote(directory: string, sessionID: string) {
+          promoted.push({ directory, sessionID })
+        },
+      },
     }),
   }))
 
@@ -129,9 +144,16 @@ beforeAll(async () => {
       session: {
         optimistic: {
           add: (value: {
+            directory?: string
+            sessionID?: string
             message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
           }) => {
             optimistic.push(value)
+            optimisticSeeded.push(
+              !!value.directory &&
+                !!value.sessionID &&
+                !!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
+            )
           },
           remove: () => undefined,
         },
@@ -144,7 +166,21 @@ beforeAll(async () => {
     useGlobalSync: () => ({
       child: (directory: string) => {
         syncedDirectories.push(directory)
-        return [{}, () => undefined]
+        storedSessions[directory] ??= []
+        return [
+          { session: storedSessions[directory] },
+          (...args: unknown[]) => {
+            if (args[0] !== "session") return
+            const next = args[1]
+            if (typeof next === "function") {
+              storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
+              return
+            }
+            if (Array.isArray(next)) {
+              storedSessions[directory] = next as Array<{ id: string; title?: string }>
+            }
+          },
+        ]
       },
     }),
   }))
@@ -170,11 +206,14 @@ beforeEach(() => {
   createdSessions.length = 0
   enabledAutoAccept.length = 0
   optimistic.length = 0
+  optimisticSeeded.length = 0
+  promoted.length = 0
   params = {}
   sentShell.length = 0
   syncedDirectories.length = 0
   selected = "/repo/worktree-a"
   variant = undefined
+  for (const key of Object.keys(storedSessions)) delete storedSessions[key]
 })
 
 describe("prompt submit worktree selection", () => {
@@ -207,7 +246,12 @@ describe("prompt submit worktree selection", () => {
     expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
     expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
     expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
-    expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
+    expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
+    expect(promoted).toEqual([
+      { directory: "/repo/worktree-a", sessionID: "session-1" },
+      { directory: "/repo/worktree-b", sessionID: "session-2" },
+    ])
+    expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
   })
 
   test("applies auto-accept to newly created sessions", async () => {
@@ -271,4 +315,32 @@ describe("prompt submit worktree selection", () => {
       },
     })
   })
+
+  test("seeds new sessions before optimistic prompts are added", async () => {
+    const submit = createPromptSubmit({
+      info: () => undefined,
+      imageAttachments: () => [],
+      commentCount: () => 0,
+      autoAccept: () => false,
+      mode: () => "normal",
+      working: () => false,
+      editor: () => undefined,
+      queueScroll: () => undefined,
+      promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
+      addToHistory: () => undefined,
+      resetHistoryNavigation: () => undefined,
+      setMode: () => undefined,
+      setPopover: () => undefined,
+      newSessionWorktree: () => selected,
+      onNewSessionWorktreeReset: () => undefined,
+      onSubmit: () => undefined,
+    })
+
+    const event = { preventDefault: () => undefined } as unknown as Event
+
+    await submit.handleSubmit(event)
+
+    expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
+    expect(optimisticSeeded).toEqual([true])
+  })
 })

+ 203 - 52
packages/app/src/components/prompt-input/submit.ts

@@ -1,6 +1,7 @@
-import type { Message } from "@opencode-ai/sdk/v2/client"
+import type { Message, Session } from "@opencode-ai/sdk/v2/client"
 import { showToast } from "@opencode-ai/ui/toast"
 import { base64Encode } from "@opencode-ai/util/encode"
+import { Binary } from "@opencode-ai/util/binary"
 import { useNavigate, useParams } from "@solidjs/router"
 import type { Accessor } from "solid-js"
 import type { FileSelection } from "@/context/file"
@@ -9,7 +10,7 @@ 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 { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
 import { Identifier } from "@/utils/id"
@@ -25,6 +26,145 @@ type PendingPrompt = {
 
 const pending = new Map<string, PendingPrompt>()
 
+export type FollowupDraft = {
+  sessionID: string
+  sessionDirectory: string
+  prompt: Prompt
+  context: (ContextItem & { key: string })[]
+  agent: string
+  model: { providerID: string; modelID: string }
+  variant?: string
+}
+
+type FollowupSendInput = {
+  client: ReturnType<typeof useSDK>["client"]
+  globalSync: ReturnType<typeof useGlobalSync>
+  sync: ReturnType<typeof useSync>
+  draft: FollowupDraft
+  messageID?: string
+  optimisticBusy?: boolean
+  before?: () => Promise<boolean> | boolean
+}
+
+const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
+
+const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
+
+export async function sendFollowupDraft(input: FollowupSendInput) {
+  const text = draftText(input.draft.prompt)
+  const images = draftImages(input.draft.prompt)
+  const [, setStore] = input.globalSync.child(input.draft.sessionDirectory)
+
+  const setBusy = () => {
+    if (!input.optimisticBusy) return
+    setStore("session_status", input.draft.sessionID, { type: "busy" })
+  }
+
+  const setIdle = () => {
+    if (!input.optimisticBusy) return
+    setStore("session_status", input.draft.sessionID, { type: "idle" })
+  }
+
+  const wait = async () => {
+    const ok = await input.before?.()
+    if (ok === false) return false
+    return true
+  }
+
+  const [head, ...tail] = text.split(" ")
+  const cmd = head?.startsWith("/") ? head.slice(1) : undefined
+  if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
+    setBusy()
+    try {
+      if (!(await wait())) {
+        setIdle()
+        return false
+      }
+
+      await input.client.session.command({
+        sessionID: input.draft.sessionID,
+        command: cmd,
+        arguments: tail.join(" "),
+        agent: input.draft.agent,
+        model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
+        variant: input.draft.variant,
+        parts: images.map((attachment) => ({
+          id: Identifier.ascending("part"),
+          type: "file" as const,
+          mime: attachment.mime,
+          url: attachment.dataUrl,
+          filename: attachment.filename,
+        })),
+      })
+      return true
+    } catch (err) {
+      setIdle()
+      throw err
+    }
+  }
+
+  const messageID = input.messageID ?? Identifier.ascending("message")
+  const { requestParts, optimisticParts } = buildRequestParts({
+    prompt: input.draft.prompt,
+    context: input.draft.context,
+    images,
+    text,
+    sessionID: input.draft.sessionID,
+    messageID,
+    sessionDirectory: input.draft.sessionDirectory,
+  })
+
+  const message: Message = {
+    id: messageID,
+    sessionID: input.draft.sessionID,
+    role: "user",
+    time: { created: Date.now() },
+    agent: input.draft.agent,
+    model: input.draft.model,
+    variant: input.draft.variant,
+  }
+
+  const add = () =>
+    input.sync.session.optimistic.add({
+      directory: input.draft.sessionDirectory,
+      sessionID: input.draft.sessionID,
+      message,
+      parts: optimisticParts,
+    })
+
+  const remove = () =>
+    input.sync.session.optimistic.remove({
+      directory: input.draft.sessionDirectory,
+      sessionID: input.draft.sessionID,
+      messageID,
+    })
+
+  setBusy()
+  add()
+
+  try {
+    if (!(await wait())) {
+      setIdle()
+      remove()
+      return false
+    }
+
+    await input.client.session.promptAsync({
+      sessionID: input.draft.sessionID,
+      agent: input.draft.agent,
+      model: input.draft.model,
+      messageID,
+      parts: requestParts,
+      variant: input.draft.variant,
+    })
+    return true
+  } catch (err) {
+    setIdle()
+    remove()
+    throw err
+  }
+}
+
 type PromptSubmitInput = {
   info: Accessor<{ id: string } | undefined>
   imageAttachments: Accessor<ImageAttachmentPart[]>
@@ -41,6 +181,9 @@ type PromptSubmitInput = {
   setPopover: (popover: "at" | "slash" | null) => void
   newSessionWorktree?: Accessor<string | undefined>
   onNewSessionWorktreeReset?: () => void
+  shouldQueue?: Accessor<boolean>
+  onQueue?: (draft: FollowupDraft) => void
+  onAbort?: () => void
   onSubmit?: () => void
 }
 
@@ -82,6 +225,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     const [, setStore] = globalSync.child(sdk.directory)
     setStore("todo", sessionID, [])
 
+    input.onAbort?.()
+
     const queued = pending.get(sessionID)
     if (queued) {
       queued.abort.abort()
@@ -116,6 +261,26 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     }
   }
 
+  const clearContext = () => {
+    for (const item of prompt.context.items()) {
+      prompt.context.remove(item.key)
+    }
+  }
+
+  const seed = (dir: string, info: Session) => {
+    const [, setStore] = globalSync.child(dir)
+    setStore("session", (list: Session[]) => {
+      const result = Binary.search(list, info.id, (item) => item.id)
+      const next = [...list]
+      if (result.found) {
+        next[result.index] = info
+        return next
+      }
+      next.splice(result.index, 0, info)
+      return next
+    })
+  }
+
   const handleSubmit = async (event: Event) => {
     event.preventDefault()
 
@@ -131,6 +296,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
 
     const currentModel = local.model.current()
     const currentAgent = local.agent.current()
+    const variant = local.model.variant.current()
     if (!currentModel || !currentAgent) {
       showToast({
         title: language.t("prompt.toast.modelAgentRequired.title"),
@@ -191,7 +357,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
 
     let session = input.info()
     if (!session && isNewSession) {
-      session = await client.session
+      const created = await client.session
         .create()
         .then((x) => x.data ?? undefined)
         .catch((err) => {
@@ -201,8 +367,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
           })
           return undefined
         })
-      if (session) {
+      if (created) {
+        seed(sessionDirectory, created)
+        session = created
         if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
+        local.session.promote(sessionDirectory, session.id)
         layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
         navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
       }
@@ -215,14 +384,21 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       return
     }
 
-    input.onSubmit?.()
-
     const model = {
       modelID: currentModel.id,
       providerID: currentModel.provider.id,
     }
     const agent = currentAgent.name
-    const variant = local.model.variant.current()
+    const context = prompt.context.items().slice()
+    const draft: FollowupDraft = {
+      sessionID: session.id,
+      sessionDirectory,
+      prompt: currentPrompt,
+      context,
+      agent,
+      model,
+      variant,
+    }
 
     const clearInput = () => {
       prompt.reset()
@@ -243,6 +419,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       })
     }
 
+    if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
+      input.onQueue?.(draft)
+      clearContext()
+      clearInput()
+      return
+    }
+
+    input.onSubmit?.()
+
     if (mode === "shell") {
       clearInput()
       client.session
@@ -295,48 +480,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       }
     }
 
-    const context = prompt.context.items().slice()
     const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
-
     const messageID = Identifier.ascending("message")
-    const { requestParts, optimisticParts } = buildRequestParts({
-      prompt: currentPrompt,
-      context,
-      images,
-      text,
-      sessionID: session.id,
-      messageID,
-      sessionDirectory,
-    })
-
-    const optimisticMessage: Message = {
-      id: messageID,
-      sessionID: session.id,
-      role: "user",
-      time: { created: Date.now() },
-      agent,
-      model,
-      variant,
-    }
 
-    const addOptimisticMessage = () =>
-      sync.session.optimistic.add({
-        directory: sessionDirectory,
-        sessionID: session.id,
-        message: optimisticMessage,
-        parts: optimisticParts,
-      })
-
-    const removeOptimisticMessage = () =>
+    const removeOptimisticMessage = () => {
       sync.session.optimistic.remove({
         directory: sessionDirectory,
         sessionID: session.id,
         messageID,
       })
+    }
 
     removeCommentItems(commentItems)
     clearInput()
-    addOptimisticMessage()
 
     const waitForWorktree = async () => {
       const worktree = WorktreeState.get(sessionDirectory)
@@ -393,20 +549,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       return true
     }
 
-    const send = async () => {
-      const ok = await waitForWorktree()
-      if (!ok) return
-      await client.session.promptAsync({
-        sessionID: session.id,
-        agent,
-        model,
-        messageID,
-        parts: requestParts,
-        variant,
-      })
-    }
-
-    void send().catch((err) => {
+    void sendFollowupDraft({
+      client,
+      sync,
+      globalSync,
+      draft,
+      messageID,
+      optimisticBusy: sessionDirectory === projectDirectory,
+      before: waitForWorktree,
+    }).catch((err) => {
       pending.delete(session.id)
       if (sessionDirectory === projectDirectory) {
         sync.set("session_status", session.id, { type: "idle" })

+ 12 - 6
packages/app/src/components/server/server-row.tsx

@@ -10,6 +10,7 @@ import {
   type ParentProps,
   Show,
 } from "solid-js"
+import { useLanguage } from "@/context/language"
 import { type ServerConnection, serverName } from "@/context/server"
 import type { ServerHealth } from "@/utils/server-health"
 
@@ -25,6 +26,7 @@ interface ServerRowProps extends ParentProps {
 }
 
 export function ServerRow(props: ServerRowProps) {
+  const language = useLanguage()
   const [truncated, setTruncated] = createSignal(false)
   let nameRef: HTMLSpanElement | undefined
   let versionRef: HTMLSpanElement | undefined
@@ -65,22 +67,26 @@ export function ServerRow(props: ServerRowProps) {
 
   return (
     <Tooltip
-      class="flex-1"
+      class="flex-1 min-w-0"
       value={tooltipValue()}
+      contentStyle={{ "max-width": "none", "white-space": "nowrap" }}
       placement="top-start"
       inactive={!truncated() && !props.conn.displayName}
     >
       <div class={props.class} classList={{ "opacity-50": props.dimmed }}>
-        <div class="flex flex-col items-start">
-          <div class="flex flex-row items-center gap-2">
-            <span ref={nameRef} class={props.nameClass ?? "truncate"}>
+        <div class="flex flex-col items-start min-w-0 w-full">
+          <div class="flex flex-row items-center gap-2 min-w-0 w-full">
+            <span ref={nameRef} class={`${props.nameClass ?? "truncate"} min-w-0`}>
               {name()}
             </span>
             <Show
               when={badge()}
               fallback={
                 <Show when={props.status?.version}>
-                  <span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
+                  <span
+                    ref={versionRef}
+                    class={`${props.versionClass ?? "text-text-weak text-14-regular truncate"} min-w-0`}
+                  >
                     v{props.status?.version}
                   </span>
                 </Show>
@@ -96,7 +102,7 @@ export function ServerRow(props: ServerRowProps) {
                   {conn().http.username ? (
                     <span class="text-text-weak">{conn().http.username}</span>
                   ) : (
-                    <span class="text-text-weaker">no username</span>
+                    <span class="text-text-weaker">{language.t("server.row.noUsername")}</span>
                   )}
                 </span>
                 {conn().http.password && <span class="text-text-weak">••••••••</span>}

+ 11 - 6
packages/app/src/components/session-context-usage.tsx

@@ -2,12 +2,14 @@ import { Match, Show, Switch, createMemo } from "solid-js"
 import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
 import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
 import { Button } from "@opencode-ai/ui/button"
-import { useParams } from "@solidjs/router"
 
+import { useFile } from "@/context/file"
 import { useLayout } from "@/context/layout"
 import { useSync } from "@/context/sync"
 import { useLanguage } from "@/context/language"
 import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
+import { useSessionLayout } from "@/pages/session/session-layout"
+import { createSessionTabs } from "@/pages/session/helpers"
 
 interface SessionContextUsageProps {
   variant?: "button" | "indicator"
@@ -27,14 +29,17 @@ function openSessionContext(args: {
 
 export function SessionContextUsage(props: SessionContextUsageProps) {
   const sync = useSync()
-  const params = useParams()
+  const file = useFile()
   const layout = useLayout()
   const language = useLanguage()
+  const { params, tabs, view } = useSessionLayout()
 
   const variant = createMemo(() => props.variant ?? "button")
-  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
-  const tabs = createMemo(() => layout.tabs(sessionKey))
-  const view = createMemo(() => layout.view(sessionKey))
+  const tabState = createSessionTabs({
+    tabs,
+    pathFromTab: file.pathFromTab,
+    normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
+  })
   const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
 
   const usd = createMemo(
@@ -54,7 +59,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
   const openContext = () => {
     if (!params.id) return
 
-    if (tabs().active() === "context") {
+    if (tabState.activeTab() === "context") {
       tabs().close("context")
       return
     }

+ 2 - 6
packages/app/src/components/session/session-context-tab.tsx

@@ -1,8 +1,6 @@
 import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
 import type { JSX } from "solid-js"
-import { useParams } from "@solidjs/router"
 import { useSync } from "@/context/sync"
-import { useLayout } from "@/context/layout"
 import { checksum } from "@opencode-ai/util/encode"
 import { findLast } from "@opencode-ai/util/array"
 import { same } from "@/utils/same"
@@ -14,6 +12,7 @@ import { Markdown } from "@opencode-ai/ui/markdown"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
 import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
 import { useLanguage } from "@/context/language"
+import { useSessionLayout } from "@/pages/session/session-layout"
 import { getSessionContextMetrics } from "./session-context-metrics"
 import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
 import { createSessionContextFormatter } from "./session-context-format"
@@ -91,13 +90,10 @@ const emptyMessages: Message[] = []
 const emptyUserMessages: UserMessage[] = []
 
 export function SessionContextTab() {
-  const params = useParams()
   const sync = useSync()
-  const layout = useLayout()
   const language = useLanguage()
+  const { params, view } = useSessionLayout()
 
-  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
-  const view = createMemo(() => layout.view(sessionKey))
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
 
   const messages = createMemo(

+ 58 - 302
packages/app/src/components/session/session-header.tsx

@@ -4,18 +4,14 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Keybind } from "@opencode-ai/ui/keybind"
-import { Popover } from "@opencode-ai/ui/popover"
 import { Spinner } from "@opencode-ai/ui/spinner"
-import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast } from "@opencode-ai/ui/toast"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { getFilename } from "@opencode-ai/util/path"
-import { useParams } from "@solidjs/router"
 import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
 import { createStore } from "solid-js/store"
 import { Portal } from "solid-js/web"
 import { useCommand } from "@/context/command"
-import { useGlobalSDK } from "@/context/global-sdk"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 import { usePlatform } from "@/context/platform"
@@ -23,6 +19,8 @@ import { useServer } from "@/context/server"
 import { useSync } from "@/context/sync"
 import { useTerminal } from "@/context/terminal"
 import { focusTerminalById } from "@/pages/session/helpers"
+import { useSessionLayout } from "@/pages/session/session-layout"
+import { messageAgentColor } from "@/utils/agent"
 import { decode64 } from "@/utils/base64"
 import { Persist, persisted } from "@/utils/persist"
 import { StatusPopover } from "../status-popover"
@@ -50,74 +48,68 @@ type OS = "macos" | "windows" | "linux" | "unknown"
 const MAC_APPS = [
   {
     id: "vscode",
-    label: "VS Code",
+    label: "session.header.open.app.vscode",
     icon: "vscode",
     openWith: "Visual Studio Code",
   },
-  { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
-  { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
-  { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
+  { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
+  { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
+  { id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
   {
     id: "antigravity",
-    label: "Antigravity",
+    label: "session.header.open.app.antigravity",
     icon: "antigravity",
     openWith: "Antigravity",
   },
-  { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
-  { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
-  { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
-  { id: "warp", label: "Warp", icon: "warp", openWith: "Warp" },
-  { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
+  { id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
+  { id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
+  { id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
+  { id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
+  { id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
   {
     id: "android-studio",
-    label: "Android Studio",
+    label: "session.header.open.app.androidStudio",
     icon: "android-studio",
     openWith: "Android Studio",
   },
   {
     id: "sublime-text",
-    label: "Sublime Text",
+    label: "session.header.open.app.sublimeText",
     icon: "sublime-text",
     openWith: "Sublime Text",
   },
 ] as const
 
 const WINDOWS_APPS = [
-  { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
-  { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
-  { id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
+  { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
+  { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
+  { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
   {
     id: "powershell",
-    label: "PowerShell",
+    label: "session.header.open.app.powershell",
     icon: "powershell",
     openWith: "powershell",
   },
   {
     id: "sublime-text",
-    label: "Sublime Text",
+    label: "session.header.open.app.sublimeText",
     icon: "sublime-text",
     openWith: "Sublime Text",
   },
 ] as const
 
 const LINUX_APPS = [
-  { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
-  { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
-  { id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
+  { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
+  { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
+  { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
   {
     id: "sublime-text",
-    label: "Sublime Text",
+    label: "session.header.open.app.sublimeText",
     icon: "sublime-text",
     openWith: "Sublime Text",
   },
 ] as const
 
-type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number]
-type OpenIcon = OpenApp | "file-explorer"
-const OPEN_ICON_BASE = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
-
-const openIconSize = (id: OpenIcon) => (OPEN_ICON_BASE.has(id) ? "size-4" : "size-[19px]")
-
 const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
   if (platform.platform === "desktop" && platform.os) return platform.os
   if (typeof navigator !== "object") return "unknown"
@@ -136,102 +128,15 @@ const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown
   })
 }
 
-function useSessionShare(args: {
-  globalSDK: ReturnType<typeof useGlobalSDK>
-  currentSession: () =>
-    | {
-        share?: {
-          url?: string
-        }
-      }
-    | undefined
-  sessionID: () => string | undefined
-  projectDirectory: () => string
-  platform: ReturnType<typeof usePlatform>
-}) {
-  const [state, setState] = createStore({
-    share: false,
-    unshare: false,
-    copied: false,
-    timer: undefined as number | undefined,
-  })
-  const shareUrl = createMemo(() => args.currentSession()?.share?.url)
-
-  createEffect(() => {
-    const url = shareUrl()
-    if (url) return
-    if (state.timer) window.clearTimeout(state.timer)
-    setState({ copied: false, timer: undefined })
-  })
-
-  onCleanup(() => {
-    if (state.timer) window.clearTimeout(state.timer)
-  })
-
-  const shareSession = () => {
-    const sessionID = args.sessionID()
-    if (!sessionID || state.share) return
-    setState("share", true)
-    args.globalSDK.client.session
-      .share({ sessionID, directory: args.projectDirectory() })
-      .catch((error) => {
-        console.error("Failed to share session", error)
-      })
-      .finally(() => {
-        setState("share", false)
-      })
-  }
-
-  const unshareSession = () => {
-    const sessionID = args.sessionID()
-    if (!sessionID || state.unshare) return
-    setState("unshare", true)
-    args.globalSDK.client.session
-      .unshare({ sessionID, directory: args.projectDirectory() })
-      .catch((error) => {
-        console.error("Failed to unshare session", error)
-      })
-      .finally(() => {
-        setState("unshare", false)
-      })
-  }
-
-  const copyLink = (onError: (error: unknown) => void) => {
-    const url = shareUrl()
-    if (!url) return
-    navigator.clipboard
-      .writeText(url)
-      .then(() => {
-        if (state.timer) window.clearTimeout(state.timer)
-        setState("copied", true)
-        const timer = window.setTimeout(() => {
-          setState("copied", false)
-          setState("timer", undefined)
-        }, 3000)
-        setState("timer", timer)
-      })
-      .catch(onError)
-  }
-
-  const viewShare = () => {
-    const url = shareUrl()
-    if (!url) return
-    args.platform.openLink(url)
-  }
-
-  return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare }
-}
-
 export function SessionHeader() {
-  const globalSDK = useGlobalSDK()
   const layout = useLayout()
-  const params = useParams()
   const command = useCommand()
   const server = useServer()
-  const sync = useSync()
   const platform = usePlatform()
   const language = useLanguage()
+  const sync = useSync()
   const terminal = useTerminal()
+  const { params, view } = useSessionLayout()
 
   const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
   const project = createMemo(() => {
@@ -245,12 +150,6 @@ export function SessionHeader() {
     return getFilename(projectDirectory())
   })
   const hotkey = createMemo(() => command.keybind("file.open"))
-
-  const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
-  const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
-  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))
 
   const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
@@ -264,9 +163,9 @@ export function SessionHeader() {
   })
 
   const fileManager = createMemo(() => {
-    if (os() === "macos") return { label: "Finder", icon: "finder" as const }
-    if (os() === "windows") return { label: "File Explorer", icon: "file-explorer" as const }
-    return { label: "File Manager", icon: "finder" as const }
+    if (os() === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
+    if (os() === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
+    return { label: "session.header.open.fileManager", icon: "finder" as const }
   })
 
   createEffect(() => {
@@ -282,10 +181,7 @@ export function SessionHeader() {
         Promise.resolve(platform.checkAppExists?.(app.openWith))
           .then((value) => Boolean(value))
           .catch(() => false)
-          .then((ok) => {
-            console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`)
-            return [app.id, ok] as const
-          }),
+          .then((ok) => [app.id, ok] as const),
       ),
     ).then((entries) => {
       setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
@@ -294,8 +190,10 @@ export function SessionHeader() {
 
   const options = createMemo(() => {
     return [
-      { id: "finder", label: fileManager().label, icon: fileManager().icon },
-      ...apps().filter((app) => exists[app.id]),
+      { id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
+      ...apps()
+        .filter((app) => exists[app.id])
+        .map((app) => ({ ...app, label: language.t(app.label) })),
     ] as const
   })
 
@@ -323,6 +221,9 @@ export function SessionHeader() {
       ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
   )
   const opening = createMemo(() => openRequest.app !== undefined)
+  const tint = createMemo(() =>
+    messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
+  )
 
   const selectApp = (app: OpenApp) => {
     if (!options().some((item) => item.id === app)) return
@@ -361,14 +262,6 @@ export function SessionHeader() {
       .catch((err: unknown) => showRequestError(language, err))
   }
 
-  const share = useSessionShare({
-    globalSDK,
-    currentSession,
-    sessionID: () => params.id,
-    projectDirectory,
-    platform,
-  })
-
   const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
   const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
 
@@ -381,12 +274,11 @@ export function SessionHeader() {
               type="button"
               variant="ghost"
               size="small"
-              class="hidden md:flex w-[240px] max-w-full min-w-0 pl-0.5 pr-2 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
+              class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
               onClick={() => command.trigger("file.open")}
               aria-label={language.t("session.header.searchFiles")}
             >
-              <div class="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible">
-                <Icon name="magnifying-glass" size="small" class="icon-base shrink-0 size-4" />
+              <div class="flex min-w-0 flex-1 items-center overflow-visible">
                 <span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
                   {language.t("session.header.search.placeholder", {
                     project: name(),
@@ -396,7 +288,9 @@ export function SessionHeader() {
 
               <Show when={hotkey()}>
                 {(keybind) => (
-                  <Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0">{keybind()}</Keybind>
+                  <Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
+                    {keybind()}
+                  </Keybind>
                 )}
               </Show>
             </Button>
@@ -407,7 +301,6 @@ export function SessionHeader() {
         {(mount) => (
           <Portal mount={mount()}>
             <div class="flex items-center gap-2">
-              <StatusPopover />
               <Show when={projectDirectory()}>
                 <div class="hidden xl:flex items-center">
                   <Show
@@ -432,7 +325,7 @@ export function SessionHeader() {
                       <div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
                         <Button
                           variant="ghost"
-                          class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none disabled:!cursor-default"
+                          class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
                           classList={{
                             "bg-surface-raised-base-active": opening(),
                           }}
@@ -440,17 +333,12 @@ export function SessionHeader() {
                           disabled={opening()}
                           aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
                         >
-                          <div class="flex size-5 shrink-0 items-center justify-center">
-                            <Show
-                              when={opening()}
-                              fallback={<AppIcon id={current().icon} class={openIconSize(current().icon)} />}
-                            >
-                              <Spinner class="size-3.5 text-icon-base" />
+                          <div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
+                            <Show when={opening()} fallback={<AppIcon id={current().icon} />}>
+                              <Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
                             </Show>
                           </div>
-                          <span class="text-12-regular text-text-strong">{language.t("common.open")}</span>
                         </Button>
-                        <div class="self-stretch w-px bg-border-weak-base" />
                         <DropdownMenu
                           gutter={4}
                           placement="bottom-end"
@@ -462,17 +350,20 @@ export function SessionHeader() {
                             icon="chevron-down"
                             variant="ghost"
                             disabled={opening()}
-                            class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
+                            class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
                             classList={{
                               "bg-surface-raised-base-active": opening(),
                             }}
                             aria-label={language.t("session.header.open.menu")}
                           />
                           <DropdownMenu.Portal>
-                            <DropdownMenu.Content>
+                            <DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
                               <DropdownMenu.Group>
-                                <DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
+                                <DropdownMenu.GroupLabel class="!px-1 !py-1">
+                                  {language.t("session.header.openIn")}
+                                </DropdownMenu.GroupLabel>
                                 <DropdownMenu.RadioGroup
+                                  class="mt-1"
                                   value={current().id}
                                   onChange={(value) => {
                                     if (!OPEN_APPS.includes(value as OpenApp)) return
@@ -489,8 +380,8 @@ export function SessionHeader() {
                                           openDir(o.id)
                                         }}
                                       >
-                                        <div class="flex size-5 shrink-0 items-center justify-center">
-                                          <AppIcon id={o.icon} class={openIconSize(o.icon)} />
+                                        <div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
+                                          <AppIcon id={o.icon} />
                                         </div>
                                         <DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
                                         <DropdownMenu.ItemIndicator>
@@ -523,113 +414,10 @@ export function SessionHeader() {
                   </Show>
                 </div>
               </Show>
-              <Show when={showShare()}>
-                <div class="flex items-center">
-                  <Popover
-                    title={language.t("session.share.popover.title")}
-                    description={
-                      share.shareUrl()
-                        ? language.t("session.share.popover.description.shared")
-                        : language.t("session.share.popover.description.unshared")
-                    }
-                    gutter={4}
-                    placement="bottom-end"
-                    shift={-64}
-                    class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
-                    triggerAs={Button}
-                    triggerProps={{
-                      variant: "ghost",
-                      class:
-                        "rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
-                      classList: {
-                        "rounded-r-none": share.shareUrl() !== undefined,
-                        "border-r-0": share.shareUrl() !== undefined,
-                      },
-                      style: { scale: 1 },
-                    }}
-                    trigger={<span class="text-12-regular">{language.t("session.share.action.share")}</span>}
-                  >
-                    <div class="flex flex-col gap-2">
-                      <Show
-                        when={share.shareUrl()}
-                        fallback={
-                          <div class="flex">
-                            <Button
-                              size="large"
-                              variant="primary"
-                              class="w-1/2"
-                              onClick={share.shareSession}
-                              disabled={share.state.share}
-                            >
-                              {share.state.share
-                                ? language.t("session.share.action.publishing")
-                                : language.t("session.share.action.publish")}
-                            </Button>
-                          </div>
-                        }
-                      >
-                        <div class="flex flex-col gap-2">
-                          <TextField
-                            value={share.shareUrl() ?? ""}
-                            readOnly
-                            copyable
-                            copyKind="link"
-                            tabIndex={-1}
-                            class="w-full"
-                          />
-                          <div class="grid grid-cols-2 gap-2">
-                            <Button
-                              size="large"
-                              variant="secondary"
-                              class="w-full shadow-none border border-border-weak-base"
-                              onClick={share.unshareSession}
-                              disabled={share.state.unshare}
-                            >
-                              {share.state.unshare
-                                ? language.t("session.share.action.unpublishing")
-                                : language.t("session.share.action.unpublish")}
-                            </Button>
-                            <Button
-                              size="large"
-                              variant="primary"
-                              class="w-full"
-                              onClick={share.viewShare}
-                              disabled={share.state.unshare}
-                            >
-                              {language.t("session.share.action.view")}
-                            </Button>
-                          </div>
-                        </div>
-                      </Show>
-                    </div>
-                  </Popover>
-                  <Show when={share.shareUrl()} fallback={<div aria-hidden="true" />}>
-                    <Tooltip
-                      value={
-                        share.state.copied
-                          ? language.t("session.share.copy.copied")
-                          : language.t("session.share.copy.copyLink")
-                      }
-                      placement="top"
-                      gutter={8}
-                    >
-                      <IconButton
-                        icon={share.state.copied ? "check" : "link"}
-                        variant="ghost"
-                        class="rounded-l-none h-[24px] border border-border-weak-base bg-surface-panel shadow-none"
-                        onClick={() => share.copyLink((error) => showRequestError(language, error))}
-                        disabled={share.state.unshare}
-                        aria-label={
-                          share.state.copied
-                            ? language.t("session.share.copy.copied")
-                            : language.t("session.share.copy.copyLink")
-                        }
-                      />
-                    </Tooltip>
-                  </Show>
-                </div>
-              </Show>
               <div class="flex items-center gap-1">
+                <Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
+                  <StatusPopover />
+                </Tooltip>
                 <TooltipKeybind
                   title={language.t("command.terminal.toggle")}
                   keybind={command.keybind("terminal.toggle")}
@@ -642,23 +430,7 @@ export function SessionHeader() {
                     aria-expanded={view().terminal.opened()}
                     aria-controls="terminal-panel"
                   >
-                    <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
-                      <Icon
-                        size="small"
-                        name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
-                        class="group-hover/terminal-toggle:hidden"
-                      />
-                      <Icon
-                        size="small"
-                        name="layout-bottom-partial"
-                        class="hidden group-hover/terminal-toggle:inline-block"
-                      />
-                      <Icon
-                        size="small"
-                        name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
-                        class="hidden group-active/terminal-toggle:inline-block"
-                      />
-                    </div>
+                    <Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
                   </Button>
                 </TooltipKeybind>
 
@@ -675,23 +447,7 @@ export function SessionHeader() {
                       aria-expanded={view().reviewPanel.opened()}
                       aria-controls="review-panel"
                     >
-                      <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
-                        <Icon
-                          size="small"
-                          name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-right"}
-                          class="group-hover/review-toggle:hidden"
-                        />
-                        <Icon
-                          size="small"
-                          name="layout-right-partial"
-                          class="hidden group-hover/review-toggle:inline-block"
-                        />
-                        <Icon
-                          size="small"
-                          name={view().reviewPanel.opened() ? "layout-right" : "layout-right-partial"}
-                          class="hidden group-active/review-toggle:inline-block"
-                        />
-                      </div>
+                      <Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
                     </Button>
                   </TooltipKeybind>
 

+ 0 - 1
packages/app/src/components/session/session-new-view.tsx

@@ -13,7 +13,6 @@ const ROOT_CLASS = "size-full flex flex-col"
 
 interface NewSessionViewProps {
   worktree: string
-  onWorktreeChange: (value: string) => void
 }
 
 export function NewSessionView(props: NewSessionViewProps) {

+ 5 - 20
packages/app/src/components/session/session-sortable-terminal-tab.tsx

@@ -6,8 +6,10 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Icon } from "@opencode-ai/ui/icon"
+import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title"
 import { useTerminal, type LocalPTY } from "@/context/terminal"
 import { useLanguage } from "@/context/language"
+import { focusTerminalById } from "@/pages/session/helpers"
 
 export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element {
   const terminal = useTerminal()
@@ -26,11 +28,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
   const isDefaultTitle = () => {
     const number = props.terminal.titleNumber
     if (!Number.isFinite(number) || number <= 0) return false
-    const match = props.terminal.title.match(/^Terminal (\d+)$/)
-    if (!match) return false
-    const parsed = Number(match[1])
-    if (!Number.isFinite(parsed) || parsed <= 0) return false
-    return parsed === number
+    return isDefaultTerminalTitle(props.terminal.title, number)
   }
 
   const label = () => {
@@ -53,21 +51,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
 
   const focus = () => {
     if (store.editing) return
-
-    if (document.activeElement instanceof HTMLElement) {
-      document.activeElement.blur()
-    }
-    const wrapper = document.getElementById(`terminal-wrapper-${props.terminal.id}`)
-    const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
-    if (!element) return
-
-    const textarea = element.querySelector("textarea") as HTMLTextAreaElement
-    if (textarea) {
-      textarea.focus()
-      return
-    }
-    element.focus()
-    element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
+    if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
+    focusTerminalById(props.terminal.id)
   }
 
   const edit = (e?: Event) => {

+ 0 - 16
packages/app/src/components/settings-agents.tsx

@@ -1,16 +0,0 @@
-import { Component } from "solid-js"
-import { useLanguage } from "@/context/language"
-
-export const SettingsAgents: Component = () => {
-  // TODO: Replace this placeholder with full agents settings controls.
-  const language = useLanguage()
-
-  return (
-    <div class="flex flex-col h-full overflow-y-auto">
-      <div class="flex flex-col gap-6 p-6 max-w-[600px]">
-        <h2 class="text-16-medium text-text-strong">{language.t("settings.agents.title")}</h2>
-        <p class="text-14-regular text-text-weak">{language.t("settings.agents.description")}</p>
-      </div>
-    </div>
-  )
-}

+ 0 - 16
packages/app/src/components/settings-commands.tsx

@@ -1,16 +0,0 @@
-import { Component } from "solid-js"
-import { useLanguage } from "@/context/language"
-
-export const SettingsCommands: Component = () => {
-  // TODO: Replace this placeholder with full commands settings controls.
-  const language = useLanguage()
-
-  return (
-    <div class="flex flex-col h-full overflow-y-auto">
-      <div class="flex flex-col gap-6 p-6 max-w-[600px]">
-        <h2 class="text-16-medium text-text-strong">{language.t("settings.commands.title")}</h2>
-        <p class="text-14-regular text-text-weak">{language.t("settings.commands.description")}</p>
-      </div>
-    </div>
-  )
-}

+ 89 - 66
packages/app/src/components/settings-general.tsx

@@ -12,6 +12,7 @@ import { usePlatform } from "@/context/platform"
 import { useSettings, monoFontFamily } from "@/context/settings"
 import { playSound, SOUND_OPTIONS } from "@/utils/sound"
 import { Link } from "./link"
+import { SettingsList } from "./settings-list"
 
 let demoSoundState = {
   cleanup: undefined as (() => void) | undefined,
@@ -113,6 +114,11 @@ export const SettingsGeneral: Component = () => {
     { value: "dark", label: language.t("theme.scheme.dark") },
   ])
 
+  const followupOptions = createMemo((): { value: "queue" | "steer"; label: string }[] => [
+    { value: "queue", label: language.t("settings.general.row.followup.option.queue") },
+    { value: "steer", label: language.t("settings.general.row.followup.option.steer") },
+  ])
+
   const languageOptions = createMemo(() =>
     language.locales.map((locale) => ({
       value: locale,
@@ -170,11 +176,9 @@ export const SettingsGeneral: Component = () => {
     triggerVariant: "settings" as const,
   })
 
-  const AppearanceSection = () => (
+  const GeneralSection = () => (
     <div class="flex flex-col gap-1">
-      <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
-
-      <div class="bg-surface-raised-base px-4 rounded-lg">
+      <SettingsList>
         <SettingsRow
           title={language.t("settings.general.row.language.title")}
           description={language.t("settings.general.row.language.description")}
@@ -193,8 +197,70 @@ export const SettingsGeneral: Component = () => {
         </SettingsRow>
 
         <SettingsRow
-          title={language.t("settings.general.row.appearance.title")}
-          description={language.t("settings.general.row.appearance.description")}
+          title={language.t("settings.general.row.reasoningSummaries.title")}
+          description={language.t("settings.general.row.reasoningSummaries.description")}
+        >
+          <div data-action="settings-feed-reasoning-summaries">
+            <Switch
+              checked={settings.general.showReasoningSummaries()}
+              onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
+            />
+          </div>
+        </SettingsRow>
+
+        <SettingsRow
+          title={language.t("settings.general.row.shellToolPartsExpanded.title")}
+          description={language.t("settings.general.row.shellToolPartsExpanded.description")}
+        >
+          <div data-action="settings-feed-shell-tool-parts-expanded">
+            <Switch
+              checked={settings.general.shellToolPartsExpanded()}
+              onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
+            />
+          </div>
+        </SettingsRow>
+
+        <SettingsRow
+          title={language.t("settings.general.row.editToolPartsExpanded.title")}
+          description={language.t("settings.general.row.editToolPartsExpanded.description")}
+        >
+          <div data-action="settings-feed-edit-tool-parts-expanded">
+            <Switch
+              checked={settings.general.editToolPartsExpanded()}
+              onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
+            />
+          </div>
+        </SettingsRow>
+
+        <SettingsRow
+          title={language.t("settings.general.row.followup.title")}
+          description={language.t("settings.general.row.followup.description")}
+        >
+          <Select
+            data-action="settings-followup"
+            options={followupOptions()}
+            current={followupOptions().find((o) => o.value === settings.general.followup())}
+            value={(o) => o.value}
+            label={(o) => o.label}
+            onSelect={(option) => option && settings.general.setFollowup(option.value)}
+            variant="secondary"
+            size="small"
+            triggerVariant="settings"
+            triggerStyle={{ "min-width": "180px" }}
+          />
+        </SettingsRow>
+      </SettingsList>
+    </div>
+  )
+
+  const AppearanceSection = () => (
+    <div class="flex flex-col gap-1">
+      <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
+
+      <SettingsList>
+        <SettingsRow
+          title={language.t("settings.general.row.colorScheme.title")}
+          description={language.t("settings.general.row.colorScheme.description")}
         >
           <Select
             data-action="settings-color-scheme"
@@ -211,6 +277,7 @@ export const SettingsGeneral: Component = () => {
             variant="secondary"
             size="small"
             triggerVariant="settings"
+            triggerStyle={{ "min-width": "220px" }}
           />
         </SettingsRow>
 
@@ -267,51 +334,7 @@ export const SettingsGeneral: Component = () => {
             )}
           </Select>
         </SettingsRow>
-      </div>
-    </div>
-  )
-
-  const FeedSection = () => (
-    <div class="flex flex-col gap-1">
-      <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.feed")}</h3>
-
-      <div class="bg-surface-raised-base px-4 rounded-lg">
-        <SettingsRow
-          title={language.t("settings.general.row.reasoningSummaries.title")}
-          description={language.t("settings.general.row.reasoningSummaries.description")}
-        >
-          <div data-action="settings-feed-reasoning-summaries">
-            <Switch
-              checked={settings.general.showReasoningSummaries()}
-              onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
-            />
-          </div>
-        </SettingsRow>
-
-        <SettingsRow
-          title={language.t("settings.general.row.shellToolPartsExpanded.title")}
-          description={language.t("settings.general.row.shellToolPartsExpanded.description")}
-        >
-          <div data-action="settings-feed-shell-tool-parts-expanded">
-            <Switch
-              checked={settings.general.shellToolPartsExpanded()}
-              onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
-            />
-          </div>
-        </SettingsRow>
-
-        <SettingsRow
-          title={language.t("settings.general.row.editToolPartsExpanded.title")}
-          description={language.t("settings.general.row.editToolPartsExpanded.description")}
-        >
-          <div data-action="settings-feed-edit-tool-parts-expanded">
-            <Switch
-              checked={settings.general.editToolPartsExpanded()}
-              onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
-            />
-          </div>
-        </SettingsRow>
-      </div>
+      </SettingsList>
     </div>
   )
 
@@ -319,7 +342,7 @@ export const SettingsGeneral: Component = () => {
     <div class="flex flex-col gap-1">
       <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
 
-      <div class="bg-surface-raised-base px-4 rounded-lg">
+      <SettingsList>
         <SettingsRow
           title={language.t("settings.general.notifications.agent.title")}
           description={language.t("settings.general.notifications.agent.description")}
@@ -355,7 +378,7 @@ export const SettingsGeneral: Component = () => {
             />
           </div>
         </SettingsRow>
-      </div>
+      </SettingsList>
     </div>
   )
 
@@ -363,7 +386,7 @@ export const SettingsGeneral: Component = () => {
     <div class="flex flex-col gap-1">
       <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
 
-      <div class="bg-surface-raised-base px-4 rounded-lg">
+      <SettingsList>
         <SettingsRow
           title={language.t("settings.general.sounds.agent.title")}
           description={language.t("settings.general.sounds.agent.description")}
@@ -408,7 +431,7 @@ export const SettingsGeneral: Component = () => {
             )}
           />
         </SettingsRow>
-      </div>
+      </SettingsList>
     </div>
   )
 
@@ -416,7 +439,7 @@ export const SettingsGeneral: Component = () => {
     <div class="flex flex-col gap-1">
       <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
 
-      <div class="bg-surface-raised-base px-4 rounded-lg">
+      <SettingsList>
         <SettingsRow
           title={language.t("settings.updates.row.startup.title")}
           description={language.t("settings.updates.row.startup.description")}
@@ -452,7 +475,7 @@ export const SettingsGeneral: Component = () => {
               : language.t("settings.updates.action.checkNow")}
           </Button>
         </SettingsRow>
-      </div>
+      </SettingsList>
     </div>
   )
 
@@ -465,9 +488,9 @@ export const SettingsGeneral: Component = () => {
       </div>
 
       <div class="flex flex-col gap-8 w-full">
-        <AppearanceSection />
+        <GeneralSection />
 
-        <FeedSection />
+        <AppearanceSection />
 
         <NotificationsSection />
 
@@ -482,7 +505,7 @@ export const SettingsGeneral: Component = () => {
               <div class="flex flex-col gap-1">
                 <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.desktop.section.wsl")}</h3>
 
-                <div class="bg-surface-raised-base px-4 rounded-lg">
+                <SettingsList>
                   <SettingsRow
                     title={language.t("settings.desktop.wsl.title")}
                     description={language.t("settings.desktop.wsl.description")}
@@ -495,7 +518,7 @@ export const SettingsGeneral: Component = () => {
                       />
                     </div>
                   </SettingsRow>
-                </div>
+                </SettingsList>
               </div>
             )
           }}
@@ -515,7 +538,7 @@ export const SettingsGeneral: Component = () => {
               <div class="flex flex-col gap-1">
                 <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
 
-                <div class="bg-surface-raised-base px-4 rounded-lg">
+                <SettingsList>
                   <SettingsRow
                     title={
                       <div class="flex items-center gap-2">
@@ -533,7 +556,7 @@ export const SettingsGeneral: Component = () => {
                       <Switch checked={value() === "wayland"} onChange={onChange} />
                     </div>
                   </SettingsRow>
-                </div>
+                </SettingsList>
               </div>
             )
           }}
@@ -551,12 +574,12 @@ interface SettingsRowProps {
 
 const SettingsRow: Component<SettingsRowProps> = (props) => {
   return (
-    <div class="flex flex-wrap items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
-      <div class="flex flex-col gap-0.5 min-w-0">
+    <div class="flex flex-wrap items-center gap-4 py-3 border-b border-border-weak-base last:border-none sm:flex-nowrap">
+      <div class="flex min-w-0 flex-1 flex-col gap-0.5">
         <span class="text-14-medium text-text-strong">{props.title}</span>
         <span class="text-12-regular text-text-weak">{props.description}</span>
       </div>
-      <div class="flex-shrink-0">{props.children}</div>
+      <div class="flex w-full justify-end sm:w-auto sm:shrink-0">{props.children}</div>
     </div>
   )
 }

+ 4 - 3
packages/app/src/components/settings-keybinds.tsx

@@ -9,6 +9,7 @@ import fuzzysort from "fuzzysort"
 import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
 import { useLanguage } from "@/context/language"
 import { useSettings } from "@/context/settings"
+import { SettingsList } from "./settings-list"
 
 const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
 const PALETTE_ID = "command.palette"
@@ -238,7 +239,7 @@ function useKeyCapture(input: {
         showToast({
           title: input.language.t("settings.shortcuts.conflict.title"),
           description: input.language.t("settings.shortcuts.conflict.description", {
-            keybind: formatKeybind(next),
+            keybind: formatKeybind(next, input.language.t),
             titles: [...conflicts.values()].join(", "),
           }),
         })
@@ -406,7 +407,7 @@ export const SettingsKeybinds: Component = () => {
             <Show when={(filtered().get(group) ?? []).length > 0}>
               <div class="flex flex-col gap-1">
                 <h3 class="text-14-medium text-text-strong pb-2">{language.t(groupKey[group])}</h3>
-                <div class="bg-surface-raised-base px-4 rounded-lg">
+                <SettingsList>
                   <For each={filtered().get(group) ?? []}>
                     {(id) => (
                       <div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
@@ -432,7 +433,7 @@ export const SettingsKeybinds: Component = () => {
                       </div>
                     )}
                   </For>
-                </div>
+                </SettingsList>
               </div>
             </Show>
           )}

+ 5 - 0
packages/app/src/components/settings-list.tsx

@@ -0,0 +1,5 @@
+import { type Component, type JSX } from "solid-js"
+
+export const SettingsList: Component<{ children: JSX.Element }> = (props) => {
+  return <div class="bg-surface-base px-4 rounded-lg">{props.children}</div>
+}

+ 0 - 16
packages/app/src/components/settings-mcp.tsx

@@ -1,16 +0,0 @@
-import { Component } from "solid-js"
-import { useLanguage } from "@/context/language"
-
-export const SettingsMcp: Component = () => {
-  // TODO: Replace this placeholder with full MCP settings controls.
-  const language = useLanguage()
-
-  return (
-    <div class="flex flex-col h-full overflow-y-auto">
-      <div class="flex flex-col gap-6 p-6 max-w-[600px]">
-        <h2 class="text-16-medium text-text-strong">{language.t("settings.mcp.title")}</h2>
-        <p class="text-14-regular text-text-weak">{language.t("settings.mcp.description")}</p>
-      </div>
-    </div>
-  )
-}

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

@@ -8,6 +8,7 @@ import { type Component, For, Show } from "solid-js"
 import { useLanguage } from "@/context/language"
 import { useModels } from "@/context/models"
 import { popularProviders } from "@/hooks/use-providers"
+import { SettingsList } from "./settings-list"
 
 type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
 
@@ -100,7 +101,7 @@ export const SettingsModels: Component = () => {
                     <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">
+                  <SettingsList>
                     <For each={group.items}>
                       {(item) => {
                         const key = { providerID: item.provider.id, modelID: item.id }
@@ -124,7 +125,7 @@ export const SettingsModels: Component = () => {
                         )
                       }}
                     </For>
-                  </div>
+                  </SettingsList>
                 </div>
               )}
             </For>

+ 0 - 230
packages/app/src/components/settings-permissions.tsx

@@ -1,230 +0,0 @@
-import { Select } from "@opencode-ai/ui/select"
-import { showToast } from "@opencode-ai/ui/toast"
-import { Component, For, createMemo, type JSX } from "solid-js"
-import { useGlobalSync } from "@/context/global-sync"
-import { useLanguage } from "@/context/language"
-
-type PermissionAction = "allow" | "ask" | "deny"
-
-type PermissionObject = Record<string, PermissionAction>
-type PermissionValue = PermissionAction | PermissionObject | string[] | undefined
-type PermissionMap = Record<string, PermissionValue>
-
-type PermissionItem = {
-  id: string
-  title: string
-  description: string
-}
-
-const ACTIONS = [
-  { value: "allow", label: "settings.permissions.action.allow" },
-  { value: "ask", label: "settings.permissions.action.ask" },
-  { value: "deny", label: "settings.permissions.action.deny" },
-] as const
-
-const ITEMS = [
-  {
-    id: "read",
-    title: "settings.permissions.tool.read.title",
-    description: "settings.permissions.tool.read.description",
-  },
-  {
-    id: "edit",
-    title: "settings.permissions.tool.edit.title",
-    description: "settings.permissions.tool.edit.description",
-  },
-  {
-    id: "glob",
-    title: "settings.permissions.tool.glob.title",
-    description: "settings.permissions.tool.glob.description",
-  },
-  {
-    id: "grep",
-    title: "settings.permissions.tool.grep.title",
-    description: "settings.permissions.tool.grep.description",
-  },
-  {
-    id: "list",
-    title: "settings.permissions.tool.list.title",
-    description: "settings.permissions.tool.list.description",
-  },
-  {
-    id: "bash",
-    title: "settings.permissions.tool.bash.title",
-    description: "settings.permissions.tool.bash.description",
-  },
-  {
-    id: "task",
-    title: "settings.permissions.tool.task.title",
-    description: "settings.permissions.tool.task.description",
-  },
-  {
-    id: "skill",
-    title: "settings.permissions.tool.skill.title",
-    description: "settings.permissions.tool.skill.description",
-  },
-  {
-    id: "lsp",
-    title: "settings.permissions.tool.lsp.title",
-    description: "settings.permissions.tool.lsp.description",
-  },
-  {
-    id: "todoread",
-    title: "settings.permissions.tool.todoread.title",
-    description: "settings.permissions.tool.todoread.description",
-  },
-  {
-    id: "todowrite",
-    title: "settings.permissions.tool.todowrite.title",
-    description: "settings.permissions.tool.todowrite.description",
-  },
-  {
-    id: "webfetch",
-    title: "settings.permissions.tool.webfetch.title",
-    description: "settings.permissions.tool.webfetch.description",
-  },
-  {
-    id: "websearch",
-    title: "settings.permissions.tool.websearch.title",
-    description: "settings.permissions.tool.websearch.description",
-  },
-  {
-    id: "codesearch",
-    title: "settings.permissions.tool.codesearch.title",
-    description: "settings.permissions.tool.codesearch.description",
-  },
-  {
-    id: "external_directory",
-    title: "settings.permissions.tool.external_directory.title",
-    description: "settings.permissions.tool.external_directory.description",
-  },
-  {
-    id: "doom_loop",
-    title: "settings.permissions.tool.doom_loop.title",
-    description: "settings.permissions.tool.doom_loop.description",
-  },
-] as const
-
-const VALID_ACTIONS = new Set<PermissionAction>(["allow", "ask", "deny"])
-
-function toMap(value: unknown): PermissionMap {
-  if (value && typeof value === "object" && !Array.isArray(value)) return value as PermissionMap
-
-  const action = getAction(value)
-  if (action) return { "*": action }
-
-  return {}
-}
-
-function getAction(value: unknown): PermissionAction | undefined {
-  if (typeof value === "string" && VALID_ACTIONS.has(value as PermissionAction)) return value as PermissionAction
-  return
-}
-
-function getRuleDefault(value: unknown): PermissionAction | undefined {
-  const action = getAction(value)
-  if (action) return action
-
-  if (!value || typeof value !== "object" || Array.isArray(value)) return
-
-  return getAction((value as Record<string, unknown>)["*"])
-}
-
-export const SettingsPermissions: Component = () => {
-  const globalSync = useGlobalSync()
-  const language = useLanguage()
-
-  const actions = createMemo(
-    (): Array<{ value: PermissionAction; label: string }> =>
-      ACTIONS.map((action) => ({
-        value: action.value,
-        label: language.t(action.label),
-      })),
-  )
-
-  const permission = createMemo(() => {
-    return toMap(globalSync.data.config.permission)
-  })
-
-  const actionFor = (id: string): PermissionAction => {
-    const value = permission()[id]
-    const direct = getRuleDefault(value)
-    if (direct) return direct
-
-    const wildcard = getRuleDefault(permission()["*"])
-    if (wildcard) return wildcard
-
-    return "allow"
-  }
-
-  const setPermission = async (id: string, action: PermissionAction) => {
-    const before = globalSync.data.config.permission
-    const map = toMap(before)
-    const existing = map[id]
-
-    const nextValue =
-      existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action
-
-    const rollback = (err: unknown) => {
-      globalSync.set("config", "permission", before)
-      const message = err instanceof Error ? err.message : String(err)
-      showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message })
-    }
-
-    globalSync.set("config", "permission", { ...map, [id]: nextValue })
-    globalSync.updateConfig({ permission: { [id]: nextValue } }).catch(rollback)
-  }
-
-  return (
-    <div class="flex flex-col h-full overflow-y-auto no-scrollbar">
-      <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
-        <div class="flex flex-col gap-1 px-4 py-8 sm:p-8 max-w-[720px]">
-          <h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2>
-          <p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p>
-        </div>
-      </div>
-
-      <div class="flex flex-col gap-6 px-4 py-6 sm:p-8 sm:pt-6 max-w-[720px]">
-        <div class="flex flex-col gap-2">
-          <h3 class="text-14-medium text-text-strong">{language.t("settings.permissions.section.tools")}</h3>
-          <div class="border border-border-weak-base rounded-lg overflow-hidden">
-            <For each={ITEMS}>
-              {(item) => (
-                <SettingsRow title={language.t(item.title)} description={language.t(item.description)}>
-                  <Select
-                    options={actions()}
-                    current={actions().find((o) => o.value === actionFor(item.id))}
-                    value={(o) => o.value}
-                    label={(o) => o.label}
-                    onSelect={(option) => option && setPermission(item.id, option.value)}
-                    variant="secondary"
-                    size="small"
-                    triggerVariant="settings"
-                  />
-                </SettingsRow>
-              )}
-            </For>
-          </div>
-        </div>
-      </div>
-    </div>
-  )
-}
-
-interface SettingsRowProps {
-  title: string
-  description: string
-  children: JSX.Element
-}
-
-const SettingsRow: Component<SettingsRowProps> = (props) => {
-  return (
-    <div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
-      <div class="flex flex-col gap-0.5 min-w-0">
-        <span class="text-14-medium text-text-strong">{props.title}</span>
-        <span class="text-12-regular text-text-weak">{props.description}</span>
-      </div>
-      <div class="flex-shrink-0">{props.children}</div>
-    </div>
-  )
-}

+ 5 - 4
packages/app/src/components/settings-providers.tsx

@@ -11,6 +11,7 @@ import { useGlobalSync } from "@/context/global-sync"
 import { DialogConnectProvider } from "./dialog-connect-provider"
 import { DialogSelectProvider } from "./dialog-select-provider"
 import { DialogCustomProvider } from "./dialog-custom-provider"
+import { SettingsList } from "./settings-list"
 
 type ProviderSource = "env" | "api" | "config" | "custom"
 type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
@@ -136,7 +137,7 @@ export const SettingsProviders: Component = () => {
       <div class="flex flex-col gap-8 max-w-[720px]">
         <div class="flex flex-col gap-1" data-component="connected-providers-section">
           <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
-          <div class="bg-surface-raised-base px-4 rounded-lg">
+          <SettingsList>
             <Show
               when={connected().length > 0}
               fallback={
@@ -169,12 +170,12 @@ export const SettingsProviders: Component = () => {
                 )}
               </For>
             </Show>
-          </div>
+          </SettingsList>
         </div>
 
         <div class="flex flex-col gap-1">
           <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.popular")}</h3>
-          <div class="bg-surface-raised-base px-4 rounded-lg">
+          <SettingsList>
             <For each={popular()}>
               {(item) => (
                 <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">
@@ -232,7 +233,7 @@ export const SettingsProviders: Component = () => {
                 {language.t("common.connect")}
               </Button>
             </div>
-          </div>
+          </SettingsList>
 
           <Button
             variant="ghost"

+ 26 - 18
packages/app/src/components/status-popover.tsx

@@ -14,7 +14,7 @@ import { usePlatform } from "@/context/platform"
 import { useSDK } from "@/context/sdk"
 import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
 import { useSync } from "@/context/sync"
-import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
+import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
 import { DialogSelectServer } from "./dialog-select-server"
 
 const pollMs = 10_000
@@ -53,7 +53,8 @@ const listServersByHealth = (
   })
 }
 
-const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
+const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
+  const checkServerHealth = useCheckServerHealth()
   const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
 
   createEffect(() => {
@@ -64,7 +65,7 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typ
       const results: Record<string, ServerHealth> = {}
       await Promise.all(
         list.map(async (conn) => {
-          results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
+          results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
         }),
       )
       if (dead) return
@@ -85,15 +86,17 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typ
 const useDefaultServerKey = (
   get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
 ) => {
-  const [url, setUrl] = createSignal<string | undefined>()
-  const [tick, setTick] = createSignal(0)
+  const [state, setState] = createStore({
+    url: undefined as string | undefined,
+    tick: 0,
+  })
 
   createEffect(() => {
-    tick()
+    state.tick
     let dead = false
     const result = get?.()
     if (!result) {
-      setUrl(undefined)
+      setState("url", undefined)
       onCleanup(() => {
         dead = true
       })
@@ -103,7 +106,7 @@ const useDefaultServerKey = (
     if (result instanceof Promise) {
       void result.then((next) => {
         if (dead) return
-        setUrl(next ? normalizeServerUrl(next) : undefined)
+        setState("url", next ? normalizeServerUrl(next) : undefined)
       })
       onCleanup(() => {
         dead = true
@@ -111,7 +114,7 @@ const useDefaultServerKey = (
       return
     }
 
-    setUrl(normalizeServerUrl(result))
+    setState("url", normalizeServerUrl(result))
     onCleanup(() => {
       dead = true
     })
@@ -119,11 +122,11 @@ const useDefaultServerKey = (
 
   return {
     key: () => {
-      const u = url()
+      const u = state.url
       if (!u) return
       return ServerConnection.key({ type: "http", http: { url: u } })
     },
-    refresh: () => setTick((value) => value + 1),
+    refresh: () => setState("tick", (value) => value + 1),
   }
 }
 
@@ -168,7 +171,7 @@ export function StatusPopover() {
   const language = useLanguage()
   const navigate = useNavigate()
 
-  const fetcher = platform.fetch ?? globalThis.fetch
+  const [shown, setShown] = createSignal(false)
   const servers = createMemo(() => {
     const current = server.current
     const list = server.list
@@ -176,10 +179,10 @@ export function StatusPopover() {
     if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
     return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
   })
-  const health = useServerHealth(servers, fetcher)
+  const health = useServerHealth(servers)
   const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
   const mcp = useMcpToggle({ sync, sdk, language })
-  const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
+  const defaultServer = useDefaultServerKey(platform.getDefaultServer)
   const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
   const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
   const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
@@ -199,18 +202,23 @@ export function StatusPopover() {
 
   return (
     <Popover
+      open={shown()}
+      onOpenChange={setShown}
       triggerAs={Button}
       triggerProps={{
         variant: "ghost",
-        class: "titlebar-icon w-6 h-6 p-0 box-border",
+        class: "titlebar-icon w-8 h-6 p-0 box-border",
         "aria-label": language.t("status.popover.trigger"),
         style: { scale: 1 },
       }}
       trigger={
-        <div class="flex size-4 items-center justify-center">
+        <div class="relative size-4">
+          <div class="badge-mask-tight size-4 flex items-center justify-center">
+            <Icon name={shown() ? "status-active" : "status"} size="small" />
+          </div>
           <div
             classList={{
-              "size-1.5 rounded-full": true,
+              "absolute -top-px -right-px 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,
@@ -269,8 +277,8 @@ export function StatusPopover() {
                         aria-disabled={isBlocked()}
                         onClick={() => {
                           if (isBlocked()) return
-                          server.setActive(key)
                           navigate("/")
+                          queueMicrotask(() => server.setActive(key))
                         }}
                       >
                         <ServerHealthIndicator health={health[key]} />

+ 148 - 70
packages/app/src/components/terminal.tsx

@@ -10,6 +10,7 @@ import { useSDK } from "@/context/sdk"
 import { useServer } from "@/context/server"
 import { monoFontFamily, useSettings } from "@/context/settings"
 import type { LocalPTY } from "@/context/terminal"
+import { terminalAttr, terminalProbe } from "@/testing/terminal"
 import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
 import { terminalWriter } from "@/utils/terminal-writer"
 
@@ -64,6 +65,13 @@ const debugTerminal = (...values: unknown[]) => {
   console.debug("[terminal]", ...values)
 }
 
+const errorName = (err: unknown) => {
+  if (!err || typeof err !== "object") return
+  if (!("name" in err)) return
+  const errorName = err.name
+  return typeof errorName === "string" ? errorName : undefined
+}
+
 const useTerminalUiBindings = (input: {
   container: HTMLDivElement
   term: Term
@@ -157,9 +165,16 @@ export const Terminal = (props: TerminalProps) => {
   const theme = useTheme()
   const language = useLanguage()
   const server = useServer()
+  const directory = sdk.directory
+  const client = sdk.client
+  const url = sdk.url
+  const auth = server.current?.http
+  const username = auth?.username ?? "opencode"
+  const password = auth?.password ?? ""
   let container!: HTMLDivElement
   const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
   const id = local.pty.id
+  const probe = terminalProbe(id)
   const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
   const restoreSize =
     restore &&
@@ -187,7 +202,11 @@ export const Terminal = (props: TerminalProps) => {
   const start =
     typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
   let cursor = start ?? 0
+  let seek = start !== undefined ? start : restore ? -1 : 0
   let output: ReturnType<typeof terminalWriter> | undefined
+  let drop: VoidFunction | undefined
+  let reconn: ReturnType<typeof setTimeout> | undefined
+  let tries = 0
 
   const cleanup = () => {
     if (!cleanups.length) return
@@ -202,7 +221,7 @@ export const Terminal = (props: TerminalProps) => {
   }
 
   const pushSize = (cols: number, rows: number) => {
-    return sdk.client.pty
+    return client.pty
       .update({
         ptyID: id,
         size: { cols, rows },
@@ -326,6 +345,9 @@ export const Terminal = (props: TerminalProps) => {
   }
 
   onMount(() => {
+    probe.init()
+    cleanups.push(() => probe.drop())
+
     const run = async () => {
       const loaded = await loadGhostty()
       if (disposed) return
@@ -353,7 +375,13 @@ export const Terminal = (props: TerminalProps) => {
       }
       ghostty = g
       term = t
-      output = terminalWriter((data, done) => t.write(data, done))
+      output = terminalWriter((data, done) =>
+        t.write(data, () => {
+          probe.render(data)
+          probe.settle()
+          done?.()
+        }),
+      )
 
       t.attachCustomKeyEventHandler((event) => {
         const key = event.key.toLowerCase()
@@ -441,89 +469,136 @@ export const Terminal = (props: TerminalProps) => {
         startResize()
       }
 
-      // t.onScroll((ydisp) => {
-      // console.log("Scroll position:", ydisp)
-      // })
-
       const once = { value: false }
-      let closing = false
-
-      const url = new URL(sdk.url + `/pty/${id}/connect`)
-      url.searchParams.set("directory", sdk.directory)
-      url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0))
-      url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
-      url.username = server.current?.http.username ?? ""
-      url.password = server.current?.http.password ?? ""
+      const decoder = new TextDecoder()
 
-      const socket = new WebSocket(url)
-      socket.binaryType = "arraybuffer"
-      ws = socket
+      const fail = (err: unknown) => {
+        if (disposed) return
+        if (once.value) return
+        once.value = true
+        local.onConnectError?.(err)
+      }
 
-      const handleOpen = () => {
-        local.onConnect?.()
-        scheduleSize(t.cols, t.rows)
+      const gone = () =>
+        client.pty
+          .get({ ptyID: id })
+          .then(() => false)
+          .catch((err) => {
+            if (errorName(err) === "NotFoundError") return true
+            debugTerminal("failed to inspect terminal session", err)
+            return false
+          })
+
+      const retry = (err: unknown) => {
+        if (disposed) return
+        if (reconn !== undefined) return
+
+        const ms = Math.min(250 * 2 ** Math.min(tries, 4), 4_000)
+        reconn = setTimeout(async () => {
+          reconn = undefined
+          if (disposed) return
+          if (await gone()) {
+            if (disposed) return
+            fail(err)
+            return
+          }
+          if (disposed) return
+          tries += 1
+          open()
+        }, ms)
       }
-      socket.addEventListener("open", handleOpen)
-      if (socket.readyState === WebSocket.OPEN) handleOpen()
 
-      const decoder = new TextDecoder()
-      const handleMessage = (event: MessageEvent) => {
+      const open = () => {
         if (disposed) return
-        if (closing) return
-        if (event.data instanceof ArrayBuffer) {
-          const bytes = new Uint8Array(event.data)
-          if (bytes[0] !== 0) return
-          const json = decoder.decode(bytes.subarray(1))
-          try {
-            const meta = JSON.parse(json) as { cursor?: unknown }
-            const next = meta?.cursor
-            if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
-              cursor = next
+        drop?.()
+
+        const next = new URL(url + `/pty/${id}/connect`)
+        next.searchParams.set("directory", directory)
+        next.searchParams.set("cursor", String(seek))
+        next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
+        next.username = username
+        next.password = password
+
+        const socket = new WebSocket(next)
+        socket.binaryType = "arraybuffer"
+        ws = socket
+
+        const handleOpen = () => {
+          if (disposed) return
+          tries = 0
+          probe.connect()
+          local.onConnect?.()
+          scheduleSize(t.cols, t.rows)
+        }
+
+        const handleMessage = (event: MessageEvent) => {
+          if (disposed) return
+          if (event.data instanceof ArrayBuffer) {
+            const bytes = new Uint8Array(event.data)
+            if (bytes[0] !== 0) return
+            const json = decoder.decode(bytes.subarray(1))
+            try {
+              const meta = JSON.parse(json) as { cursor?: unknown }
+              const next = meta?.cursor
+              if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
+                cursor = next
+                seek = next
+              }
+            } catch (err) {
+              debugTerminal("invalid websocket control frame", err)
             }
-          } catch (err) {
-            debugTerminal("invalid websocket control frame", err)
+            return
           }
-          return
+
+          const data = typeof event.data === "string" ? event.data : ""
+          if (!data) return
+          output?.push(data)
+          cursor += data.length
+          seek = cursor
         }
 
-        const data = typeof event.data === "string" ? event.data : ""
-        if (!data) return
-        output?.push(data)
-        cursor += data.length
-      }
-      socket.addEventListener("message", handleMessage)
+        const handleError = (error: Event) => {
+          if (disposed) return
+          debugTerminal("websocket error", error)
+        }
 
-      const handleError = (error: Event) => {
-        if (disposed) return
-        if (closing) return
-        if (once.value) return
-        once.value = true
-        console.error("WebSocket error:", error)
-        local.onConnectError?.(error)
-      }
-      socket.addEventListener("error", handleError)
+        const stop = () => {
+          socket.removeEventListener("open", handleOpen)
+          socket.removeEventListener("message", handleMessage)
+          socket.removeEventListener("error", handleError)
+          socket.removeEventListener("close", handleClose)
+          if (ws === socket) ws = undefined
+          if (drop === stop) drop = undefined
+          if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000)
+        }
 
-      const handleClose = (event: CloseEvent) => {
-        if (disposed) return
-        if (closing) return
-        // Normal closure (code 1000) means PTY process exited - server event handles cleanup
-        // For other codes (network issues, server restart), trigger error handler
-        if (event.code !== 1000) {
-          if (once.value) return
-          once.value = true
-          local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`))
+        const handleClose = (event: CloseEvent) => {
+          if (ws === socket) ws = undefined
+          if (drop === stop) drop = undefined
+          socket.removeEventListener("open", handleOpen)
+          socket.removeEventListener("message", handleMessage)
+          socket.removeEventListener("error", handleError)
+          socket.removeEventListener("close", handleClose)
+          if (disposed) return
+          if (event.code === 1000) return
+          retry(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code })))
         }
+
+        drop = stop
+        socket.addEventListener("open", handleOpen)
+        socket.addEventListener("message", handleMessage)
+        socket.addEventListener("error", handleError)
+        socket.addEventListener("close", handleClose)
       }
-      socket.addEventListener("close", handleClose)
-
-      cleanups.push(() => {
-        closing = true
-        socket.removeEventListener("open", handleOpen)
-        socket.removeEventListener("message", handleMessage)
-        socket.removeEventListener("error", handleError)
-        socket.removeEventListener("close", handleClose)
-        if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000)
+
+      probe.control({
+        disconnect: () => {
+          if (!ws) return
+          ws.close(4_000, "e2e")
+        },
       })
+
+      open()
     }
 
     void run().catch((err) => {
@@ -541,6 +616,8 @@ export const Terminal = (props: TerminalProps) => {
     disposed = true
     if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
     if (sizeTimer !== undefined) clearTimeout(sizeTimer)
+    if (reconn !== undefined) clearTimeout(reconn)
+    drop?.()
     if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
 
     const finalize = () => {
@@ -560,6 +637,7 @@ export const Terminal = (props: TerminalProps) => {
     <div
       ref={container}
       data-component="terminal"
+      {...{ [terminalAttr]: id }}
       data-prevent-autofocus
       tabIndex={-1}
       style={{ "background-color": terminalColors().background }}

+ 73 - 52
packages/app/src/components/titlebar.tsx

@@ -1,4 +1,4 @@
-import { createEffect, createMemo, Show, untrack } from "solid-js"
+import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
 import { createStore } from "solid-js/store"
 import { useLocation, useNavigate, useParams } from "@solidjs/router"
 import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -58,6 +58,12 @@ export function Titlebar() {
   })
 
   const path = () => `${location.pathname}${location.search}${location.hash}`
+  const creating = createMemo(() => {
+    if (!params.dir) return false
+    if (params.id) return false
+    const parts = location.pathname.replace(/\/+$/, "").split("/")
+    return parts.at(-1) === "session"
+  })
 
   createEffect(() => {
     const current = path()
@@ -71,6 +77,7 @@ export function Titlebar() {
 
   const canBack = createMemo(() => history.index > 0)
   const canForward = createMemo(() => history.index < history.stack.length - 1)
+  const hasProjects = createMemo(() => layout.projects.list().length > 0)
 
   const back = () => {
     const next = backPath(history)
@@ -206,63 +213,77 @@ export function Titlebar() {
               aria-label={language.t("command.sidebar.toggle")}
               aria-expanded={layout.sidebar.opened()}
             >
-              <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
-                <Icon
-                  size="small"
-                  name={layout.sidebar.opened() ? "layout-left-partial" : "layout-left"}
-                  class="group-hover/sidebar-toggle:hidden"
-                />
-                <Icon size="small" name="layout-left-partial" class="hidden group-hover/sidebar-toggle:inline-block" />
-                <Icon
-                  size="small"
-                  name={layout.sidebar.opened() ? "layout-left" : "layout-left-partial"}
-                  class="hidden group-active/sidebar-toggle:inline-block"
-                />
-              </div>
+              <Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
             </Button>
           </TooltipKeybind>
           <div class="hidden xl:flex items-center shrink-0">
             <Show when={params.dir}>
-              <TooltipKeybind
-                placement="bottom"
-                title={language.t("command.session.new")}
-                keybind={command.keybind("session.new")}
-                openDelay={2000}
+              <div
+                class="flex items-center shrink-0 w-8 mr-1"
+                aria-hidden={layout.sidebar.opened() ? "true" : undefined}
               >
-                <Button
-                  variant="ghost"
-                  icon="new-session"
-                  class="titlebar-icon w-8 h-6 p-0 box-border"
-                  onClick={() => {
-                    if (!params.dir) return
-                    navigate(`/${params.dir}/session`)
+                <div
+                  class="transition-opacity"
+                  classList={{
+                    "opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
+                    "opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
                   }}
-                  aria-label={language.t("command.session.new")}
-                />
-              </TooltipKeybind>
+                >
+                  <TooltipKeybind
+                    placement="bottom"
+                    title={language.t("command.session.new")}
+                    keybind={command.keybind("session.new")}
+                    openDelay={2000}
+                  >
+                    <Button
+                      variant="ghost"
+                      icon={creating() ? "new-session-active" : "new-session"}
+                      class="titlebar-icon w-8 h-6 p-0 box-border"
+                      disabled={layout.sidebar.opened()}
+                      tabIndex={layout.sidebar.opened() ? -1 : undefined}
+                      onClick={() => {
+                        if (!params.dir) return
+                        navigate(`/${params.dir}/session`)
+                      }}
+                      aria-label={language.t("command.session.new")}
+                      aria-current={creating() ? "page" : undefined}
+                    />
+                  </TooltipKeybind>
+                </div>
+              </div>
+            </Show>
+            <Show when={hasProjects()}>
+              <div
+                class="flex items-center gap-0 transition-transform"
+                classList={{
+                  "translate-x-0": !layout.sidebar.opened(),
+                  "-translate-x-[36px]": layout.sidebar.opened(),
+                  "duration-180 ease-out": !layout.sidebar.opened(),
+                  "duration-180 ease-in": layout.sidebar.opened(),
+                }}
+              >
+                <Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
+                  <Button
+                    variant="ghost"
+                    icon="chevron-left"
+                    class="titlebar-icon w-6 h-6 p-0 box-border"
+                    disabled={!canBack()}
+                    onClick={back}
+                    aria-label={language.t("common.goBack")}
+                  />
+                </Tooltip>
+                <Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
+                  <Button
+                    variant="ghost"
+                    icon="chevron-right"
+                    class="titlebar-icon w-6 h-6 p-0 box-border"
+                    disabled={!canForward()}
+                    onClick={forward}
+                    aria-label={language.t("common.goForward")}
+                  />
+                </Tooltip>
+              </div>
             </Show>
-            <div class="flex items-center gap-0" classList={{ "ml-1": !!params.dir }}>
-              <Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
-                <Button
-                  variant="ghost"
-                  icon="chevron-left"
-                  class="titlebar-icon w-6 h-6 p-0 box-border"
-                  disabled={!canBack()}
-                  onClick={back}
-                  aria-label={language.t("common.goBack")}
-                />
-              </Tooltip>
-              <Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
-                <Button
-                  variant="ghost"
-                  icon="chevron-right"
-                  class="titlebar-icon w-6 h-6 p-0 box-border"
-                  disabled={!canForward()}
-                  onClick={forward}
-                  aria-label={language.t("common.goForward")}
-                />
-              </Tooltip>
-            </div>
           </div>
         </div>
         <div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
@@ -282,7 +303,7 @@ export function Titlebar() {
       >
         <div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
         <Show when={windows()}>
-          <div class="w-6 shrink-0" />
+          {!tauriApi() && <div class="w-36 shrink-0" />}
           <div data-tauri-decorum-tb class="flex flex-row" />
         </Show>
       </div>

+ 69 - 24
packages/app/src/context/command.tsx

@@ -1,9 +1,10 @@
-import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
-import { createStore } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
+import { createStore } from "solid-js/store"
 import { useLanguage } from "@/context/language"
 import { useSettings } from "@/context/settings"
+import { dict as en } from "@/i18n/en"
 import { Persist, persisted } from "@/utils/persist"
 
 const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
@@ -13,6 +14,27 @@ const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
 const SUGGESTED_PREFIX = "suggested."
 const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
 
+type KeyLabel =
+  | "common.key.ctrl"
+  | "common.key.alt"
+  | "common.key.shift"
+  | "common.key.meta"
+  | "common.key.space"
+  | "common.key.backspace"
+  | "common.key.enter"
+  | "common.key.tab"
+  | "common.key.delete"
+  | "common.key.home"
+  | "common.key.end"
+  | "common.key.pageUp"
+  | "common.key.pageDown"
+  | "common.key.insert"
+  | "common.key.esc"
+
+function keyText(key: KeyLabel, t?: (key: KeyLabel) => string) {
+  return t ? t(key) : en[key]
+}
+
 function actionId(id: string) {
   if (!id.startsWith(SUGGESTED_PREFIX)) return id
   return id.slice(SUGGESTED_PREFIX.length)
@@ -145,7 +167,7 @@ export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean
   return false
 }
 
-export function formatKeybind(config: string): string {
+export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string {
   if (!config || config === "none") return ""
 
   const keybinds = parseKeybind(config)
@@ -154,10 +176,10 @@ export function formatKeybind(config: string): string {
   const kb = keybinds[0]
   const parts: string[] = []
 
-  if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
-  if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
-  if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
-  if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
+  if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t))
+  if (kb.alt) parts.push(IS_MAC ? "⌥" : keyText("common.key.alt", t))
+  if (kb.shift) parts.push(IS_MAC ? "⇧" : keyText("common.key.shift", t))
+  if (kb.meta) parts.push(IS_MAC ? "⌘" : keyText("common.key.meta", t))
 
   if (kb.key) {
     const keys: Record<string, string> = {
@@ -167,10 +189,29 @@ export function formatKeybind(config: string): string {
       arrowright: "→",
       comma: ",",
       plus: "+",
-      space: "Space",
+    }
+    const named: Record<string, KeyLabel> = {
+      backspace: "common.key.backspace",
+      delete: "common.key.delete",
+      end: "common.key.end",
+      enter: "common.key.enter",
+      esc: "common.key.esc",
+      escape: "common.key.esc",
+      home: "common.key.home",
+      insert: "common.key.insert",
+      pagedown: "common.key.pageDown",
+      pageup: "common.key.pageUp",
+      space: "common.key.space",
+      tab: "common.key.tab",
     }
     const key = kb.key.toLowerCase()
-    const displayKey = keys[key] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1))
+    const displayKey =
+      keys[key] ??
+      (named[key]
+        ? keyText(named[key], t)
+        : key.length === 1
+          ? key.toUpperCase()
+          : key.charAt(0).toUpperCase() + key.slice(1))
     parts.push(displayKey)
   }
 
@@ -197,9 +238,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
     })
     const warnedDuplicates = new Set<string>()
 
+    type CommandCatalog = Record<string, CommandCatalogItem>
     const [catalog, setCatalog, _, catalogReady] = persisted(
       Persist.global("command.catalog.v1"),
-      createStore<Record<string, CommandCatalogItem>>({}),
+      createStore<CommandCatalog>({}),
     )
 
     const bind = (id: string, def: KeybindConfig | undefined) => {
@@ -218,7 +260,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
           if (seen.has(opt.id)) {
             if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
               warnedDuplicates.add(opt.id)
-              console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`)
+              console.warn(`[command] duplicate command id "${opt.id}" registered; keeping first entry`)
             }
             continue
           }
@@ -233,16 +275,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
     createEffect(() => {
       if (!catalogReady()) return
 
-      for (const opt of registered()) {
-        const id = actionId(opt.id)
-        setCatalog(id, {
-          title: opt.title,
-          description: opt.description,
-          category: opt.category,
-          keybind: opt.keybind,
-          slash: opt.slash,
-        })
-      }
+      setCatalog(
+        registered().reduce((acc, opt) => {
+          const id = actionId(opt.id)
+          acc[id] = {
+            title: opt.title,
+            description: opt.description,
+            category: opt.category,
+            keybind: opt.keybind,
+            slash: opt.slash,
+          }
+          return acc
+        }, {} as CommandCatalog),
+      )
     })
 
     const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
@@ -364,17 +409,17 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
       },
       keybind(id: string) {
         if (id === PALETTE_ID) {
-          return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
+          return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND, language.t)
         }
 
         const base = actionId(id)
         const option = options().find((x) => actionId(x.id) === base)
-        if (option?.keybind) return formatKeybind(option.keybind)
+        if (option?.keybind) return formatKeybind(option.keybind, language.t)
 
         const meta = catalog[base]
         const config = bind(base, meta?.keybind)
         if (!config) return ""
-        return formatKeybind(config)
+        return formatKeybind(config, language.t)
       },
       show: showPalette,
       keybinds(enabled: boolean) {

+ 3 - 3
packages/app/src/context/file.tsx

@@ -43,10 +43,10 @@ export {
   touchFileContent,
 }
 
-function errorMessage(error: unknown) {
+function errorMessage(error: unknown, fallback: string) {
   if (error instanceof Error && error.message) return error.message
   if (typeof error === "string" && error) return error
-  return "Unknown error"
+  return fallback
 }
 
 export const { use: useFile, provider: FileProvider } = createSimpleContext({
@@ -184,7 +184,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
         })
         .catch((e) => {
           if (scope() !== directory) return
-          setLoadError(file, errorMessage(e))
+          setLoadError(file, errorMessage(e, language.t("error.chain.unknown")))
         })
         .finally(() => {
           inflight.delete(key)

+ 4 - 2
packages/app/src/context/global-sdk.tsx

@@ -4,6 +4,7 @@ import { createGlobalEmitter } from "@solid-primitives/event-bus"
 import { batch, onCleanup } from "solid-js"
 import z from "zod"
 import { createSdkForServer } from "@/utils/server"
+import { useLanguage } from "./language"
 import { usePlatform } from "./platform"
 import { useServer } from "./server"
 
@@ -14,6 +15,7 @@ const abortError = z.object({
 export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
   name: "GlobalSDK",
   init: () => {
+    const language = useLanguage()
     const server = useServer()
     const platform = usePlatform()
     const abort = new AbortController()
@@ -30,7 +32,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
     })()
 
     const currentServer = server.current
-    if (!currentServer) throw new Error("No server available")
+    if (!currentServer) throw new Error(language.t("error.globalSDK.noServerAvailable"))
 
     const eventSdk = createSdkForServer({
       signal: abort.signal,
@@ -218,7 +220,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
       event: emitter,
       createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
         const s = server.current
-        if (!s) throw new Error("Server not available")
+        if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))
         return createSdkForServer({
           server: s.http,
           fetch: platform.fetch,

+ 2 - 6
packages/app/src/context/global-sync.test.ts

@@ -1,10 +1,6 @@
 import { describe, expect, test } from "bun:test"
-import {
-  canDisposeDirectory,
-  estimateRootSessionTotal,
-  loadRootSessionsWithFallback,
-  pickDirectoriesToEvict,
-} from "./global-sync"
+import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
+import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
 
 describe("pickDirectoriesToEvict", () => {
   test("keeps pinned stores and evicts idle stores", () => {

+ 3 - 3
packages/app/src/context/global-sync.tsx

@@ -29,6 +29,7 @@ import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
 import { createChildStoreManager } from "./global-sync/child-store"
 import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
 import { createRefreshQueue } from "./global-sync/queue"
+import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
 import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
 import { trimSessions } from "./global-sync/session-trim"
 import type { ProjectMeta } from "./global-sync/types"
@@ -161,7 +162,9 @@ function createGlobalSync() {
       queue.clear(directory)
       sessionMeta.delete(directory)
       sdkCache.delete(directory)
+      clearSessionPrefetchDirectory(directory)
     },
+    translate: language.t,
   })
 
   const sdkFor = (directory: string) => {
@@ -402,6 +405,3 @@ export function useGlobalSync() {
   if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
   return context
 }
-
-export { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
-export { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"

+ 1 - 1
packages/app/src/context/global-sync/bootstrap.ts

@@ -139,7 +139,7 @@ export async function bootstrapDirectory(input: {
     const project = getFilename(input.directory)
     showToast({
       variant: "error",
-      title: `Failed to reload ${project}`,
+      title: input.translate("toast.project.reloadFailed.title", { project }),
       description: formatServerError(err, input.translate),
     })
     input.setStore("status", "partial")

+ 1 - 0
packages/app/src/context/global-sync/child-store.test.ts

@@ -21,6 +21,7 @@ describe("createChildStoreManager", () => {
       isLoadingSessions: () => false,
       onBootstrap() {},
       onDispose() {},
+      translate: (key) => key,
     })
 
     Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {

+ 5 - 4
packages/app/src/context/global-sync/child-store.ts

@@ -21,6 +21,7 @@ export function createChildStoreManager(input: {
   isLoadingSessions: (directory: string) => boolean
   onBootstrap: (directory: string) => void
   onDispose: (directory: string) => void
+  translate: (key: string, vars?: Record<string, string | number>) => string
 }) {
   const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
   const vcsCache = new Map<string, VcsCache>()
@@ -129,7 +130,7 @@ export function createChildStoreManager(input: {
           createStore({ value: undefined as VcsInfo | undefined }),
         ),
       )
-      if (!vcs) throw new Error("Failed to create persisted cache")
+      if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
       const vcsStore = vcs[0]
       vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
 
@@ -139,7 +140,7 @@ export function createChildStoreManager(input: {
           createStore({ value: undefined as ProjectMeta | undefined }),
         ),
       )
-      if (!meta) throw new Error("Failed to create persisted project metadata")
+      if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed"))
       metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
 
       const icon = runWithOwner(input.owner, () =>
@@ -148,7 +149,7 @@ export function createChildStoreManager(input: {
           createStore({ value: undefined as string | undefined }),
         ),
       )
-      if (!icon) throw new Error("Failed to create persisted project icon")
+      if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
       iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
 
       const init = () =>
@@ -211,7 +212,7 @@ export function createChildStoreManager(input: {
     }
     mark(directory)
     const childStore = children[directory]
-    if (!childStore) throw new Error("Failed to create store")
+    if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed"))
     return childStore
   }
 

+ 96 - 0
packages/app/src/context/global-sync/session-prefetch.test.ts

@@ -0,0 +1,96 @@
+import { describe, expect, test } from "bun:test"
+import {
+  clearSessionPrefetch,
+  clearSessionPrefetchDirectory,
+  getSessionPrefetch,
+  runSessionPrefetch,
+  setSessionPrefetch,
+  shouldSkipSessionPrefetch,
+} from "./session-prefetch"
+
+describe("session prefetch", () => {
+  test("stores and clears message metadata by directory", () => {
+    clearSessionPrefetch("/tmp/a", ["ses_1"])
+    clearSessionPrefetch("/tmp/b", ["ses_1"])
+
+    setSessionPrefetch({
+      directory: "/tmp/a",
+      sessionID: "ses_1",
+      limit: 200,
+      cursor: "abc",
+      complete: false,
+      at: 123,
+    })
+
+    expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, cursor: "abc", complete: false, at: 123 })
+    expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined()
+
+    clearSessionPrefetch("/tmp/a", ["ses_1"])
+
+    expect(getSessionPrefetch("/tmp/a", "ses_1")).toBeUndefined()
+  })
+
+  test("dedupes inflight work", async () => {
+    clearSessionPrefetch("/tmp/c", ["ses_2"])
+
+    let calls = 0
+    const run = () =>
+      runSessionPrefetch({
+        directory: "/tmp/c",
+        sessionID: "ses_2",
+        task: async () => {
+          calls += 1
+          return { limit: 100, cursor: "next", complete: true, at: 456 }
+        },
+      })
+
+    const [a, b] = await Promise.all([run(), run()])
+
+    expect(calls).toBe(1)
+    expect(a).toEqual({ limit: 100, cursor: "next", complete: true, at: 456 })
+    expect(b).toEqual({ limit: 100, cursor: "next", complete: true, at: 456 })
+  })
+
+  test("clears a whole directory", () => {
+    setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, cursor: "a", complete: true, at: 1 })
+    setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, cursor: "b", complete: false, at: 2 })
+    setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, cursor: "c", complete: true, at: 3 })
+
+    clearSessionPrefetchDirectory("/tmp/d")
+
+    expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined()
+    expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined()
+    expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 })
+  })
+
+  test("refreshes stale first-page prefetched history", () => {
+    expect(
+      shouldSkipSessionPrefetch({
+        message: true,
+        info: { limit: 200, cursor: "x", complete: false, at: 1 },
+        chunk: 200,
+        now: 1 + 15_001,
+      }),
+    ).toBe(false)
+  })
+
+  test("keeps deeper or complete history cached", () => {
+    expect(
+      shouldSkipSessionPrefetch({
+        message: true,
+        info: { limit: 400, cursor: "x", complete: false, at: 1 },
+        chunk: 200,
+        now: 1 + 15_001,
+      }),
+    ).toBe(true)
+
+    expect(
+      shouldSkipSessionPrefetch({
+        message: true,
+        info: { limit: 120, complete: true, at: 1 },
+        chunk: 200,
+        now: 1 + 15_001,
+      }),
+    ).toBe(true)
+  })
+})

+ 100 - 0
packages/app/src/context/global-sync/session-prefetch.ts

@@ -0,0 +1,100 @@
+const key = (directory: string, sessionID: string) => `${directory}\n${sessionID}`
+
+export const SESSION_PREFETCH_TTL = 15_000
+
+type Meta = {
+  limit: number
+  cursor?: string
+  complete: boolean
+  at: number
+}
+
+export function shouldSkipSessionPrefetch(input: { message: boolean; info?: Meta; chunk: number; now?: number }) {
+  if (input.message) {
+    if (!input.info) return true
+    if (input.info.complete) return true
+    if (input.info.limit > input.chunk) return true
+  } else {
+    if (!input.info) return false
+  }
+
+  return (input.now ?? Date.now()) - input.info.at < SESSION_PREFETCH_TTL
+}
+
+const cache = new Map<string, Meta>()
+const inflight = new Map<string, Promise<Meta | undefined>>()
+const rev = new Map<string, number>()
+
+const version = (id: string) => rev.get(id) ?? 0
+
+export function getSessionPrefetch(directory: string, sessionID: string) {
+  return cache.get(key(directory, sessionID))
+}
+
+export function getSessionPrefetchPromise(directory: string, sessionID: string) {
+  return inflight.get(key(directory, sessionID))
+}
+
+export function clearSessionPrefetchInflight() {
+  inflight.clear()
+}
+
+export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) {
+  return version(key(directory, sessionID)) === value
+}
+
+export function runSessionPrefetch(input: {
+  directory: string
+  sessionID: string
+  task: (value: number) => Promise<Meta | undefined>
+}) {
+  const id = key(input.directory, input.sessionID)
+  const pending = inflight.get(id)
+  if (pending) return pending
+
+  const value = version(id)
+
+  const promise = input.task(value).finally(() => {
+    if (inflight.get(id) === promise) inflight.delete(id)
+  })
+
+  inflight.set(id, promise)
+  return promise
+}
+
+export function setSessionPrefetch(input: {
+  directory: string
+  sessionID: string
+  limit: number
+  cursor?: string
+  complete: boolean
+  at?: number
+}) {
+  cache.set(key(input.directory, input.sessionID), {
+    limit: input.limit,
+    cursor: input.cursor,
+    complete: input.complete,
+    at: input.at ?? Date.now(),
+  })
+}
+
+export function clearSessionPrefetch(directory: string, sessionIDs: Iterable<string>) {
+  for (const sessionID of sessionIDs) {
+    if (!sessionID) continue
+    const id = key(directory, sessionID)
+    rev.set(id, version(id) + 1)
+    cache.delete(id)
+    inflight.delete(id)
+  }
+}
+
+export function clearSessionPrefetchDirectory(directory: string) {
+  const prefix = `${directory}\n`
+  const keys = new Set([...cache.keys(), ...inflight.keys()])
+  for (const id of keys) {
+    if (!id.startsWith(prefix)) continue
+    rev.set(id, version(id) + 1)
+    cache.delete(id)
+    inflight.delete(id)
+  }
+}

+ 8 - 7
packages/app/src/context/highlights.tsx

@@ -1,4 +1,4 @@
-import { createEffect, createSignal, onCleanup } from "solid-js"
+import { createEffect, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -146,8 +146,10 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
     const settings = useSettings()
     const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
 
-    const [from, setFrom] = createSignal<string | undefined>(undefined)
-    const [to, setTo] = createSignal<string | undefined>(undefined)
+    const [range, setRange] = createStore({
+      from: undefined as string | undefined,
+      to: undefined as string | undefined,
+    })
     const state = { started: false }
     let timer: ReturnType<typeof setTimeout> | undefined
 
@@ -214,15 +216,14 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
 
       if (previous === platform.version) return
 
-      setFrom(previous)
-      setTo(platform.version)
+      setRange({ from: previous, to: platform.version })
       start(previous)
     })
 
     return {
       ready,
-      from,
-      to,
+      from: () => range.from,
+      to: () => range.to,
       get last() {
         return store.version
       },

+ 51 - 4
packages/app/src/context/layout.tsx

@@ -793,20 +793,67 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
             },
           },
           review: {
-            open: createMemo(() => s().reviewOpen),
+            open: createMemo(() => s().reviewOpen ?? []),
             setOpen(open: string[]) {
               const session = key()
+              const next = Array.from(new Set(open))
               const current = store.sessionView[session]
               if (!current) {
                 setStore("sessionView", session, {
                   scroll: {},
-                  reviewOpen: open,
+                  reviewOpen: next,
                 })
                 return
               }
 
-              if (same(current.reviewOpen, open)) return
-              setStore("sessionView", session, "reviewOpen", open)
+              if (same(current.reviewOpen, next)) return
+              setStore("sessionView", session, "reviewOpen", next)
+            },
+            openPath(path: string) {
+              const session = key()
+              const current = store.sessionView[session]
+              if (!current) {
+                setStore("sessionView", session, {
+                  scroll: {},
+                  reviewOpen: [path],
+                })
+                return
+              }
+
+              if (!current.reviewOpen) {
+                setStore("sessionView", session, "reviewOpen", [path])
+                return
+              }
+
+              if (current.reviewOpen.includes(path)) return
+              setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
+            },
+            closePath(path: string) {
+              const session = key()
+              const current = store.sessionView[session]?.reviewOpen
+              if (!current) return
+
+              const index = current.indexOf(path)
+              if (index === -1) return
+              setStore(
+                "sessionView",
+                session,
+                "reviewOpen",
+                produce((draft) => {
+                  if (!draft) return
+                  draft.splice(index, 1)
+                }),
+              )
+            },
+            togglePath(path: string) {
+              const session = key()
+              const current = store.sessionView[session]?.reviewOpen
+              if (!current || !current.includes(path)) {
+                this.openPath(path)
+                return
+              }
+
+              this.closePath(path)
             },
           },
         }

+ 361 - 191
packages/app/src/context/local.tsx

@@ -1,252 +1,422 @@
-import { createStore } from "solid-js/store"
-import { batch, createMemo } from "solid-js"
 import { createSimpleContext } from "@opencode-ai/ui/context"
-import { useSDK } from "./sdk"
-import { useSync } from "./sync"
 import { base64Encode } from "@opencode-ai/util/encode"
-import { useProviders } from "@/hooks/use-providers"
+import { useParams } from "@solidjs/router"
+import { batch, createEffect, createMemo, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
 import { useModels } from "@/context/models"
+import { useProviders } from "@/hooks/use-providers"
+import { modelEnabled, modelProbe } from "@/testing/model-selection"
+import { Persist, persisted } from "@/utils/persist"
 import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
+import { useSDK } from "./sdk"
+import { useSync } from "./sync"
 
 export type ModelKey = { providerID: string; modelID: string }
 
+type State = {
+  agent?: string
+  model?: ModelKey
+  variant?: string | null
+}
+
+type Saved = {
+  session: Record<string, State | undefined>
+}
+
+const WORKSPACE_KEY = "__workspace__"
+const handoff = new Map<string, State>()
+
+const handoffKey = (dir: string, id: string) => `${dir}\n${id}`
+
+const migrate = (value: unknown) => {
+  if (!value || typeof value !== "object") return { session: {} }
+
+  const item = value as {
+    session?: Record<string, State | undefined>
+    pick?: Record<string, State | undefined>
+  }
+
+  if (item.session && typeof item.session === "object") return { session: item.session }
+  if (!item.pick || typeof item.pick !== "object") return { session: {} }
+
+  return {
+    session: Object.fromEntries(Object.entries(item.pick).filter(([key]) => key !== WORKSPACE_KEY)),
+  }
+}
+
+const clone = (value: State | undefined) => {
+  if (!value) return undefined
+  return {
+    ...value,
+    model: value.model ? { ...value.model } : undefined,
+  } satisfies State
+}
+
 export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
   name: "Local",
   init: () => {
+    const params = useParams()
     const sdk = useSDK()
     const sync = useSync()
     const providers = useProviders()
-    const connected = createMemo(() => new Set(providers.connected().map((provider) => provider.id)))
+    const models = useModels()
+
+    const id = createMemo(() => params.id || undefined)
+    const list = createMemo(() => sync.data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
+    const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
 
-    function isModelValid(model: ModelKey) {
-      const provider = providers.all().find((x) => x.id === model.providerID)
+    const [saved, setSaved] = persisted(
+      {
+        ...Persist.workspace(sdk.directory, "model-selection", ["model-selection.v1"]),
+        migrate,
+      },
+      createStore<Saved>({
+        session: {},
+      }),
+    )
+
+    const [store, setStore] = createStore<{
+      current?: string
+      draft?: State
+      last?: {
+        type: "agent" | "model" | "variant"
+        agent?: string
+        model?: ModelKey | null
+        variant?: string | null
+      }
+    }>({
+      current: list()[0]?.name,
+      draft: undefined,
+      last: undefined,
+    })
+
+    const validModel = (model: ModelKey) => {
+      const provider = providers.all().find((item) => item.id === model.providerID)
       return !!provider?.models[model.modelID] && connected().has(model.providerID)
     }
 
-    function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
-      for (const modelFn of modelFns) {
-        const model = modelFn()
+    const firstModel = (...items: Array<() => ModelKey | undefined>) => {
+      for (const item of items) {
+        const model = item()
         if (!model) continue
-        if (isModelValid(model)) return model
+        if (validModel(model)) return model
       }
     }
 
-    let setModel: (model: ModelKey | undefined, options?: { recent?: boolean }) => void = () => undefined
+    const pickAgent = (name: string | undefined) => {
+      const items = list()
+      if (items.length === 0) return undefined
+      return items.find((item) => item.name === name) ?? items[0]
+    }
 
-    const agent = (() => {
-      const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
-      const models = useModels()
+    createEffect(() => {
+      const items = list()
+      if (items.length === 0) {
+        if (store.current !== undefined) setStore("current", undefined)
+        return
+      }
+      if (items.some((item) => item.name === store.current)) return
+      setStore("current", items[0]?.name)
+    })
 
-      const [store, setStore] = createStore<{
-        current?: string
-      }>({
-        current: list()[0]?.name,
-      })
-      return {
-        list,
-        current() {
-          const available = list()
-          if (available.length === 0) return undefined
-          return available.find((x) => x.name === store.current) ?? available[0]
-        },
-        set(name: string | undefined) {
-          const available = list()
-          if (available.length === 0) {
-            setStore("current", undefined)
-            return
-          }
-          const match = name ? available.find((x) => x.name === name) : undefined
-          const value = match ?? available[0]
-          if (!value) return
-          setStore("current", value.name)
-          if (!value.model) return
-          setModel({
-            providerID: value.model.providerID,
-            modelID: value.model.modelID,
-          })
-          if (value.variant)
-            models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant)
-        },
-        move(direction: 1 | -1) {
-          const available = list()
-          if (available.length === 0) {
-            setStore("current", undefined)
-            return
-          }
-          let next = available.findIndex((x) => x.name === store.current) + direction
-          if (next < 0) next = available.length - 1
-          if (next >= available.length) next = 0
-          const value = available[next]
-          if (!value) return
-          setStore("current", value.name)
-          if (!value.model) return
-          setModel({
-            providerID: value.model.providerID,
-            modelID: value.model.modelID,
-          })
-          if (value.variant)
-            models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant)
-        },
+    const scope = createMemo<State | undefined>(() => {
+      const session = id()
+      if (!session) return store.draft
+      return saved.session[session] ?? handoff.get(handoffKey(sdk.directory, session))
+    })
+
+    createEffect(() => {
+      const session = id()
+      if (!session) return
+
+      const key = handoffKey(sdk.directory, session)
+      const next = handoff.get(key)
+      if (!next) return
+      if (saved.session[session] !== undefined) {
+        handoff.delete(key)
+        return
       }
-    })()
 
-    const model = (() => {
-      const models = useModels()
+      setSaved("session", session, clone(next))
+      handoff.delete(key)
+    })
 
-      const [ephemeral, setEphemeral] = createStore<{
-        model: Record<string, ModelKey | undefined>
-      }>({
-        model: {},
-      })
+    const configuredModel = () => {
+      if (!sync.data.config.model) return
+      const [providerID, modelID] = sync.data.config.model.split("/")
+      const model = { providerID, modelID }
+      if (validModel(model)) return model
+    }
 
-      const resolveConfigured = () => {
-        if (!sync.data.config.model) return
-        const [providerID, modelID] = sync.data.config.model.split("/")
-        const key = { providerID, modelID }
-        if (isModelValid(key)) return key
+    const recentModel = () => {
+      for (const item of models.recent.list()) {
+        if (validModel(item)) return item
       }
+    }
 
-      const resolveRecent = () => {
-        for (const item of models.recent.list()) {
-          if (isModelValid(item)) return item
+    const defaultModel = () => {
+      const defaults = providers.default()
+      for (const provider of providers.connected()) {
+        const configured = defaults[provider.id]
+        if (configured) {
+          const model = { providerID: provider.id, modelID: configured }
+          if (validModel(model)) return model
         }
+
+        const first = Object.values(provider.models)[0]
+        if (!first) continue
+        const model = { providerID: provider.id, modelID: first.id }
+        if (validModel(model)) return model
       }
+    }
 
-      const resolveDefault = () => {
-        const defaults = providers.default()
-        for (const provider of providers.connected()) {
-          const configured = defaults[provider.id]
-          if (configured) {
-            const key = { providerID: provider.id, modelID: configured }
-            if (isModelValid(key)) return key
-          }
+    const fallback = createMemo<ModelKey | undefined>(() => configuredModel() ?? recentModel() ?? defaultModel())
 
-          const first = Object.values(provider.models)[0]
-          if (!first) continue
-          const key = { providerID: provider.id, modelID: first.id }
-          if (isModelValid(key)) return key
+    const agent = {
+      list,
+      current() {
+        return pickAgent(scope()?.agent ?? store.current)
+      },
+      set(name: string | undefined) {
+        const item = pickAgent(name)
+        if (!item) {
+          setStore("current", undefined)
+          return
         }
-      }
 
-      const fallbackModel = createMemo<ModelKey | undefined>(() => {
-        return resolveConfigured() ?? resolveRecent() ?? resolveDefault()
-      })
+        batch(() => {
+          setStore("current", item.name)
+          setStore("last", {
+            type: "agent",
+            agent: item.name,
+            model: item.model,
+            variant: item.variant ?? null,
+          })
+          const prev = scope()
+          const next = {
+            agent: item.name,
+            model: item.model ?? prev?.model,
+            variant: item.variant ?? prev?.variant,
+          } satisfies State
+          const session = id()
+          if (session) {
+            setSaved("session", session, next)
+            return
+          }
+          setStore("draft", next)
+        })
+      },
+      move(direction: 1 | -1) {
+        const items = list()
+        if (items.length === 0) {
+          setStore("current", undefined)
+          return
+        }
 
-      const current = createMemo(() => {
-        const a = agent.current()
-        if (!a) return undefined
-        const key = getFirstValidModel(
-          () => ephemeral.model[a.name],
-          () => a.model,
-          fallbackModel,
-        )
-        if (!key) return undefined
-        return models.find(key)
-      })
+        let next = items.findIndex((item) => item.name === agent.current()?.name) + direction
+        if (next < 0) next = items.length - 1
+        if (next >= items.length) next = 0
+        const item = items[next]
+        if (!item) return
+        agent.set(item.name)
+      },
+    }
 
-      const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
+    const current = () => {
+      const item = firstModel(
+        () => scope()?.model,
+        () => agent.current()?.model,
+        fallback,
+      )
+      if (!item) return undefined
+      return models.find(item)
+    }
 
-      const cycle = (direction: 1 | -1) => {
-        const recentList = recent()
-        const currentModel = current()
-        if (!currentModel) return
+    const configured = () => {
+      const item = agent.current()
+      const model = current()
+      if (!item || !model) return undefined
+      return getConfiguredAgentVariant({
+        agent: { model: item.model, variant: item.variant },
+        model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
+      })
+    }
 
-        const index = recentList.findIndex(
-          (x) => x?.provider.id === currentModel.provider.id && x?.id === currentModel.id,
-        )
-        if (index === -1) return
+    const selected = () => scope()?.variant
 
-        let next = index + direction
-        if (next < 0) next = recentList.length - 1
-        if (next >= recentList.length) next = 0
+    const snapshot = () => {
+      const model = current()
+      return {
+        agent: agent.current()?.name,
+        model: model ? { providerID: model.provider.id, modelID: model.id } : undefined,
+        variant: selected(),
+      } satisfies State
+    }
 
-        const val = recentList[next]
-        if (!val) return
+    const write = (next: Partial<State>) => {
+      const state = {
+        ...(scope() ?? { agent: agent.current()?.name }),
+        ...next,
+      } satisfies State
 
-        model.set({
-          providerID: val.provider.id,
-          modelID: val.id,
-        })
+      const session = id()
+      if (session) {
+        setSaved("session", session, state)
+        return
       }
+      setStore("draft", state)
+    }
 
-      const set = (model: ModelKey | undefined, options?: { recent?: boolean }) => {
-        batch(() => {
-          const currentAgent = agent.current()
-          const next = model ?? fallbackModel()
-          if (currentAgent) setEphemeral("model", currentAgent.name, next)
-          if (model) models.setVisibility(model, true)
-          if (options?.recent && model) models.recent.push(model)
-        })
-      }
+    const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
 
-      setModel = set
+    const model = {
+      ready: models.ready,
+      current,
+      recent,
+      list: models.list,
+      cycle(direction: 1 | -1) {
+        const items = recent()
+        const item = current()
+        if (!item) return
 
-      return {
-        ready: models.ready,
-        current,
-        recent,
-        list: models.list,
-        cycle,
-        set,
-        visible(model: ModelKey) {
-          return models.visible(model)
+        const index = items.findIndex((entry) => entry?.provider.id === item.provider.id && entry?.id === item.id)
+        if (index === -1) return
+
+        let next = index + direction
+        if (next < 0) next = items.length - 1
+        if (next >= items.length) next = 0
+
+        const entry = items[next]
+        if (!entry) return
+        model.set({ providerID: entry.provider.id, modelID: entry.id })
+      },
+      set(item: ModelKey | undefined, options?: { recent?: boolean }) {
+        batch(() => {
+          setStore("last", {
+            type: "model",
+            agent: agent.current()?.name,
+            model: item ?? null,
+            variant: selected(),
+          })
+          write({ model: item })
+          if (!item) return
+          models.setVisibility(item, true)
+          if (!options?.recent) return
+          models.recent.push(item)
+        })
+      },
+      visible(item: ModelKey) {
+        return models.visible(item)
+      },
+      setVisibility(item: ModelKey, visible: boolean) {
+        models.setVisibility(item, visible)
+      },
+      variant: {
+        configured,
+        selected,
+        current() {
+          return resolveModelVariant({
+            variants: this.list(),
+            selected: this.selected(),
+            configured: this.configured(),
+          })
         },
-        setVisibility(model: ModelKey, visible: boolean) {
-          models.setVisibility(model, visible)
+        list() {
+          const item = current()
+          if (!item?.variants) return []
+          return Object.keys(item.variants)
         },
-        variant: {
-          configured() {
-            const a = agent.current()
-            const m = current()
-            if (!a || !m) return undefined
-            return getConfiguredAgentVariant({
-              agent: { model: a.model, variant: a.variant },
-              model: { providerID: m.provider.id, modelID: m.id, variants: m.variants },
+        set(value: string | undefined) {
+          batch(() => {
+            const model = current()
+            setStore("last", {
+              type: "variant",
+              agent: agent.current()?.name,
+              model: model ? { providerID: model.provider.id, modelID: model.id } : null,
+              variant: value ?? null,
             })
-          },
-          selected() {
-            const m = current()
-            if (!m) return undefined
-            return models.variant.get({ providerID: m.provider.id, modelID: m.id })
-          },
-          current() {
-            return resolveModelVariant({
-              variants: this.list(),
+            write({ variant: value ?? null })
+          })
+        },
+        cycle() {
+          const items = this.list()
+          if (items.length === 0) return
+          this.set(
+            cycleModelVariant({
+              variants: items,
               selected: this.selected(),
               configured: this.configured(),
-            })
-          },
-          list() {
-            const m = current()
-            if (!m) return []
-            if (!m.variants) return []
-            return Object.keys(m.variants)
-          },
-          set(value: string | undefined) {
-            const m = current()
-            if (!m) return
-            models.variant.set({ providerID: m.provider.id, modelID: m.id }, value)
-          },
-          cycle() {
-            const variants = this.list()
-            if (variants.length === 0) return
-            this.set(
-              cycleModelVariant({
-                variants,
-                selected: this.selected(),
-                configured: this.configured(),
-              }),
-            )
-          },
+            }),
+          )
         },
-      }
-    })()
+      },
+    }
 
     const result = {
       slug: createMemo(() => base64Encode(sdk.directory)),
       model,
       agent,
+      session: {
+        reset() {
+          setStore("draft", undefined)
+        },
+        promote(dir: string, session: string) {
+          const next = clone(snapshot())
+          if (!next) return
+
+          if (dir === sdk.directory) {
+            setSaved("session", session, next)
+            setStore("draft", undefined)
+            return
+          }
+
+          handoff.set(handoffKey(dir, session), next)
+          setStore("draft", undefined)
+        },
+        restore(msg: { sessionID: string; agent: string; model: ModelKey; variant?: string }) {
+          const session = id()
+          if (!session) return
+          if (msg.sessionID !== session) return
+          if (saved.session[session] !== undefined) return
+          if (handoff.has(handoffKey(sdk.directory, session))) return
+
+          setSaved("session", session, {
+            agent: msg.agent,
+            model: msg.model,
+            variant: msg.variant ?? null,
+          })
+        },
+      },
     }
+
+    if (modelEnabled()) {
+      createEffect(() => {
+        const agent = result.agent.current()
+        const model = result.model.current()
+        modelProbe.set({
+          dir: sdk.directory,
+          sessionID: id(),
+          last: store.last,
+          agent: agent?.name,
+          model: model
+            ? {
+                providerID: model.provider.id,
+                modelID: model.id,
+                name: model.name,
+              }
+            : undefined,
+          variant: result.model.variant.current() ?? null,
+          selected: result.model.variant.selected(),
+          configured: result.model.variant.configured(),
+          pick: scope(),
+          base: undefined,
+          current: store.current,
+        })
+      })
+
+      onCleanup(() => modelProbe.clear())
+    }
+
     return result
   },
 })

+ 20 - 0
packages/app/src/context/model-variant.test.ts

@@ -44,6 +44,16 @@ describe("model variant", () => {
     expect(value).toBe("high")
   })
 
+  test("lets an explicit default override the configured variant", () => {
+    const value = resolveModelVariant({
+      variants: ["low", "high", "xhigh"],
+      selected: null,
+      configured: "xhigh",
+    })
+
+    expect(value).toBeUndefined()
+  })
+
   test("cycles from configured variant to next", () => {
     const value = cycleModelVariant({
       variants: ["low", "high", "xhigh"],
@@ -63,4 +73,14 @@ describe("model variant", () => {
 
     expect(value).toBe("low")
   })
+
+  test("cycles from an explicit default to the first variant", () => {
+    const value = cycleModelVariant({
+      variants: ["low", "high", "xhigh"],
+      selected: null,
+      configured: "xhigh",
+    })
+
+    expect(value).toBe("low")
+  })
 })

+ 3 - 1
packages/app/src/context/model-variant.ts

@@ -14,7 +14,7 @@ type Model = AgentModel & {
 
 type VariantInput = {
   variants: string[]
-  selected: string | undefined
+  selected: string | null | undefined
   configured: string | undefined
 }
 
@@ -29,6 +29,7 @@ export function getConfiguredAgentVariant(input: { agent: Agent | undefined; mod
 }
 
 export function resolveModelVariant(input: VariantInput) {
+  if (input.selected === null) return undefined
   if (input.selected && input.variants.includes(input.selected)) return input.selected
   if (input.configured && input.variants.includes(input.configured)) return input.configured
   return undefined
@@ -36,6 +37,7 @@ export function resolveModelVariant(input: VariantInput) {
 
 export function cycleModelVariant(input: VariantInput) {
   if (input.variants.length === 0) return undefined
+  if (input.selected === null) return input.variants[0]
   if (input.selected && input.variants.includes(input.selected)) {
     const index = input.variants.indexOf(input.selected)
     if (index === input.variants.length - 1) return undefined

+ 0 - 66
packages/app/src/context/notification-index.ts

@@ -1,66 +0,0 @@
-type NotificationIndexItem = {
-  directory?: string
-  session?: string
-  viewed: boolean
-  type: string
-}
-
-export function buildNotificationIndex<T extends NotificationIndexItem>(list: T[]) {
-  const sessionAll = new Map<string, T[]>()
-  const sessionUnseen = new Map<string, T[]>()
-  const sessionUnseenCount = new Map<string, number>()
-  const sessionUnseenHasError = new Map<string, boolean>()
-  const projectAll = new Map<string, T[]>()
-  const projectUnseen = new Map<string, T[]>()
-  const projectUnseenCount = new Map<string, number>()
-  const projectUnseenHasError = new Map<string, boolean>()
-
-  for (const notification of list) {
-    const session = notification.session
-    if (session) {
-      const all = sessionAll.get(session)
-      if (all) all.push(notification)
-      else sessionAll.set(session, [notification])
-
-      if (!notification.viewed) {
-        const unseen = sessionUnseen.get(session)
-        if (unseen) unseen.push(notification)
-        else sessionUnseen.set(session, [notification])
-
-        sessionUnseenCount.set(session, (sessionUnseenCount.get(session) ?? 0) + 1)
-        if (notification.type === "error") sessionUnseenHasError.set(session, true)
-      }
-    }
-
-    const directory = notification.directory
-    if (directory) {
-      const all = projectAll.get(directory)
-      if (all) all.push(notification)
-      else projectAll.set(directory, [notification])
-
-      if (!notification.viewed) {
-        const unseen = projectUnseen.get(directory)
-        if (unseen) unseen.push(notification)
-        else projectUnseen.set(directory, [notification])
-
-        projectUnseenCount.set(directory, (projectUnseenCount.get(directory) ?? 0) + 1)
-        if (notification.type === "error") projectUnseenHasError.set(directory, true)
-      }
-    }
-  }
-
-  return {
-    session: {
-      all: sessionAll,
-      unseen: sessionUnseen,
-      unseenCount: sessionUnseenCount,
-      unseenHasError: sessionUnseenHasError,
-    },
-    project: {
-      all: projectAll,
-      unseen: projectUnseen,
-      unseenCount: projectUnseenCount,
-      unseenHasError: projectUnseenHasError,
-    },
-  }
-}

+ 0 - 73
packages/app/src/context/notification.test.ts

@@ -1,73 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { buildNotificationIndex } from "./notification-index"
-
-type Notification = {
-  type: "turn-complete" | "error"
-  session: string
-  directory: string
-  viewed: boolean
-  time: number
-}
-
-const turn = (session: string, directory: string, viewed = false): Notification => ({
-  type: "turn-complete",
-  session,
-  directory,
-  viewed,
-  time: 1,
-})
-
-const error = (session: string, directory: string, viewed = false): Notification => ({
-  type: "error",
-  session,
-  directory,
-  viewed,
-  time: 1,
-})
-
-describe("buildNotificationIndex", () => {
-  test("builds unseen counts and unseen error flags", () => {
-    const list = [
-      turn("s1", "d1", false),
-      error("s1", "d1", false),
-      turn("s1", "d1", true),
-      turn("s2", "d1", false),
-      error("s3", "d2", true),
-    ]
-
-    const index = buildNotificationIndex(list)
-
-    expect(index.session.all.get("s1")?.length).toBe(3)
-    expect(index.session.unseen.get("s1")?.length).toBe(2)
-    expect(index.session.unseenCount.get("s1")).toBe(2)
-    expect(index.session.unseenHasError.get("s1")).toBe(true)
-
-    expect(index.session.unseenCount.get("s2")).toBe(1)
-    expect(index.session.unseenHasError.get("s2") ?? false).toBe(false)
-    expect(index.session.unseenCount.get("s3") ?? 0).toBe(0)
-    expect(index.session.unseenHasError.get("s3") ?? false).toBe(false)
-
-    expect(index.project.unseenCount.get("d1")).toBe(3)
-    expect(index.project.unseenHasError.get("d1")).toBe(true)
-    expect(index.project.unseenCount.get("d2") ?? 0).toBe(0)
-    expect(index.project.unseenHasError.get("d2") ?? false).toBe(false)
-  })
-
-  test("updates selectors after viewed transitions", () => {
-    const list = [turn("s1", "d1", false), error("s1", "d1", false), turn("s2", "d1", false)]
-    const next = list.map((item) => (item.session === "s1" ? { ...item, viewed: true } : item))
-
-    const before = buildNotificationIndex(list)
-    const after = buildNotificationIndex(next)
-
-    expect(before.session.unseenCount.get("s1")).toBe(2)
-    expect(before.session.unseenHasError.get("s1")).toBe(true)
-    expect(before.project.unseenCount.get("d1")).toBe(3)
-    expect(before.project.unseenHasError.get("d1")).toBe(true)
-
-    expect(after.session.unseenCount.get("s1") ?? 0).toBe(0)
-    expect(after.session.unseenHasError.get("s1") ?? false).toBe(false)
-    expect(after.project.unseenCount.get("d1")).toBe(1)
-    expect(after.project.unseenHasError.get("d1") ?? false).toBe(false)
-  })
-})

+ 3 - 2
packages/app/src/context/platform.tsx

@@ -1,6 +1,7 @@
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
 import type { Accessor } from "solid-js"
+import { ServerConnection } from "./server"
 
 type PickerPaths = string | string[] | null
 type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -58,10 +59,10 @@ export type Platform = {
   fetch?: typeof fetch
 
   /** Get the configured default server URL (platform-specific) */
-  getDefaultServerUrl?(): Promise<string | null>
+  getDefaultServer?(): Promise<ServerConnection.Key | null>
 
   /** Set the default server URL to use on app startup (platform-specific) */
-  setDefaultServerUrl?(url: string | null): Promise<void> | void
+  setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
 
   /** Get the configured WSL integration (desktop only) */
   getWslEnabled?(): Promise<boolean>

+ 19 - 9
packages/app/src/context/prompt.tsx

@@ -1,10 +1,10 @@
-import { createStore, type SetStoreFunction } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
-import { batch, createMemo, createRoot, onCleanup } from "solid-js"
+import { checksum } from "@opencode-ai/util/encode"
 import { useParams } from "@solidjs/router"
+import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
+import { createStore, type SetStoreFunction } from "solid-js/store"
 import type { FileSelection } from "@/context/file"
 import { Persist, persisted } from "@/utils/persist"
-import { checksum } from "@opencode-ai/util/encode"
 
 interface PartBase {
   content: string
@@ -151,6 +151,11 @@ const MAX_PROMPT_SESSIONS = 20
 
 type PromptSession = ReturnType<typeof createPromptSession>
 
+type Scope = {
+  dir: string
+  id?: string
+}
+
 type PromptCacheEntry = {
   value: PromptSession
   dispose: VoidFunction
@@ -245,6 +250,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
       }
     }
 
+    const owner = getOwner()
     const load = (dir: string, id: string | undefined) => {
       const key = `${dir}:${id ?? WORKSPACE_KEY}`
       const existing = cache.get(key)
@@ -254,10 +260,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
         return existing.value
       }
 
-      const entry = createRoot((dispose) => ({
-        value: createPromptSession(dir, id),
-        dispose,
-      }))
+      const entry = createRoot(
+        (dispose) => ({
+          value: createPromptSession(dir, id),
+          dispose,
+        }),
+        owner,
+      )
 
       cache.set(key, entry)
       prune()
@@ -265,6 +274,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
     }
 
     const session = createMemo(() => load(params.dir!, params.id))
+    const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session())
 
     return {
       ready: () => session().ready(),
@@ -280,8 +290,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
           session().context.updateComment(path, commentID, next),
         replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
       },
-      set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
-      reset: () => session().reset(),
+      set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition),
+      reset: (scope?: Scope) => pick(scope).reset(),
     }
   },
 })

+ 3 - 5
packages/app/src/context/server.tsx

@@ -1,9 +1,8 @@
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
-import { usePlatform } from "@/context/platform"
 import { Persist, persisted } from "@/utils/persist"
-import { checkServerHealth } from "@/utils/server-health"
+import { useCheckServerHealth } from "@/utils/server-health"
 
 type StoredProject = { worktree: string; expanded: boolean }
 type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
@@ -96,7 +95,7 @@ export namespace ServerConnection {
 export const { use: useServer, provider: ServerProvider } = createSimpleContext({
   name: "Server",
   init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
-    const platform = usePlatform()
+    const checkServerHealth = useCheckServerHealth()
 
     const [store, setStore, _, ready] = persisted(
       Persist.global("server", ["server.v3"]),
@@ -197,8 +196,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
 
     const isReady = createMemo(() => ready() && !!state.active)
 
-    const fetcher = platform.fetch ?? globalThis.fetch
-    const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
+    const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
 
     createEffect(() => {
       const current_ = current()

+ 6 - 0
packages/app/src/context/settings.tsx

@@ -22,6 +22,7 @@ export interface Settings {
   general: {
     autoSave: boolean
     releaseNotes: boolean
+    followup: "queue" | "steer"
     showReasoningSummaries: boolean
     shellToolPartsExpanded: boolean
     editToolPartsExpanded: boolean
@@ -45,6 +46,7 @@ const defaultSettings: Settings = {
   general: {
     autoSave: true,
     releaseNotes: true,
+    followup: "steer",
     showReasoningSummaries: false,
     shellToolPartsExpanded: true,
     editToolPartsExpanded: false,
@@ -126,6 +128,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
         setReleaseNotes(value: boolean) {
           setStore("general", "releaseNotes", value)
         },
+        followup: withFallback(() => store.general?.followup, defaultSettings.general.followup),
+        setFollowup(value: "queue" | "steer") {
+          setStore("general", "followup", value)
+        },
         showReasoningSummaries: withFallback(
           () => store.general?.showReasoningSummaries,
           defaultSettings.general.showReasoningSummaries,

+ 69 - 2
packages/app/src/context/sync-optimistic.test.ts

@@ -1,6 +1,8 @@
 import { describe, expect, test } from "bun:test"
 import type { Message, Part } from "@opencode-ai/sdk/v2/client"
-import { applyOptimisticAdd, applyOptimisticRemove } from "./sync"
+import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
+
+type Text = Extract<Part, { type: "text" }>
 
 const userMessage = (id: string, sessionID: string): Message => ({
   id,
@@ -11,7 +13,7 @@ const userMessage = (id: string, sessionID: string): Message => ({
   model: { providerID: "openai", modelID: "gpt" },
 })
 
-const textPart = (id: string, sessionID: string, messageID: string): Part => ({
+const textPart = (id: string, sessionID: string, messageID: string): Text => ({
   id,
   sessionID,
   messageID,
@@ -53,4 +55,69 @@ describe("sync optimistic reducers", () => {
     expect(draft.part.msg_1).toBeUndefined()
     expect(draft.part.msg_2).toHaveLength(1)
   })
+
+  test("mergeOptimisticPage keeps pending messages in fetched timelines", () => {
+    const sessionID = "ses_1"
+    const page = mergeOptimisticPage(
+      {
+        session: [userMessage("msg_1", sessionID)],
+        part: [{ id: "msg_1", part: [textPart("prt_1", sessionID, "msg_1")] }],
+        complete: true,
+      },
+      [{ message: userMessage("msg_2", sessionID), parts: [textPart("prt_2", sessionID, "msg_2")] }],
+    )
+
+    expect(page.session.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
+    expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_2"])
+    expect(page.confirmed).toEqual([])
+    expect(page.complete).toBe(true)
+  })
+
+  test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => {
+    const sessionID = "ses_1"
+    const page = mergeOptimisticPage(
+      {
+        session: [userMessage("msg_2", sessionID)],
+        part: [{ id: "msg_2", part: [textPart("prt_2", sessionID, "msg_2")] }],
+        complete: true,
+      },
+      [
+        {
+          message: userMessage("msg_2", sessionID),
+          parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
+        },
+      ],
+    )
+
+    expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
+    expect(page.confirmed).toEqual([])
+  })
+
+  test("mergeOptimisticPage confirms echoed messages once all parts arrive", () => {
+    const sessionID = "ses_1"
+    const page = mergeOptimisticPage(
+      {
+        session: [userMessage("msg_2", sessionID)],
+        part: [
+          {
+            id: "msg_2",
+            part: [{ ...textPart("prt_1", sessionID, "msg_2"), text: "server" }, textPart("prt_2", sessionID, "msg_2")],
+          },
+        ],
+        complete: true,
+      },
+      [
+        {
+          message: userMessage("msg_2", sessionID),
+          parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
+        },
+      ],
+    )
+
+    expect(page.confirmed).toEqual(["msg_2"])
+    expect(page.part.find((x) => x.id === "msg_2")?.part).toMatchObject([
+      { id: "prt_1", type: "text", text: "server" },
+      { id: "prt_2", type: "text", text: "prt_2" },
+    ])
+  })
 })

+ 224 - 48
packages/app/src/context/sync.tsx

@@ -3,6 +3,12 @@ import { createStore, produce, reconcile } from "solid-js/store"
 import { Binary } from "@opencode-ai/util/binary"
 import { retry } from "@opencode-ai/util/retry"
 import { createSimpleContext } from "@opencode-ai/ui/context"
+import {
+  clearSessionPrefetch,
+  getSessionPrefetch,
+  getSessionPrefetchPromise,
+  setSessionPrefetch,
+} from "./global-sync/session-prefetch"
 import { useGlobalSync } from "./global-sync"
 import { useSDK } from "./sdk"
 import type { Message, Part } from "@opencode-ai/sdk/v2/client"
@@ -26,6 +32,12 @@ const keyFor = (directory: string, id: string) => `${directory}\n${id}`
 
 const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
 
+function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
+  const map = new Map(a.map((item) => [item.id, item] as const))
+  for (const item of b) map.set(item.id, item)
+  return [...map.values()].sort((x, y) => cmp(x.id, y.id))
+}
+
 type OptimisticStore = {
   message: Record<string, Message[] | undefined>
   part: Record<string, Part[] | undefined>
@@ -42,6 +54,67 @@ type OptimisticRemoveInput = {
   messageID: string
 }
 
+type OptimisticItem = {
+  message: Message
+  parts: Part[]
+}
+
+type MessagePage = {
+  session: Message[]
+  part: { id: string; part: Part[] }[]
+  cursor?: string
+  complete: boolean
+}
+
+const hasParts = (parts: Part[] | undefined, want: Part[]) => {
+  if (!parts) return want.length === 0
+  return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
+}
+
+const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
+  if (!parts) return sortParts(want)
+  const next = [...parts]
+  let changed = false
+  for (const part of want) {
+    const result = Binary.search(next, part.id, (item) => item.id)
+    if (result.found) continue
+    next.splice(result.index, 0, part)
+    changed = true
+  }
+  if (!changed) return parts
+  return next
+}
+
+export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
+  if (items.length === 0) return { ...page, confirmed: [] as string[] }
+
+  const session = [...page.session]
+  const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
+  const confirmed: string[] = []
+
+  for (const item of items) {
+    const result = Binary.search(session, item.message.id, (message) => message.id)
+    const found = result.found
+    if (!found) session.splice(result.index, 0, item.message)
+
+    const current = part.get(item.message.id)
+    if (found && hasParts(current, item.parts)) {
+      confirmed.push(item.message.id)
+      continue
+    }
+
+    part.set(item.message.id, mergeParts(current, item.parts))
+  }
+
+  return {
+    cursor: page.cursor,
+    complete: page.complete,
+    session,
+    part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
+    confirmed,
+  }
+}
+
 export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
   const messages = draft.message[input.sessionID]
   if (messages) {
@@ -109,10 +182,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
     const inflight = new Map<string, Promise<void>>()
     const inflightDiff = new Map<string, Promise<void>>()
     const inflightTodo = new Map<string, Promise<void>>()
+    const optimistic = new Map<string, Map<string, OptimisticItem>>()
     const maxDirs = 30
     const seen = new Map<string, Set<string>>()
     const [meta, setMeta] = createStore({
       limit: {} as Record<string, number>,
+      cursor: {} as Record<string, string | undefined>,
       complete: {} as Record<string, boolean>,
       loading: {} as Record<string, boolean>,
     })
@@ -124,6 +199,33 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       return undefined
     }
 
+    const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
+      const key = keyFor(directory, sessionID)
+      const list = optimistic.get(key)
+      if (list) {
+        list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
+        return
+      }
+      optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
+    }
+
+    const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
+      const key = keyFor(directory, sessionID)
+      if (!messageID) {
+        optimistic.delete(key)
+        return
+      }
+
+      const list = optimistic.get(key)
+      if (!list) return
+      list.delete(messageID)
+      if (list.size === 0) optimistic.delete(key)
+    }
+
+    const getOptimistic = (directory: string, sessionID: string) => [
+      ...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
+    ]
+
     const seenFor = (directory: string) => {
       const existing = seen.get(directory)
       if (existing) {
@@ -146,11 +248,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
 
     const clearMeta = (directory: string, sessionIDs: string[]) => {
       if (sessionIDs.length === 0) return
+      for (const sessionID of sessionIDs) {
+        clearOptimistic(directory, sessionID)
+      }
       setMeta(
         produce((draft) => {
           for (const sessionID of sessionIDs) {
             const key = keyFor(directory, sessionID)
             delete draft.limit[key]
+            delete draft.cursor[key]
             delete draft.complete[key]
             delete draft.loading[key]
           }
@@ -160,6 +266,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
 
     const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
       if (sessionIDs.length === 0) return
+      clearSessionPrefetch(directory, sessionIDs)
       for (const sessionID of sessionIDs) {
         globalSync.todo.set(sessionID, undefined)
       }
@@ -180,17 +287,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       evict(directory, setStore, stale)
     }
 
-    const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
+    const fetchMessages = async (input: {
+      client: typeof sdk.client
+      sessionID: string
+      limit: number
+      before?: string
+    }) => {
       const messages = await retry(() =>
-        input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
+        input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
       )
       const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
       const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
       const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
+      const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
       return {
         session,
         part,
-        complete: session.length < input.limit,
+        cursor,
+        complete: !cursor,
       }
     }
 
@@ -202,26 +316,50 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       setStore: Setter
       sessionID: string
       limit: number
+      before?: string
+      mode?: "replace" | "prepend"
     }) => {
       const key = keyFor(input.directory, input.sessionID)
       if (meta.loading[key]) return
 
       setMeta("loading", key, true)
       await fetchMessages(input)
-        .then((next) => {
+        .then((page) => {
           if (!tracked(input.directory, input.sessionID)) return
+          const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
+          for (const messageID of next.confirmed) {
+            clearOptimistic(input.directory, input.sessionID, messageID)
+          }
+          const [store] = globalSync.child(input.directory, { bootstrap: false })
+          const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
+          const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
           batch(() => {
-            input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
+            input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
             for (const p of next.part) {
               input.setStore("part", p.id, p.part)
             }
-            setMeta("limit", key, input.limit)
+            setMeta("limit", key, message.length)
+            setMeta("cursor", key, next.cursor)
             setMeta("complete", key, next.complete)
+            setSessionPrefetch({
+              directory: input.directory,
+              sessionID: input.sessionID,
+              limit: message.length,
+              cursor: next.cursor,
+              complete: next.complete,
+            })
           })
         })
         .finally(() => {
-          if (!tracked(input.directory, input.sessionID)) return
-          setMeta("loading", key, false)
+          setMeta(
+            produce((draft) => {
+              if (!tracked(input.directory, input.sessionID)) {
+                delete draft.loading[key]
+                return
+              }
+              draft.loading[key] = false
+            }),
+          )
         })
     }
 
@@ -248,11 +386,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         get: getSession,
         optimistic: {
           add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
+            const directory = input.directory ?? sdk.directory
             const [, setStore] = target(input.directory)
+            setOptimistic(directory, input.sessionID, { message: input.message, parts: input.parts })
             setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
           },
           remove(input: { directory?: string; sessionID: string; messageID: string }) {
+            const directory = input.directory ?? sdk.directory
             const [, setStore] = target(input.directory)
+            clearOptimistic(directory, input.sessionID, input.messageID)
             setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
           },
         },
@@ -274,60 +416,91 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             variant: input.variant,
           }
           const [, setStore] = target()
+          setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })
           setOptimisticAdd(setStore as (...args: unknown[]) => void, {
             sessionID: input.sessionID,
             message,
             parts: input.parts,
           })
         },
-        async sync(sessionID: string) {
+        async sync(sessionID: string, opts?: { force?: boolean }) {
           const directory = sdk.directory
           const client = sdk.client
           const [store, setStore] = globalSync.child(directory)
           const key = keyFor(directory, sessionID)
-          const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
 
           touch(directory, setStore, sessionID)
 
-          if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return
-
-          const limit = meta.limit[key] ?? messagePageSize
-
-          const sessionReq = hasSession
-            ? Promise.resolve()
-            : retry(() => client.session.get({ sessionID })).then((session) => {
-                if (!tracked(directory, sessionID)) return
-                const data = session.data
-                if (!data) return
-                setStore(
-                  "session",
-                  produce((draft) => {
-                    const match = Binary.search(draft, sessionID, (s) => s.id)
-                    if (match.found) {
-                      draft[match.index] = data
-                      return
-                    }
-                    draft.splice(match.index, 0, data)
-                  }),
-                )
-              })
-
-          const messagesReq = loadMessages({
-            directory,
-            client,
-            setStore,
-            sessionID,
-            limit,
-          })
+          const seeded = getSessionPrefetch(directory, sessionID)
+          if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
+            batch(() => {
+              setMeta("limit", key, seeded.limit)
+              setMeta("cursor", key, seeded.cursor)
+              setMeta("complete", key, seeded.complete)
+              setMeta("loading", key, false)
+            })
+          }
 
-          return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
+          return runInflight(inflight, key, async () => {
+            const pending = getSessionPrefetchPromise(directory, sessionID)
+            if (pending) {
+              await pending
+              const seeded = getSessionPrefetch(directory, sessionID)
+              if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
+                batch(() => {
+                  setMeta("limit", key, seeded.limit)
+                  setMeta("cursor", key, seeded.cursor)
+                  setMeta("complete", key, seeded.complete)
+                  setMeta("loading", key, false)
+                })
+              }
+            }
+
+            const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
+            const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
+            if (cached && hasSession && !opts?.force) return
+
+            const limit = meta.limit[key] ?? messagePageSize
+            const sessionReq =
+              hasSession && !opts?.force
+                ? Promise.resolve()
+                : retry(() => client.session.get({ sessionID })).then((session) => {
+                    if (!tracked(directory, sessionID)) return
+                    const data = session.data
+                    if (!data) return
+                    setStore(
+                      "session",
+                      produce((draft) => {
+                        const match = Binary.search(draft, sessionID, (s) => s.id)
+                        if (match.found) {
+                          draft[match.index] = data
+                          return
+                        }
+                        draft.splice(match.index, 0, data)
+                      }),
+                    )
+                  })
+
+            const messagesReq =
+              cached && !opts?.force
+                ? Promise.resolve()
+                : loadMessages({
+                    directory,
+                    client,
+                    setStore,
+                    sessionID,
+                    limit,
+                  })
+
+            await Promise.all([sessionReq, messagesReq])
+          })
         },
-        async diff(sessionID: string) {
+        async diff(sessionID: string, opts?: { force?: boolean }) {
           const directory = sdk.directory
           const client = sdk.client
           const [store, setStore] = globalSync.child(directory)
           touch(directory, setStore, sessionID)
-          if (store.session_diff[sessionID] !== undefined) return
+          if (store.session_diff[sessionID] !== undefined && !opts?.force) return
 
           const key = keyFor(directory, sessionID)
           return runInflight(inflightDiff, key, () =>
@@ -337,7 +510,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             }),
           )
         },
-        async todo(sessionID: string) {
+        async todo(sessionID: string, opts?: { force?: boolean }) {
           const directory = sdk.directory
           const client = sdk.client
           const [store, setStore] = globalSync.child(directory)
@@ -348,7 +521,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             if (cached === undefined) {
               globalSync.todo.set(sessionID, existing)
             }
-            return
+            if (!opts?.force) return
           }
 
           if (cached !== undefined) {
@@ -372,7 +545,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             if (store.message[sessionID] === undefined) return false
             if (meta.limit[key] === undefined) return false
             if (meta.complete[key]) return false
-            return true
+            return !!meta.cursor[key]
           },
           loading(sessionID: string) {
             const key = keyFor(sdk.directory, sessionID)
@@ -387,14 +560,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             const step = count ?? messagePageSize
             if (meta.loading[key]) return
             if (meta.complete[key]) return
+            const before = meta.cursor[key]
+            if (!before) return
 
-            const currentLimit = meta.limit[key] ?? messagePageSize
             await loadMessages({
               directory,
               client,
               setStore,
               sessionID,
-              limit: currentLimit + step,
+              limit: step,
+              before,
+              mode: "prepend",
             })
           },
         },

+ 51 - 0
packages/app/src/context/terminal-title.ts

@@ -0,0 +1,51 @@
+import { dict as ar } from "@/i18n/ar"
+import { dict as br } from "@/i18n/br"
+import { dict as bs } from "@/i18n/bs"
+import { dict as da } from "@/i18n/da"
+import { dict as de } from "@/i18n/de"
+import { dict as en } from "@/i18n/en"
+import { dict as es } from "@/i18n/es"
+import { dict as fr } from "@/i18n/fr"
+import { dict as ja } from "@/i18n/ja"
+import { dict as ko } from "@/i18n/ko"
+import { dict as no } from "@/i18n/no"
+import { dict as pl } from "@/i18n/pl"
+import { dict as ru } from "@/i18n/ru"
+import { dict as th } from "@/i18n/th"
+import { dict as tr } from "@/i18n/tr"
+import { dict as zh } from "@/i18n/zh"
+import { dict as zht } from "@/i18n/zht"
+
+const numbered = Array.from(
+  new Set([
+    en["terminal.title.numbered"],
+    ar["terminal.title.numbered"],
+    br["terminal.title.numbered"],
+    bs["terminal.title.numbered"],
+    da["terminal.title.numbered"],
+    de["terminal.title.numbered"],
+    es["terminal.title.numbered"],
+    fr["terminal.title.numbered"],
+    ja["terminal.title.numbered"],
+    ko["terminal.title.numbered"],
+    no["terminal.title.numbered"],
+    pl["terminal.title.numbered"],
+    ru["terminal.title.numbered"],
+    th["terminal.title.numbered"],
+    tr["terminal.title.numbered"],
+    zh["terminal.title.numbered"],
+    zht["terminal.title.numbered"],
+  ]),
+)
+
+export function defaultTitle(number: number) {
+  return en["terminal.title.numbered"].replace("{{number}}", String(number))
+}
+
+export function isDefaultTitle(title: string, number: number) {
+  return numbered.some((text) => title === text.replace("{{number}}", String(number)))
+}
+
+export function titleNumber(title: string, max: number) {
+  return Array.from({ length: max }, (_, idx) => idx + 1).find((number) => isDefaultTitle(title, number))
+}

+ 77 - 56
packages/app/src/context/terminal.tsx

@@ -4,6 +4,7 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
 import { useParams } from "@solidjs/router"
 import { useSDK } from "./sdk"
 import type { Platform } from "./platform"
+import { defaultTitle, titleNumber } from "./terminal-title"
 import { Persist, persisted, removePersisted } from "@/utils/persist"
 
 export type LocalPTY = {
@@ -33,11 +34,7 @@ function num(value: unknown) {
 }
 
 function numberFromTitle(title: string) {
-  const match = title.match(/^Terminal (\d+)$/)
-  if (!match) return
-  const value = Number(match[1])
-  if (!Number.isFinite(value) || value <= 0) return
-  return value
+  return titleNumber(title, MAX_TERMINAL_SESSIONS)
 }
 
 function pty(value: unknown): LocalPTY | undefined {
@@ -188,6 +185,60 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
   })
   onCleanup(unsub)
 
+  const update = (client: ReturnType<typeof useSDK>["client"], pty: Partial<LocalPTY> & { id: string }) => {
+    const index = store.all.findIndex((x) => x.id === pty.id)
+    const previous = index >= 0 ? store.all[index] : undefined
+    if (index >= 0) {
+      setStore("all", index, (item) => ({ ...item, ...pty }))
+    }
+    client.pty
+      .update({
+        ptyID: pty.id,
+        title: pty.title,
+        size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
+      })
+      .catch((error: unknown) => {
+        if (previous) {
+          const currentIndex = store.all.findIndex((item) => item.id === pty.id)
+          if (currentIndex >= 0) setStore("all", currentIndex, previous)
+        }
+        console.error("Failed to update terminal", error)
+      })
+  }
+
+  const clone = async (client: ReturnType<typeof useSDK>["client"], id: string) => {
+    const index = store.all.findIndex((x) => x.id === id)
+    const pty = store.all[index]
+    if (!pty) return
+    const next = await client.pty
+      .create({
+        title: pty.title,
+      })
+      .catch((error: unknown) => {
+        console.error("Failed to clone terminal", error)
+        return undefined
+      })
+    if (!next?.data) return
+
+    const active = store.active === pty.id
+
+    batch(() => {
+      setStore("all", index, {
+        id: next.data.id,
+        title: next.data.title ?? pty.title,
+        titleNumber: pty.titleNumber,
+        buffer: undefined,
+        cursor: undefined,
+        scrollY: undefined,
+        rows: undefined,
+        cols: undefined,
+      })
+      if (active) {
+        setStore("active", next.data.id)
+      }
+    })
+  }
+
   return {
     ready,
     all: createMemo(() => store.all),
@@ -202,13 +253,13 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
       const nextNumber = pickNextTerminalNumber()
 
       sdk.client.pty
-        .create({ title: `Terminal ${nextNumber}` })
+        .create({ title: defaultTitle(nextNumber) })
         .then((pty: { data?: { id?: string; title?: string } }) => {
           const id = pty.data?.id
           if (!id) return
           const newTerminal = {
             id,
-            title: pty.data?.title ?? "Terminal",
+            title: pty.data?.title ?? defaultTitle(nextNumber),
             titleNumber: nextNumber,
           }
           setStore("all", store.all.length, newTerminal)
@@ -219,24 +270,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
         })
     },
     update(pty: Partial<LocalPTY> & { id: string }) {
-      const index = store.all.findIndex((x) => x.id === pty.id)
-      const previous = index >= 0 ? store.all[index] : undefined
-      if (index >= 0) {
-        setStore("all", index, (item) => ({ ...item, ...pty }))
-      }
-      sdk.client.pty
-        .update({
-          ptyID: pty.id,
-          title: pty.title,
-          size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
-        })
-        .catch((error: unknown) => {
-          if (previous) {
-            const currentIndex = store.all.findIndex((item) => item.id === pty.id)
-            if (currentIndex >= 0) setStore("all", currentIndex, previous)
-          }
-          console.error("Failed to update terminal", error)
-        })
+      update(sdk.client, pty)
     },
     trim(id: string) {
       const index = store.all.findIndex((x) => x.id === id)
@@ -251,37 +285,23 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
       })
     },
     async clone(id: string) {
-      const index = store.all.findIndex((x) => x.id === id)
-      const pty = store.all[index]
-      if (!pty) return
-      const clone = await sdk.client.pty
-        .create({
-          title: pty.title,
-        })
-        .catch((error: unknown) => {
-          console.error("Failed to clone terminal", error)
-          return undefined
-        })
-      if (!clone?.data) return
-
-      const active = store.active === pty.id
-
-      batch(() => {
-        setStore("all", index, {
-          id: clone.data.id,
-          title: clone.data.title ?? pty.title,
-          titleNumber: pty.titleNumber,
-          // New PTY process, so start clean.
-          buffer: undefined,
-          cursor: undefined,
-          scrollY: undefined,
-          rows: undefined,
-          cols: undefined,
-        })
-        if (active) {
-          setStore("active", clone.data.id)
-        }
-      })
+      await clone(sdk.client, id)
+    },
+    bind() {
+      const client = sdk.client
+      return {
+        trim(id: string) {
+          const index = store.all.findIndex((x) => x.id === id)
+          if (index === -1) return
+          setStore("all", index, (pty) => trimTerminal(pty))
+        },
+        update(pty: Partial<LocalPTY> & { id: string }) {
+          update(client, pty)
+        },
+        async clone(id: string) {
+          await clone(client, id)
+        },
+      }
     },
     open(id: string) {
       setStore("active", id)
@@ -406,6 +426,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
       trim: (id: string) => workspace().trim(id),
       trimAll: () => workspace().trimAll(),
       clone: (id: string) => workspace().clone(id),
+      bind: () => workspace(),
       open: (id: string) => workspace().open(id),
       close: (id: string) => workspace().close(id),
       move: (id: string, to: number) => workspace().move(id, to),

+ 24 - 14
packages/app/src/entry.tsx

@@ -1,6 +1,5 @@
 // @refresh reload
 
-import { iife } from "@opencode-ai/util/iife"
 import { render } from "solid-js/web"
 import { AppBaseProviders, AppInterface } from "@/app"
 import { type Platform, PlatformProvider } from "@/context/platform"
@@ -98,6 +97,19 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
   throw new Error(getRootNotFoundError())
 }
 
+const getCurrentUrl = () => {
+  if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
+  if (import.meta.env.DEV)
+    return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
+  return location.origin
+}
+
+const getDefaultUrl = () => {
+  const lsDefault = readDefaultServerUrl()
+  if (lsDefault) return lsDefault
+  return getCurrentUrl()
+}
+
 const platform: Platform = {
   platform: "web",
   version: pkg.version,
@@ -106,26 +118,24 @@ const platform: Platform = {
   forward,
   restart,
   notify,
-  getDefaultServerUrl: async () => readDefaultServerUrl(),
-  setDefaultServerUrl: writeDefaultServerUrl,
+  getDefaultServer: async () => {
+    const stored = readDefaultServerUrl()
+    return stored ? ServerConnection.Key.make(stored) : null
+  },
+  setDefaultServer: writeDefaultServerUrl,
 }
 
-const defaultUrl = iife(() => {
-  const lsDefault = readDefaultServerUrl()
-  if (lsDefault) return lsDefault
-  if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
-  if (import.meta.env.DEV)
-    return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
-  return location.origin
-})
-
 if (root instanceof HTMLElement) {
-  const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
+  const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
   render(
     () => (
       <PlatformProvider value={platform}>
         <AppBaseProviders>
-          <AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} />
+          <AppInterface
+            defaultServer={ServerConnection.Key.make(getDefaultUrl())}
+            servers={[server]}
+            disableHealthCheck
+          />
         </AppBaseProviders>
       </PlatformProvider>
     ),

+ 18 - 16
packages/app/src/hooks/use-providers.ts

@@ -18,25 +18,27 @@ const popularProviderSet = new Set(popularProviders)
 export function useProviders() {
   const globalSync = useGlobalSync()
   const params = useParams()
-  const currentDirectory = createMemo(() => decode64(params.dir) ?? "")
-  const providers = createMemo(() => {
-    if (currentDirectory()) {
-      const [projectStore] = globalSync.child(currentDirectory())
+  const dir = createMemo(() => decode64(params.dir) ?? "")
+  const providers = () => {
+    if (dir()) {
+      const [projectStore] = globalSync.child(dir())
       return projectStore.provider
     }
     return globalSync.data.provider
-  })
-  const connectedIDs = createMemo(() => new Set(providers().connected))
-  const connected = createMemo(() => providers().all.filter((p) => connectedIDs().has(p.id)))
-  const paid = createMemo(() =>
-    connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)),
-  )
-  const popular = createMemo(() => providers().all.filter((p) => popularProviderSet.has(p.id)))
+  }
   return {
-    all: createMemo(() => providers().all),
-    default: createMemo(() => providers().default),
-    popular,
-    connected,
-    paid,
+    all: () => providers().all,
+    default: () => providers().default,
+    popular: () => providers().all.filter((p) => popularProviderSet.has(p.id)),
+    connected: () => {
+      const connected = new Set(providers().connected)
+      return providers().all.filter((p) => connected.has(p.id))
+    },
+    paid: () => {
+      const connected = new Set(providers().connected)
+      return providers().all.filter(
+        (p) => connected.has(p.id) && (p.id !== "opencode" || Object.values(p.models).some((m) => m.cost?.input)),
+      )
+    },
   }
 }

+ 106 - 3
packages/app/src/i18n/ar.ts

@@ -104,6 +104,7 @@ export const dict = {
   "dialog.model.empty": "لا توجد نتائج للنماذج",
   "dialog.model.manage": "إدارة النماذج",
   "dialog.model.manage.description": "تخصيص النماذج التي تظهر في محدد النماذج.",
+  "dialog.model.manage.provider.toggle": "تبديل جميع نماذج {{provider}}",
   "dialog.model.unpaid.freeModels.title": "نماذج مجانية مقدمة من OpenCode",
   "dialog.model.unpaid.addMore.title": "إضافة المزيد من النماذج من موفرين مشهورين",
   "dialog.provider.viewAll": "عرض المزيد من الموفرين",
@@ -203,6 +204,7 @@ export const dict = {
   "common.cancel": "إلغاء",
   "common.connect": "اتصال",
   "common.disconnect": "قطع الاتصال",
+  "common.continue": "إرسال",
   "common.submit": "إرسال",
   "common.save": "حفظ",
   "common.saving": "جارٍ الحفظ...",
@@ -243,7 +245,7 @@ export const dict = {
   "prompt.example.25": "كيف تعمل متغيرات البيئة هنا؟",
   "prompt.popover.emptyResults": "لا توجد نتائج مطابقة",
   "prompt.popover.emptyCommands": "لا توجد أوامر مطابقة",
-  "prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا",
+  "prompt.dropzone.label": "أفلت الصور أو ملفات PDF أو الملفات النصية هنا",
   "prompt.dropzone.file.label": "أفلت لإشارة @ للملف",
   "prompt.slash.badge.custom": "مخصص",
   "prompt.slash.badge.skill": "مهارة",
@@ -256,8 +258,8 @@ export const dict = {
   "prompt.attachment.remove": "إزالة المرفق",
   "prompt.action.send": "إرسال",
   "prompt.action.stop": "توقف",
-  "prompt.toast.pasteUnsupported.title": "لصق غير مدعوم",
-  "prompt.toast.pasteUnsupported.description": "يمكن لصق الصور أو ملفات PDF فقط هنا.",
+  "prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم",
+  "prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.",
   "prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً",
   "prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.",
   "prompt.toast.worktreeCreateFailed.title": "فشل إنشاء شجرة العمل",
@@ -288,6 +290,11 @@ export const dict = {
   "dialog.server.add.error": "تعذر الاتصال بالخادم",
   "dialog.server.add.checking": "جارٍ التحقق...",
   "dialog.server.add.button": "إضافة خادم",
+  "dialog.server.add.name": "اسم الخادم (اختياري)",
+  "dialog.server.add.namePlaceholder": "Localhost",
+  "dialog.server.add.username": "اسم المستخدم (اختياري)",
+  "dialog.server.add.password": "كلمة المرور (اختياري)",
+  "dialog.server.edit.title": "تحرير الخادم",
   "dialog.server.default.title": "الخادم الافتراضي",
   "dialog.server.default.description":
     "الاتصال بهذا الخادم عند بدء تشغيل التطبيق بدلاً من بدء خادم محلي. يتطلب إعادة التشغيل.",
@@ -358,6 +365,7 @@ export const dict = {
   "language.br": "Português (Brasil)",
   "language.bs": "Bosanski",
   "language.th": "ไทย",
+  "language.tr": "Türkçe",
   "toast.language.title": "لغة",
   "toast.language.description": "تم التبديل إلى {{language}}",
   "toast.theme.title": "تم تبديل السمة",
@@ -444,8 +452,11 @@ export const dict = {
   "session.review.loadingChanges": "جارٍ تحميل التغييرات...",
   "session.review.empty": "لا توجد تغييرات في هذه الجلسة بعد",
   "session.review.noChanges": "لا توجد تغييرات",
+  "session.review.noVcs": "لم يتم اكتشاف نظام التحكم في الإصدار Git، لن يتم عرض التغييرات",
+  "session.review.noSnapshot": "تم تعطيل تتبع اللقطات في التكوين، لذا فإن تغييرات الجلسة غير متوفرة",
   "session.files.selectToOpen": "اختر ملفًا لفتحه",
   "session.files.all": "كل الملفات",
+  "session.files.empty": "لا توجد ملفات",
   "session.files.binaryContent": "ملف ثنائي (لا يمكن عرض المحتوى)",
   "session.messages.renderEarlier": "عرض الرسائل السابقة",
   "session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...",
@@ -456,6 +467,17 @@ export const dict = {
   "session.todo.title": "المهام",
   "session.todo.collapse": "طي",
   "session.todo.expand": "توسيع",
+  "session.followupDock.summary.one": "{{count}} رسالة في الانتظار",
+  "session.followupDock.summary.other": "{{count}} رسائل في الانتظار",
+  "session.followupDock.sendNow": "إرسال الآن",
+  "session.followupDock.edit": "تحرير",
+  "session.followupDock.collapse": "طي الرسائل المنتظرة",
+  "session.followupDock.expand": "توسيع الرسائل المنتظرة",
+  "session.revertDock.summary.one": "{{count}} رسالة تم التراجع عنها",
+  "session.revertDock.summary.other": "{{count}} رسائل تم التراجع عنها",
+  "session.revertDock.collapse": "طي الرسائل التي تم التراجع عنها",
+  "session.revertDock.expand": "توسيع الرسائل التي تم التراجع عنها",
+  "session.revertDock.restore": "استعادة الرسالة",
   "session.new.title": "ابنِ أي شيء",
   "session.new.worktree.main": "الفرع الرئيسي",
   "session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
@@ -538,10 +560,18 @@ export const dict = {
   "settings.general.row.language.description": "تغيير لغة العرض لـ OpenCode",
   "settings.general.row.appearance.title": "المظهر",
   "settings.general.row.appearance.description": "تخصيص كيفية ظهور OpenCode على جهازك",
+  "settings.general.row.colorScheme.title": "مخطط الألوان",
+  "settings.general.row.colorScheme.description": "اختر ما إذا كان OpenCode يتبع سمة النظام أو الفاتح أو الداكن",
   "settings.general.row.theme.title": "السمة",
   "settings.general.row.theme.description": "تخصيص سمة OpenCode.",
   "settings.general.row.font.title": "الخط",
   "settings.general.row.font.description": "تخصيص الخط الأحادي المستخدم في كتل التعليمات البرمجية",
+  "settings.general.row.followup.title": "سلوك المتابعة",
+  "settings.general.row.followup.description": "اختر ما إذا كانت طلبات المتابعة توجه فورًا أو تنتظر في قائمة انتظار",
+  "settings.general.row.followup.option.queue": "قائمة انتظار",
+  "settings.general.row.followup.option.steer": "توجيه",
+  "settings.general.row.reasoningSummaries.title": "إظهار ملخصات الاستنتاج",
+  "settings.general.row.reasoningSummaries.description": "عرض ملخصات استنتاج النموذج في الشريط الزمني",
   "settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell",
   "settings.general.row.shellToolPartsExpanded.description":
     "إظهار أجزاء أداة shell موسعة بشكل افتراضي في الشريط الزمني",
@@ -749,4 +779,77 @@ export const dict = {
   "common.time.daysAgo.short": "قبل {{count}} ي",
   "settings.providers.connected.environmentDescription": "متصل من متغيرات البيئة الخاصة بك",
   "settings.providers.custom.description": "أضف مزود متوافق مع OpenAI بواسطة عنوان URL الأساسي.",
+
+  "app.server.unreachable": "تعذر الوصول إلى {{server}}",
+  "app.server.retrying": "جاري إعادة المحاولة تلقائيًا...",
+  "app.server.otherServers": "خوادم أخرى",
+  "dialog.server.add.usernamePlaceholder": "اسم المستخدم",
+  "dialog.server.add.passwordPlaceholder": "كلمة المرور",
+  "server.row.noUsername": "لا يوجد اسم مستخدم",
+  "session.review.noVcs.createGit.title": "إنشاء مستودع Git",
+  "session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع",
+  "session.review.noVcs.createGit.actionLoading": "جاري إنشاء مستودع Git...",
+  "session.review.noVcs.createGit.action": "إنشاء مستودع Git",
+  "session.todo.progress": "تم إكمال {{done}} من {{total}} مهام",
+  "session.question.progress": "{{current}} من {{total}} أسئلة",
+  "session.header.open.finder": "Finder",
+  "session.header.open.fileExplorer": "مستكشف الملفات",
+  "session.header.open.fileManager": "مدير الملفات",
+  "session.header.open.app.vscode": "VS Code",
+  "session.header.open.app.cursor": "Cursor",
+  "session.header.open.app.zed": "Zed",
+  "session.header.open.app.textmate": "TextMate",
+  "session.header.open.app.antigravity": "Antigravity",
+  "session.header.open.app.terminal": "المحطة الطرفية",
+  "session.header.open.app.iterm2": "iTerm2",
+  "session.header.open.app.ghostty": "Ghostty",
+  "session.header.open.app.warp": "Warp",
+  "session.header.open.app.xcode": "Xcode",
+  "session.header.open.app.androidStudio": "Android Studio",
+  "session.header.open.app.powershell": "PowerShell",
+  "session.header.open.app.sublimeText": "Sublime Text",
+  "debugBar.ariaLabel": "تشخيص أداء التطوير",
+  "debugBar.na": "غير متاح",
+  "debugBar.nav.label": "NAV",
+  "debugBar.nav.tip": "آخر انتقال مكتمل للمسار يمس صفحة جلسة، مُقاسًا من بدء التوجيه حتى أول رسم بعد استقراره.",
+  "debugBar.fps.label": "FPS",
+  "debugBar.fps.tip": "الإطارات المتجددة في الثانية خلال آخر 5 ثوانٍ.",
+  "debugBar.frame.label": "FRAME",
+  "debugBar.frame.tip": "أسوأ وقت للإطار خلال آخر 5 ثوانٍ.",
+  "debugBar.jank.label": "JANK",
+  "debugBar.jank.tip": "الإطارات التي تزيد عن 32 مللي ثانية في آخر 5 ثوانٍ.",
+  "debugBar.long.label": "LONG",
+  "debugBar.long.tip": "الوقت المحظور وعدد المهام الطويلة في آخر 5 ثوانٍ. أقصى مهمة: {{max}}.",
+  "debugBar.delay.label": "DELAY",
+  "debugBar.delay.tip": "أسوأ تأخير إدخال تمت ملاحظته في آخر 5 ثوانٍ.",
+  "debugBar.inp.label": "INP",
+  "debugBar.inp.tip": "مدة التفاعل التقريبية خلال آخر 5 ثوانٍ. هذا يشبه INP، وليس Web Vitals INP الرسمي.",
+  "debugBar.cls.label": "CLS",
+  "debugBar.cls.tip": "التحول التخطيطي التراكمي لعمر التطبيق الحالي.",
+  "debugBar.mem.label": "MEM",
+  "debugBar.mem.tipUnavailable": "كومة JS المستخدمة مقابل حد الكومة. Chromium فقط.",
+  "debugBar.mem.tip": "كومة JS المستخدمة مقابل حد الكومة. {{used}} من {{limit}}.",
+  "common.key.ctrl": "Ctrl",
+  "common.key.alt": "Alt",
+  "common.key.shift": "Shift",
+  "common.key.meta": "Meta",
+  "common.key.space": "Space",
+  "common.key.backspace": "Backspace",
+  "common.key.enter": "Enter",
+  "common.key.tab": "Tab",
+  "common.key.delete": "Delete",
+  "common.key.home": "Home",
+  "common.key.end": "End",
+  "common.key.pageUp": "Page Up",
+  "common.key.pageDown": "Page Down",
+  "common.key.insert": "Insert",
+  "common.unknown": "غير معروف",
+  "error.page.circular": "[دائري]",
+  "error.globalSDK.noServerAvailable": "لا يوجد خادم متاح",
+  "error.globalSDK.serverNotAvailable": "الخادم غير متاح",
+  "error.childStore.persistedCacheCreateFailed": "فشل إنشاء ذاكرة التخزين المؤقت الدائمة",
+  "error.childStore.persistedProjectMetadataCreateFailed": "فشل إنشاء بيانات تعريف المشروع الدائمة",
+  "error.childStore.persistedProjectIconCreateFailed": "فشل إنشاء أيقونة المشروع الدائمة",
+  "error.childStore.storeCreateFailed": "فشل إنشاء المخزن",
+  "terminal.connectionLost.abnormalClose": "تم إغلاق WebSocket بشكل غير طبيعي: {{code}}",
 }

+ 110 - 3
packages/app/src/i18n/br.ts

@@ -104,6 +104,7 @@ export const dict = {
   "dialog.model.empty": "Nenhum resultado de modelo",
   "dialog.model.manage": "Gerenciar modelos",
   "dialog.model.manage.description": "Personalizar quais modelos aparecem no seletor de modelos.",
+  "dialog.model.manage.provider.toggle": "Alternar todos os modelos {{provider}}",
   "dialog.model.unpaid.freeModels.title": "Modelos gratuitos fornecidos pelo OpenCode",
   "dialog.model.unpaid.addMore.title": "Adicionar mais modelos de provedores populares",
   "dialog.provider.viewAll": "Ver mais provedores",
@@ -203,6 +204,7 @@ export const dict = {
   "common.cancel": "Cancelar",
   "common.connect": "Conectar",
   "common.disconnect": "Desconectar",
+  "common.continue": "Enviar",
   "common.submit": "Enviar",
   "common.save": "Salvar",
   "common.saving": "Salvando...",
@@ -243,7 +245,7 @@ export const dict = {
   "prompt.example.25": "Como funcionam as variáveis de ambiente aqui?",
   "prompt.popover.emptyResults": "Nenhum resultado correspondente",
   "prompt.popover.emptyCommands": "Nenhum comando correspondente",
-  "prompt.dropzone.label": "Solte imagens ou PDFs aqui",
+  "prompt.dropzone.label": "Arraste imagens, PDFs ou arquivos de texto aqui",
   "prompt.dropzone.file.label": "Solte para @mencionar arquivo",
   "prompt.slash.badge.custom": "personalizado",
   "prompt.slash.badge.skill": "skill",
@@ -256,8 +258,8 @@ export const dict = {
   "prompt.attachment.remove": "Remover anexo",
   "prompt.action.send": "Enviar",
   "prompt.action.stop": "Parar",
-  "prompt.toast.pasteUnsupported.title": "Colagem não suportada",
-  "prompt.toast.pasteUnsupported.description": "Somente imagens ou PDFs podem ser colados aqui.",
+  "prompt.toast.pasteUnsupported.title": "Anexo não suportado",
+  "prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.",
   "prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo",
   "prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.",
   "prompt.toast.worktreeCreateFailed.title": "Falha ao criar worktree",
@@ -288,6 +290,11 @@ export const dict = {
   "dialog.server.add.error": "Não foi possível conectar ao servidor",
   "dialog.server.add.checking": "Verificando...",
   "dialog.server.add.button": "Adicionar",
+  "dialog.server.add.name": "Nome do servidor (opcional)",
+  "dialog.server.add.namePlaceholder": "Localhost",
+  "dialog.server.add.username": "Nome de usuário (opcional)",
+  "dialog.server.add.password": "Senha (opcional)",
+  "dialog.server.edit.title": "Editar servidor",
   "dialog.server.default.title": "Servidor padrão",
   "dialog.server.default.description":
     "Conectar a este servidor na inicialização do aplicativo ao invés de iniciar um servidor local. Requer reinicialização.",
@@ -359,6 +366,7 @@ export const dict = {
   "language.br": "Português (Brasil)",
   "language.bs": "Bosanski",
   "language.th": "ไทย",
+  "language.tr": "Türkçe",
   "toast.language.title": "Idioma",
   "toast.language.description": "Alterado para {{language}}",
   "toast.theme.title": "Tema alterado",
@@ -446,9 +454,13 @@ export const dict = {
   "session.review.change.other": "Alterações",
   "session.review.loadingChanges": "Carregando alterações...",
   "session.review.empty": "Nenhuma alteração nesta sessão ainda",
+  "session.review.noVcs": "Nenhum Sistema de Controle de Versão Git detectado, alterações não exibidas",
+  "session.review.noSnapshot":
+    "O rastreamento de snapshot está desabilitado na configuração, então as alterações da sessão estão indisponíveis",
   "session.review.noChanges": "Sem alterações",
   "session.files.selectToOpen": "Selecione um arquivo para abrir",
   "session.files.all": "Todos os arquivos",
+  "session.files.empty": "Nenhum arquivo",
   "session.files.binaryContent": "Arquivo binário (conteúdo não pode ser exibido)",
   "session.messages.renderEarlier": "Renderizar mensagens anteriores",
   "session.messages.loadingEarlier": "Carregando mensagens anteriores...",
@@ -459,6 +471,17 @@ export const dict = {
   "session.todo.title": "Tarefas",
   "session.todo.collapse": "Recolher",
   "session.todo.expand": "Expandir",
+  "session.followupDock.summary.one": "{{count}} mensagem na fila",
+  "session.followupDock.summary.other": "{{count}} mensagens na fila",
+  "session.followupDock.sendNow": "Enviar agora",
+  "session.followupDock.edit": "Editar",
+  "session.followupDock.collapse": "Recolher mensagens na fila",
+  "session.followupDock.expand": "Expandir mensagens na fila",
+  "session.revertDock.summary.one": "{{count}} mensagem revertida",
+  "session.revertDock.summary.other": "{{count}} mensagens revertidas",
+  "session.revertDock.collapse": "Recolher mensagens revertidas",
+  "session.revertDock.expand": "Expandir mensagens revertidas",
+  "session.revertDock.restore": "Restaurar mensagem",
   "session.new.title": "Crie qualquer coisa",
   "session.new.worktree.main": "Branch principal",
   "session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
@@ -544,10 +567,19 @@ export const dict = {
   "settings.general.row.language.description": "Alterar o idioma de exibição do OpenCode",
   "settings.general.row.appearance.title": "Aparência",
   "settings.general.row.appearance.description": "Personalize como o OpenCode aparece no seu dispositivo",
+  "settings.general.row.colorScheme.title": "Esquema de cores",
+  "settings.general.row.colorScheme.description": "Escolha se o OpenCode segue o tema do sistema, claro ou escuro",
   "settings.general.row.theme.title": "Tema",
   "settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
   "settings.general.row.font.title": "Fonte",
   "settings.general.row.font.description": "Personalize a fonte monoespaçada usada em blocos de código",
+  "settings.general.row.followup.title": "Comportamento de acompanhamento",
+  "settings.general.row.followup.description":
+    "Escolha se os prompts de acompanhamento orientam imediatamente ou esperam na fila",
+  "settings.general.row.followup.option.queue": "Fila",
+  "settings.general.row.followup.option.steer": "Orientar",
+  "settings.general.row.reasoningSummaries.title": "Mostrar resumos de raciocínio",
+  "settings.general.row.reasoningSummaries.description": "Exibir resumos de raciocínio do modelo na linha do tempo",
   "settings.general.row.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell",
   "settings.general.row.shellToolPartsExpanded.description":
     "Mostrar partes da ferramenta shell expandidas por padrão na linha do tempo",
@@ -757,4 +789,79 @@ export const dict = {
   "common.time.daysAgo.short": "{{count}}d atrás",
   "settings.providers.connected.environmentDescription": "Conectado a partir de suas variáveis de ambiente",
   "settings.providers.custom.description": "Adicionar um provedor compatível com a OpenAI através do URL base.",
+
+  "app.server.unreachable": "Não foi possível conectar a {{server}}",
+  "app.server.retrying": "Tentando novamente automaticamente...",
+  "app.server.otherServers": "Outros servidores",
+  "dialog.server.add.usernamePlaceholder": "nome de usuário",
+  "dialog.server.add.passwordPlaceholder": "senha",
+  "server.row.noUsername": "sem nome de usuário",
+  "session.review.noVcs.createGit.title": "Criar um repositório Git",
+  "session.review.noVcs.createGit.description": "Rastreie, revise e desfaça alterações neste projeto",
+  "session.review.noVcs.createGit.actionLoading": "Criando repositório Git...",
+  "session.review.noVcs.createGit.action": "Criar repositório Git",
+  "session.todo.progress": "{{done}} de {{total}} tarefas concluídas",
+  "session.question.progress": "{{current}} de {{total}} perguntas",
+  "session.header.open.finder": "Finder",
+  "session.header.open.fileExplorer": "Explorador de Arquivos",
+  "session.header.open.fileManager": "Gerenciador de Arquivos",
+  "session.header.open.app.vscode": "VS Code",
+  "session.header.open.app.cursor": "Cursor",
+  "session.header.open.app.zed": "Zed",
+  "session.header.open.app.textmate": "TextMate",
+  "session.header.open.app.antigravity": "Antigravity",
+  "session.header.open.app.terminal": "Terminal",
+  "session.header.open.app.iterm2": "iTerm2",
+  "session.header.open.app.ghostty": "Ghostty",
+  "session.header.open.app.warp": "Warp",
+  "session.header.open.app.xcode": "Xcode",
+  "session.header.open.app.androidStudio": "Android Studio",
+  "session.header.open.app.powershell": "PowerShell",
+  "session.header.open.app.sublimeText": "Sublime Text",
+  "debugBar.ariaLabel": "Diagnóstico de desempenho de desenvolvimento",
+  "debugBar.na": "n/a",
+  "debugBar.nav.label": "NAV",
+  "debugBar.nav.tip":
+    "Última transição de rota concluída tocando em uma página de sessão, medida desde o início do roteador até a primeira pintura após o estabelecimento.",
+  "debugBar.fps.label": "FPS",
+  "debugBar.fps.tip": "Quadros por segundo nos últimos 5 segundos.",
+  "debugBar.frame.label": "FRAME",
+  "debugBar.frame.tip": "Pior tempo de quadro nos últimos 5 segundos.",
+  "debugBar.jank.label": "JANK",
+  "debugBar.jank.tip": "Quadros acima de 32ms nos últimos 5 segundos.",
+  "debugBar.long.label": "LONG",
+  "debugBar.long.tip": "Tempo bloqueado e contagem de tarefas longas nos últimos 5 segundos. Tarefa máx: {{max}}.",
+  "debugBar.delay.label": "DELAY",
+  "debugBar.delay.tip": "Pior atraso de entrada observado nos últimos 5 segundos.",
+  "debugBar.inp.label": "INP",
+  "debugBar.inp.tip":
+    "Duração aproximada da interação nos últimos 5 segundos. Isso é semelhante ao INP, não o INP oficial do Web Vitals.",
+  "debugBar.cls.label": "CLS",
+  "debugBar.cls.tip": "Mudança cumulativa de layout para o tempo de vida atual do aplicativo.",
+  "debugBar.mem.label": "MEM",
+  "debugBar.mem.tipUnavailable": "Heap JS usado vs limite de heap. Apenas Chromium.",
+  "debugBar.mem.tip": "Heap JS usado vs limite de heap. {{used}} de {{limit}}.",
+  "common.key.ctrl": "Ctrl",
+  "common.key.alt": "Alt",
+  "common.key.shift": "Shift",
+  "common.key.meta": "Meta",
+  "common.key.space": "Espaço",
+  "common.key.backspace": "Backspace",
+  "common.key.enter": "Enter",
+  "common.key.tab": "Tab",
+  "common.key.delete": "Delete",
+  "common.key.home": "Home",
+  "common.key.end": "End",
+  "common.key.pageUp": "Page Up",
+  "common.key.pageDown": "Page Down",
+  "common.key.insert": "Insert",
+  "common.unknown": "desconhecido",
+  "error.page.circular": "[Circular]",
+  "error.globalSDK.noServerAvailable": "Nenhum servidor disponível",
+  "error.globalSDK.serverNotAvailable": "Servidor indisponível",
+  "error.childStore.persistedCacheCreateFailed": "Falha ao criar cache persistente",
+  "error.childStore.persistedProjectMetadataCreateFailed": "Falha ao criar metadados de projeto persistentes",
+  "error.childStore.persistedProjectIconCreateFailed": "Falha ao criar ícone de projeto persistente",
+  "error.childStore.storeCreateFailed": "Falha ao criar armazenamento",
+  "terminal.connectionLost.abnormalClose": "WebSocket fechado anormalmente: {{code}}",
 }

+ 109 - 3
packages/app/src/i18n/bs.ts

@@ -113,6 +113,7 @@ export const dict = {
   "dialog.model.empty": "Nema rezultata za modele",
   "dialog.model.manage": "Upravljaj modelima",
   "dialog.model.manage.description": "Prilagodi koji se modeli prikazuju u izborniku modela.",
+  "dialog.model.manage.provider.toggle": "Uključi/isključi sve {{provider}} modele",
 
   "dialog.model.unpaid.freeModels.title": "Besplatni modeli koje obezbjeđuje OpenCode",
   "dialog.model.unpaid.addMore.title": "Dodaj još modela od popularnih provajdera",
@@ -220,6 +221,7 @@ export const dict = {
   "common.cancel": "Otkaži",
   "common.connect": "Poveži",
   "common.disconnect": "Prekini vezu",
+  "common.continue": "Pošalji",
   "common.submit": "Pošalji",
   "common.save": "Sačuvaj",
   "common.saving": "Čuvanje...",
@@ -263,7 +265,7 @@ export const dict = {
 
   "prompt.popover.emptyResults": "Nema rezultata",
   "prompt.popover.emptyCommands": "Nema komandi",
-  "prompt.dropzone.label": "Spusti slike ili PDF-ove ovdje",
+  "prompt.dropzone.label": "Ovdje prevucite slike, PDF-ove ili tekstualne datoteke",
   "prompt.dropzone.file.label": "Spusti za @spominjanje datoteke",
   "prompt.slash.badge.custom": "prilagođeno",
   "prompt.slash.badge.skill": "skill",
@@ -277,8 +279,8 @@ export const dict = {
   "prompt.action.send": "Pošalji",
   "prompt.action.stop": "Zaustavi",
 
-  "prompt.toast.pasteUnsupported.title": "Nepodržano lijepljenje",
-  "prompt.toast.pasteUnsupported.description": "Ovdje se mogu zalijepiti samo slike ili PDF-ovi.",
+  "prompt.toast.pasteUnsupported.title": "Nepodržan prilog",
+  "prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.",
   "prompt.toast.modelAgentRequired.title": "Odaberi agenta i model",
   "prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.",
   "prompt.toast.worktreeCreateFailed.title": "Neuspješno kreiranje worktree-a",
@@ -315,6 +317,11 @@ export const dict = {
   "dialog.server.add.error": "Nije moguće povezati se na server",
   "dialog.server.add.checking": "Provjera...",
   "dialog.server.add.button": "Dodaj server",
+  "dialog.server.add.name": "Ime servera (opcionalno)",
+  "dialog.server.add.namePlaceholder": "Localhost",
+  "dialog.server.add.username": "Korisničko ime (opcionalno)",
+  "dialog.server.add.password": "Lozinka (opcionalno)",
+  "dialog.server.edit.title": "Uredi server",
   "dialog.server.default.title": "Podrazumijevani server",
   "dialog.server.default.description":
     "Poveži se na ovaj server pri pokretanju aplikacije umjesto pokretanja lokalnog servera. Potreban je restart.",
@@ -393,6 +400,7 @@ export const dict = {
   "language.br": "Português (Brasil)",
   "language.bs": "Bosanski",
   "language.th": "ไทย",
+  "language.tr": "Türkçe",
 
   "toast.language.title": "Jezik",
   "toast.language.description": "Prebačeno na {{language}}",
@@ -498,10 +506,14 @@ export const dict = {
   "session.review.change.other": "Izmjene",
   "session.review.loadingChanges": "Učitavanje izmjena...",
   "session.review.empty": "Još nema izmjena u ovoj sesiji",
+  "session.review.noVcs": "Nije detektovan Git sistem kontrole verzija, promjene se ne prikazuju",
+  "session.review.noSnapshot":
+    "Praćenje snimaka (snapshot) je onemogućeno u konfiguraciji, pa promjene sesije nisu dostupne",
   "session.review.noChanges": "Nema izmjena",
 
   "session.files.selectToOpen": "Odaberi datoteku za otvaranje",
   "session.files.all": "Sve datoteke",
+  "session.files.empty": "Nema datoteka",
   "session.files.binaryContent": "Binarna datoteka (sadržaj se ne može prikazati)",
 
   "session.messages.renderEarlier": "Prikaži ranije poruke",
@@ -514,6 +526,17 @@ export const dict = {
   "session.todo.title": "Zadaci",
   "session.todo.collapse": "Sažmi",
   "session.todo.expand": "Proširi",
+  "session.followupDock.summary.one": "{{count}} poruka na čekanju",
+  "session.followupDock.summary.other": "{{count}} poruka na čekanju",
+  "session.followupDock.sendNow": "Pošalji sada",
+  "session.followupDock.edit": "Uredi",
+  "session.followupDock.collapse": "Sažmi poruke na čekanju",
+  "session.followupDock.expand": "Proširi poruke na čekanju",
+  "session.revertDock.summary.one": "{{count}} vraćena poruka",
+  "session.revertDock.summary.other": "{{count}} vraćenih poruka",
+  "session.revertDock.collapse": "Sažmi vraćene poruke",
+  "session.revertDock.expand": "Proširi vraćene poruke",
+  "session.revertDock.restore": "Vrati poruku",
 
   "session.new.title": "Napravi bilo šta",
   "session.new.worktree.main": "Glavna grana",
@@ -609,10 +632,18 @@ export const dict = {
   "settings.general.row.language.description": "Promijeni jezik prikaza u OpenCode-u",
   "settings.general.row.appearance.title": "Izgled",
   "settings.general.row.appearance.description": "Prilagodi kako OpenCode izgleda na tvom uređaju",
+  "settings.general.row.colorScheme.title": "Šema boja",
+  "settings.general.row.colorScheme.description": "Odaberi da li OpenCode prati sistemsku, svijetlu ili tamnu temu",
   "settings.general.row.theme.title": "Tema",
   "settings.general.row.theme.description": "Prilagodi temu OpenCode-a.",
   "settings.general.row.font.title": "Font",
   "settings.general.row.font.description": "Prilagodi monospace font koji se koristi u blokovima koda",
+  "settings.general.row.followup.title": "Ponašanje nadovezivanja",
+  "settings.general.row.followup.description": "Odaberi da li upiti nadovezivanja usmjeravaju odmah ili čekaju u redu",
+  "settings.general.row.followup.option.queue": "Red čekanja",
+  "settings.general.row.followup.option.steer": "Usmjeri",
+  "settings.general.row.reasoningSummaries.title": "Prikaži sažetke rasuđivanja",
+  "settings.general.row.reasoningSummaries.description": "Prikaži sažetke rasuđivanja modela na vremenskoj traci",
 
   "settings.general.row.shellToolPartsExpanded.title": "Proširi dijelove shell alata",
   "settings.general.row.shellToolPartsExpanded.description":
@@ -834,4 +865,79 @@ export const dict = {
   "common.time.daysAgo.short": "prije {{count}} d",
   "settings.providers.connected.environmentDescription": "Povezano sa vašim varijablama okruženja",
   "settings.providers.custom.description": "Dodajte provajdera kompatibilnog s OpenAI putem osnovnog URL-a.",
+
+  "app.server.unreachable": "Nije moguće pristupiti {{server}}",
+  "app.server.retrying": "Automatski ponovni pokušaj...",
+  "app.server.otherServers": "Drugi serveri",
+  "dialog.server.add.usernamePlaceholder": "korisničko ime",
+  "dialog.server.add.passwordPlaceholder": "lozinka",
+  "server.row.noUsername": "nema korisničkog imena",
+  "session.review.noVcs.createGit.title": "Kreiraj Git repozitorij",
+  "session.review.noVcs.createGit.description": "Pratite, pregledajte i poništite promjene u ovom projektu",
+  "session.review.noVcs.createGit.actionLoading": "Kreiranje Git repozitorija...",
+  "session.review.noVcs.createGit.action": "Kreiraj Git repozitorij",
+  "session.todo.progress": "{{done}} od {{total}} zadataka završeno",
+  "session.question.progress": "{{current}} od {{total}} pitanja",
+  "session.header.open.finder": "Finder",
+  "session.header.open.fileExplorer": "File Explorer",
+  "session.header.open.fileManager": "File Manager",
+  "session.header.open.app.vscode": "VS Code",
+  "session.header.open.app.cursor": "Cursor",
+  "session.header.open.app.zed": "Zed",
+  "session.header.open.app.textmate": "TextMate",
+  "session.header.open.app.antigravity": "Antigravity",
+  "session.header.open.app.terminal": "Terminal",
+  "session.header.open.app.iterm2": "iTerm2",
+  "session.header.open.app.ghostty": "Ghostty",
+  "session.header.open.app.warp": "Warp",
+  "session.header.open.app.xcode": "Xcode",
+  "session.header.open.app.androidStudio": "Android Studio",
+  "session.header.open.app.powershell": "PowerShell",
+  "session.header.open.app.sublimeText": "Sublime Text",
+  "debugBar.ariaLabel": "Dijagnostika performansi razvoja",
+  "debugBar.na": "n/a",
+  "debugBar.nav.label": "NAV",
+  "debugBar.nav.tip":
+    "Posljednji završeni prelazak rute koji dotiče stranicu sesije, mjeren od početka rutera do prvog iscrtavanja nakon smirivanja.",
+  "debugBar.fps.label": "FPS",
+  "debugBar.fps.tip": "Kadrovi u sekundi tokom posljednjih 5 sekundi.",
+  "debugBar.frame.label": "FRAME",
+  "debugBar.frame.tip": "Najgore vrijeme kadra u posljednjih 5 sekundi.",
+  "debugBar.jank.label": "JANK",
+  "debugBar.jank.tip": "Kadrovi duži od 32ms u posljednjih 5 sekundi.",
+  "debugBar.long.label": "LONG",
+  "debugBar.long.tip": "Blokirano vrijeme i broj dugih zadataka u posljednjih 5 sekundi. Maks zadatak: {{max}}.",
+  "debugBar.delay.label": "DELAY",
+  "debugBar.delay.tip": "Najgore zabilježeno kašnjenje unosa u posljednjih 5 sekundi.",
+  "debugBar.inp.label": "INP",
+  "debugBar.inp.tip":
+    "Približno trajanje interakcije tokom posljednjih 5 sekundi. Ovo je slično INP-u, nije službeni Web Vitals INP.",
+  "debugBar.cls.label": "CLS",
+  "debugBar.cls.tip": "Kumulativni pomak rasporeda za trenutni životni vijek aplikacije.",
+  "debugBar.mem.label": "MEM",
+  "debugBar.mem.tipUnavailable": "Korišteni JS heap naspram limita heapa. Samo Chromium.",
+  "debugBar.mem.tip": "Korišteni JS heap naspram limita heapa. {{used}} od {{limit}}.",
+  "common.key.ctrl": "Ctrl",
+  "common.key.alt": "Alt",
+  "common.key.shift": "Shift",
+  "common.key.meta": "Meta",
+  "common.key.space": "Space",
+  "common.key.backspace": "Backspace",
+  "common.key.enter": "Enter",
+  "common.key.tab": "Tab",
+  "common.key.delete": "Delete",
+  "common.key.home": "Home",
+  "common.key.end": "End",
+  "common.key.pageUp": "Page Up",
+  "common.key.pageDown": "Page Down",
+  "common.key.insert": "Insert",
+  "common.unknown": "nepoznato",
+  "error.page.circular": "[Kružno]",
+  "error.globalSDK.noServerAvailable": "Nema dostupnog servera",
+  "error.globalSDK.serverNotAvailable": "Server nije dostupan",
+  "error.childStore.persistedCacheCreateFailed": "Nije uspjelo kreiranje trajnog keša",
+  "error.childStore.persistedProjectMetadataCreateFailed": "Nije uspjelo kreiranje trajnih metapodataka projekta",
+  "error.childStore.persistedProjectIconCreateFailed": "Nije uspjelo kreiranje trajne ikone projekta",
+  "error.childStore.storeCreateFailed": "Nije uspjelo kreiranje skladišta",
+  "terminal.connectionLost.abnormalClose": "WebSocket zatvoren nenormalno: {{code}}",
 }

+ 109 - 3
packages/app/src/i18n/da.ts

@@ -113,6 +113,7 @@ export const dict = {
   "dialog.model.empty": "Ingen modeller fundet",
   "dialog.model.manage": "Administrer modeller",
   "dialog.model.manage.description": "Tilpas hvilke modeller der vises i modelvælgeren.",
+  "dialog.model.manage.provider.toggle": "Skift alle {{provider}}-modeller",
 
   "dialog.model.unpaid.freeModels.title": "Gratis modeller leveret af OpenCode",
   "dialog.model.unpaid.addMore.title": "Tilføj flere modeller fra populære udbydere",
@@ -218,6 +219,7 @@ export const dict = {
   "common.cancel": "Annuller",
   "common.connect": "Forbind",
   "common.disconnect": "Frakobl",
+  "common.continue": "Indsend",
   "common.submit": "Indsend",
   "common.save": "Gem",
   "common.saving": "Gemmer...",
@@ -261,7 +263,7 @@ export const dict = {
 
   "prompt.popover.emptyResults": "Ingen matchende resultater",
   "prompt.popover.emptyCommands": "Ingen matchende kommandoer",
-  "prompt.dropzone.label": "Slip billeder eller PDF'er her",
+  "prompt.dropzone.label": "Slip billeder, PDF'er eller tekstfiler her",
   "prompt.dropzone.file.label": "Slip for at @nævne fil",
   "prompt.slash.badge.custom": "brugerdefineret",
   "prompt.slash.badge.skill": "skill",
@@ -275,8 +277,8 @@ export const dict = {
   "prompt.action.send": "Send",
   "prompt.action.stop": "Stop",
 
-  "prompt.toast.pasteUnsupported.title": "Ikke understøttet indsæt",
-  "prompt.toast.pasteUnsupported.description": "Kun billeder eller PDF'er kan indsættes her.",
+  "prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning",
+  "prompt.toast.pasteUnsupported.description": "Kun billeder, PDF'er eller tekstfiler kan vedhæftes her.",
   "prompt.toast.modelAgentRequired.title": "Vælg en agent og model",
   "prompt.toast.modelAgentRequired.description": "Vælg en agent og model før du sender en forespørgsel.",
   "prompt.toast.worktreeCreateFailed.title": "Kunne ikke oprette worktree",
@@ -313,6 +315,11 @@ export const dict = {
   "dialog.server.add.error": "Kunne ikke forbinde til server",
   "dialog.server.add.checking": "Tjekker...",
   "dialog.server.add.button": "Tilføj server",
+  "dialog.server.add.name": "Servernavn (valgfrit)",
+  "dialog.server.add.namePlaceholder": "Localhost",
+  "dialog.server.add.username": "Brugernavn (valgfrit)",
+  "dialog.server.add.password": "Adgangskode (valgfrit)",
+  "dialog.server.edit.title": "Rediger server",
   "dialog.server.default.title": "Standardserver",
   "dialog.server.default.description":
     "Forbind til denne server ved start af app i stedet for at starte en lokal server. Kræver genstart.",
@@ -391,6 +398,7 @@ export const dict = {
   "language.br": "Português (Brasil)",
   "language.bs": "Bosanski",
   "language.th": "ไทย",
+  "language.tr": "Türkçe",
 
   "toast.language.title": "Sprog",
   "toast.language.description": "Skiftede til {{language}}",
@@ -495,9 +503,13 @@ export const dict = {
   "session.review.change.other": "Ændringer",
   "session.review.loadingChanges": "Indlæser ændringer...",
   "session.review.empty": "Ingen ændringer i denne session endnu",
+  "session.review.noVcs": "Intet Git versionsstyringssystem fundet, ændringer vises ikke",
+  "session.review.noSnapshot":
+    "Snapshot-sporing er deaktiveret i konfigurationen, så sessionsændringer er ikke tilgængelige",
   "session.review.noChanges": "Ingen ændringer",
   "session.files.selectToOpen": "Vælg en fil at åbne",
   "session.files.all": "Alle filer",
+  "session.files.empty": "Ingen filer",
   "session.files.binaryContent": "Binær fil (indhold kan ikke vises)",
   "session.messages.renderEarlier": "Vis tidligere beskeder",
   "session.messages.loadingEarlier": "Indlæser tidligere beskeder...",
@@ -509,6 +521,17 @@ export const dict = {
   "session.todo.title": "Opgaver",
   "session.todo.collapse": "Skjul",
   "session.todo.expand": "Udvid",
+  "session.followupDock.summary.one": "{{count}} besked i kø",
+  "session.followupDock.summary.other": "{{count}} beskeder i kø",
+  "session.followupDock.sendNow": "Send nu",
+  "session.followupDock.edit": "Rediger",
+  "session.followupDock.collapse": "Skjul beskeder i kø",
+  "session.followupDock.expand": "Udvid beskeder i kø",
+  "session.revertDock.summary.one": "{{count}} tilbagerullet besked",
+  "session.revertDock.summary.other": "{{count}} tilbagerullede beskeder",
+  "session.revertDock.collapse": "Skjul tilbagerullede beskeder",
+  "session.revertDock.expand": "Udvid tilbagerullede beskeder",
+  "session.revertDock.restore": "Gendan besked",
 
   "session.new.title": "Byg hvad som helst",
   "session.new.worktree.main": "Hovedgren",
@@ -604,10 +627,18 @@ export const dict = {
   "settings.general.row.language.description": "Ændr visningssproget for OpenCode",
   "settings.general.row.appearance.title": "Udseende",
   "settings.general.row.appearance.description": "Tilpas hvordan OpenCode ser ud på din enhed",
+  "settings.general.row.colorScheme.title": "Farveskema",
+  "settings.general.row.colorScheme.description": "Vælg om OpenCode følger systemets, lyst eller mørkt tema",
   "settings.general.row.theme.title": "Tema",
   "settings.general.row.theme.description": "Tilpas hvordan OpenCode er temabestemt.",
   "settings.general.row.font.title": "Skrifttype",
   "settings.general.row.font.description": "Tilpas mono-skrifttypen brugt i kodeblokke",
+  "settings.general.row.followup.title": "Opfølgningsadfærd",
+  "settings.general.row.followup.description": "Vælg om opfølgende forespørgsler skal styre straks eller vente i kø",
+  "settings.general.row.followup.option.queue": "Kø",
+  "settings.general.row.followup.option.steer": "Styr",
+  "settings.general.row.reasoningSummaries.title": "Vis tænkeoversigter",
+  "settings.general.row.reasoningSummaries.description": "Vis model tænkeoversigter i tidslinjen",
 
   "settings.general.row.shellToolPartsExpanded.title": "Udvid shell-værktøjsdele",
   "settings.general.row.shellToolPartsExpanded.description": "Vis shell-værktøjsdele udvidet som standard i tidslinjen",
@@ -828,4 +859,79 @@ export const dict = {
   "common.time.daysAgo.short": "{{count}}d siden",
   "settings.providers.connected.environmentDescription": "Tilsluttet fra dine miljøvariabler",
   "settings.providers.custom.description": "Tilføj en OpenAI-kompatibel udbyder via basis-URL.",
+
+  "app.server.unreachable": "Kunne ikke nå {{server}}",
+  "app.server.retrying": "Prøver igen automatisk...",
+  "app.server.otherServers": "Andre servere",
+  "dialog.server.add.usernamePlaceholder": "brugernavn",
+  "dialog.server.add.passwordPlaceholder": "adgangskode",
+  "server.row.noUsername": "intet brugernavn",
+  "session.review.noVcs.createGit.title": "Opret et Git-repository",
+  "session.review.noVcs.createGit.description": "Spor, gennemgå og fortryd ændringer i dette projekt",
+  "session.review.noVcs.createGit.actionLoading": "Opretter Git-repository...",
+  "session.review.noVcs.createGit.action": "Opret Git-repository",
+  "session.todo.progress": "{{done}} af {{total}} opgaver fuldført",
+  "session.question.progress": "{{current}} af {{total}} spørgsmål",
+  "session.header.open.finder": "Finder",
+  "session.header.open.fileExplorer": "Stifinder",
+  "session.header.open.fileManager": "Filhåndtering",
+  "session.header.open.app.vscode": "VS Code",
+  "session.header.open.app.cursor": "Cursor",
+  "session.header.open.app.zed": "Zed",
+  "session.header.open.app.textmate": "TextMate",
+  "session.header.open.app.antigravity": "Antigravity",
+  "session.header.open.app.terminal": "Terminal",
+  "session.header.open.app.iterm2": "iTerm2",
+  "session.header.open.app.ghostty": "Ghostty",
+  "session.header.open.app.warp": "Warp",
+  "session.header.open.app.xcode": "Xcode",
+  "session.header.open.app.androidStudio": "Android Studio",
+  "session.header.open.app.powershell": "PowerShell",
+  "session.header.open.app.sublimeText": "Sublime Text",
+  "debugBar.ariaLabel": "Udviklingsydelsesdiagnostik",
+  "debugBar.na": "n/a",
+  "debugBar.nav.label": "NAV",
+  "debugBar.nav.tip":
+    "Sidste gennemførte ruteovergang, der berører en sessionsside, målt fra routerstart til den første optegning efter den falder til ro.",
+  "debugBar.fps.label": "FPS",
+  "debugBar.fps.tip": "Rullende billeder pr. sekund over de sidste 5 sekunder.",
+  "debugBar.frame.label": "FRAME",
+  "debugBar.frame.tip": "Værste billedtid over de sidste 5 sekunder.",
+  "debugBar.jank.label": "JANK",
+  "debugBar.jank.tip": "Billeder over 32ms i de sidste 5 sekunder.",
+  "debugBar.long.label": "LONG",
+  "debugBar.long.tip": "Blokeret tid og antal lange opgaver i de sidste 5 sekunder. Maks opgave: {{max}}.",
+  "debugBar.delay.label": "DELAY",
+  "debugBar.delay.tip": "Værste observerede inputforsinkelse i de sidste 5 sekunder.",
+  "debugBar.inp.label": "INP",
+  "debugBar.inp.tip":
+    "Omtrentlig interaktionsvarighed over de sidste 5 sekunder. Dette er INP-lignende, ikke den officielle Web Vitals INP.",
+  "debugBar.cls.label": "CLS",
+  "debugBar.cls.tip": "Kumulativt layoutskift for den nuværende app-levetid.",
+  "debugBar.mem.label": "MEM",
+  "debugBar.mem.tipUnavailable": "Brugt JS-heap vs heap-grænse. Kun Chromium.",
+  "debugBar.mem.tip": "Brugt JS-heap vs heap-grænse. {{used}} af {{limit}}.",
+  "common.key.ctrl": "Ctrl",
+  "common.key.alt": "Alt",
+  "common.key.shift": "Shift",
+  "common.key.meta": "Meta",
+  "common.key.space": "Mellemrum",
+  "common.key.backspace": "Backspace",
+  "common.key.enter": "Enter",
+  "common.key.tab": "Tab",
+  "common.key.delete": "Delete",
+  "common.key.home": "Home",
+  "common.key.end": "End",
+  "common.key.pageUp": "Page Up",
+  "common.key.pageDown": "Page Down",
+  "common.key.insert": "Insert",
+  "common.unknown": "ukendt",
+  "error.page.circular": "[Cirkulær]",
+  "error.globalSDK.noServerAvailable": "Ingen server tilgængelig",
+  "error.globalSDK.serverNotAvailable": "Server ikke tilgængelig",
+  "error.childStore.persistedCacheCreateFailed": "Kunne ikke oprette vedvarende cache",
+  "error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke oprette vedvarende projektmetadata",
+  "error.childStore.persistedProjectIconCreateFailed": "Kunne ikke oprette vedvarende projektikon",
+  "error.childStore.storeCreateFailed": "Kunne ikke oprette lager",
+  "terminal.connectionLost.abnormalClose": "WebSocket lukkede unormalt: {{code}}",
 }

+ 113 - 3
packages/app/src/i18n/de.ts

@@ -108,6 +108,7 @@ export const dict = {
   "dialog.model.empty": "Keine Modellergebnisse",
   "dialog.model.manage": "Modelle verwalten",
   "dialog.model.manage.description": "Anpassen, welche Modelle in der Modellauswahl erscheinen.",
+  "dialog.model.manage.provider.toggle": "Alle {{provider}}-Modelle umschalten",
   "dialog.model.unpaid.freeModels.title": "Kostenlose Modelle von OpenCode",
   "dialog.model.unpaid.addMore.title": "Weitere Modelle von beliebten Anbietern hinzufügen",
   "dialog.provider.viewAll": "Mehr Anbieter anzeigen",
@@ -208,6 +209,7 @@ export const dict = {
   "common.cancel": "Abbrechen",
   "common.connect": "Verbinden",
   "common.disconnect": "Trennen",
+  "common.continue": "Absenden",
   "common.submit": "Absenden",
   "common.save": "Speichern",
   "common.saving": "Speichert...",
@@ -248,7 +250,7 @@ export const dict = {
   "prompt.example.25": "Wie funktionieren Umgebungsvariablen hier?",
   "prompt.popover.emptyResults": "Keine passenden Ergebnisse",
   "prompt.popover.emptyCommands": "Keine passenden Befehle",
-  "prompt.dropzone.label": "Bilder oder PDFs hier ablegen",
+  "prompt.dropzone.label": "Bilder, PDFs oder Textdateien hier ablegen",
   "prompt.dropzone.file.label": "Ablegen zum @Erwähnen der Datei",
   "prompt.slash.badge.custom": "benutzerdefiniert",
   "prompt.slash.badge.skill": "Skill",
@@ -261,8 +263,8 @@ export const dict = {
   "prompt.attachment.remove": "Anhang entfernen",
   "prompt.action.send": "Senden",
   "prompt.action.stop": "Stopp",
-  "prompt.toast.pasteUnsupported.title": "Nicht unterstütztes Einfügen",
-  "prompt.toast.pasteUnsupported.description": "Hier können nur Bilder oder PDFs eingefügt werden.",
+  "prompt.toast.pasteUnsupported.title": "Nicht unterstützter Anhang",
+  "prompt.toast.pasteUnsupported.description": "Hier können nur Bilder, PDFs oder Textdateien angehängt werden.",
   "prompt.toast.modelAgentRequired.title": "Wählen Sie einen Agenten und ein Modell",
   "prompt.toast.modelAgentRequired.description":
     "Wählen Sie einen Agenten und ein Modell, bevor Sie eine Eingabe senden.",
@@ -294,6 +296,11 @@ export const dict = {
   "dialog.server.add.error": "Verbindung zum Server fehlgeschlagen",
   "dialog.server.add.checking": "Prüfen...",
   "dialog.server.add.button": "Server hinzufügen",
+  "dialog.server.add.name": "Servername (optional)",
+  "dialog.server.add.namePlaceholder": "Localhost",
+  "dialog.server.add.username": "Benutzername (optional)",
+  "dialog.server.add.password": "Passwort (optional)",
+  "dialog.server.edit.title": "Server bearbeiten",
   "dialog.server.default.title": "Standardserver",
   "dialog.server.default.description":
     "Beim App-Start mit diesem Server verbinden, anstatt einen lokalen Server zu starten. Erfordert Neustart.",
@@ -366,6 +373,7 @@ export const dict = {
   "language.br": "Português (Brasil)",
   "language.bs": "Bosanski",
   "language.th": "ไทย",
+  "language.tr": "Türkçe",
   "toast.language.title": "Sprache",
   "toast.language.description": "Zu {{language}} gewechselt",
   "toast.theme.title": "Thema gewechselt",
@@ -454,9 +462,13 @@ export const dict = {
   "session.review.change.other": "Änderungen",
   "session.review.loadingChanges": "Lade Änderungen...",
   "session.review.empty": "Noch keine Änderungen in dieser Sitzung",
+  "session.review.noVcs": "Kein Git-Versionskontrollsystem erkannt, Änderungen werden nicht angezeigt",
+  "session.review.noSnapshot":
+    "Snapshot-Tracking ist in der Konfiguration deaktiviert, daher sind Sitzungsänderungen nicht verfügbar",
   "session.review.noChanges": "Keine Änderungen",
   "session.files.selectToOpen": "Datei zum Öffnen auswählen",
   "session.files.all": "Alle Dateien",
+  "session.files.empty": "Keine Dateien",
   "session.files.binaryContent": "Binärdatei (Inhalt kann nicht angezeigt werden)",
   "session.messages.renderEarlier": "Frühere Nachrichten rendern",
   "session.messages.loadingEarlier": "Lade frühere Nachrichten...",
@@ -467,6 +479,17 @@ export const dict = {
   "session.todo.title": "Aufgaben",
   "session.todo.collapse": "Einklappen",
   "session.todo.expand": "Ausklappen",
+  "session.followupDock.summary.one": "{{count}} Nachricht in der Warteschlange",
+  "session.followupDock.summary.other": "{{count}} Nachrichten in der Warteschlange",
+  "session.followupDock.sendNow": "Jetzt senden",
+  "session.followupDock.edit": "Bearbeiten",
+  "session.followupDock.collapse": "Warteschlange einklappen",
+  "session.followupDock.expand": "Warteschlange ausklappen",
+  "session.revertDock.summary.one": "{{count}} zurückgesetzte Nachricht",
+  "session.revertDock.summary.other": "{{count}} zurückgesetzte Nachrichten",
+  "session.revertDock.collapse": "Zurückgesetzte Nachrichten einklappen",
+  "session.revertDock.expand": "Zurückgesetzte Nachrichten ausklappen",
+  "session.revertDock.restore": "Nachricht wiederherstellen",
   "session.new.title": "Baue, was du willst",
   "session.new.worktree.main": "Haupt-Branch",
   "session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
@@ -553,10 +576,21 @@ export const dict = {
   "settings.general.row.language.description": "Die Anzeigesprache für OpenCode ändern",
   "settings.general.row.appearance.title": "Erscheinungsbild",
   "settings.general.row.appearance.description": "Anpassen, wie OpenCode auf Ihrem Gerät aussieht",
+  "settings.general.row.colorScheme.title": "Farbschema",
+  "settings.general.row.colorScheme.description":
+    "Wählen Sie, ob OpenCode dem System-, hellen oder dunklen Thema folgt",
   "settings.general.row.theme.title": "Thema",
   "settings.general.row.theme.description": "Das Thema von OpenCode anpassen.",
   "settings.general.row.font.title": "Schriftart",
   "settings.general.row.font.description": "Die in Codeblöcken verwendete Monospace-Schriftart anpassen",
+  "settings.general.row.followup.title": "Verhalten bei Folgefragen",
+  "settings.general.row.followup.description":
+    "Wählen Sie, ob Folgefragen sofort steuern oder in einer Warteschlange warten",
+  "settings.general.row.followup.option.queue": "Warteschlange",
+  "settings.general.row.followup.option.steer": "Steuern",
+  "settings.general.row.reasoningSummaries.title": "Reasoning-Zusammenfassungen anzeigen",
+  "settings.general.row.reasoningSummaries.description":
+    "Zusammenfassungen des Modell-Reasonings in der Timeline anzeigen",
   "settings.general.row.shellToolPartsExpanded.title": "Shell-Tool-Abschnitte ausklappen",
   "settings.general.row.shellToolPartsExpanded.description":
     "Shell-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
@@ -766,4 +800,80 @@ export const dict = {
   "common.time.daysAgo.short": "vor {{count}} Tg",
   "settings.providers.connected.environmentDescription": "Verbunden aus Ihren Umgebungsvariablen",
   "settings.providers.custom.description": "Fügen Sie einen OpenAI-kompatiblen Anbieter per Basis-URL hinzu.",
+
+  "app.server.unreachable": "Konnte {{server}} nicht erreichen",
+  "app.server.retrying": "Automatische erneute Verbindung...",
+  "app.server.otherServers": "Andere Server",
+  "dialog.server.add.usernamePlaceholder": "Benutzername",
+  "dialog.server.add.passwordPlaceholder": "Passwort",
+  "server.row.noUsername": "Kein Benutzername",
+  "session.review.noVcs.createGit.title": "Git-Repository erstellen",
+  "session.review.noVcs.createGit.description":
+    "Änderungen in diesem Projekt verfolgen, überprüfen und rückgängig machen",
+  "session.review.noVcs.createGit.actionLoading": "Git-Repository wird erstellt...",
+  "session.review.noVcs.createGit.action": "Git-Repository erstellen",
+  "session.todo.progress": "{{done}} von {{total}} Aufgaben erledigt",
+  "session.question.progress": "{{current}} von {{total}} Fragen",
+  "session.header.open.finder": "Finder",
+  "session.header.open.fileExplorer": "Datei-Explorer",
+  "session.header.open.fileManager": "Dateimanager",
+  "session.header.open.app.vscode": "VS Code",
+  "session.header.open.app.cursor": "Cursor",
+  "session.header.open.app.zed": "Zed",
+  "session.header.open.app.textmate": "TextMate",
+  "session.header.open.app.antigravity": "Antigravity",
+  "session.header.open.app.terminal": "Terminal",
+  "session.header.open.app.iterm2": "iTerm2",
+  "session.header.open.app.ghostty": "Ghostty",
+  "session.header.open.app.warp": "Warp",
+  "session.header.open.app.xcode": "Xcode",
+  "session.header.open.app.androidStudio": "Android Studio",
+  "session.header.open.app.powershell": "PowerShell",
+  "session.header.open.app.sublimeText": "Sublime Text",
+  "debugBar.ariaLabel": "Entwicklungs-Leistungsdiagnose",
+  "debugBar.na": "n.v.",
+  "debugBar.nav.label": "NAV",
+  "debugBar.nav.tip":
+    "Letzter abgeschlossener Routenübergang, der eine Sitzungsseite berührt, gemessen vom Start des Routers bis zum ersten Rendern nach dem Einschwingen.",
+  "debugBar.fps.label": "FPS",
+  "debugBar.fps.tip": "Gleitende Bilder pro Sekunde in den letzten 5 Sekunden.",
+  "debugBar.frame.label": "FRAME",
+  "debugBar.frame.tip": "Schlechteste Frame-Zeit in den letzten 5 Sekunden.",
+  "debugBar.jank.label": "JANK",
+  "debugBar.jank.tip": "Frames über 32ms in den letzten 5 Sekunden.",
+  "debugBar.long.label": "LONG",
+  "debugBar.long.tip": "Blockierte Zeit und Anzahl langer Aufgaben in den letzten 5 Sekunden. Max Aufgabe: {{max}}.",
+  "debugBar.delay.label": "DELAY",
+  "debugBar.delay.tip": "Schlechteste beobachtete Eingabeverzögerung in den letzten 5 Sekunden.",
+  "debugBar.inp.label": "INP",
+  "debugBar.inp.tip":
+    "Ungefähre Interaktionsdauer in den letzten 5 Sekunden. Dies ist INP-ähnlich, nicht das offizielle Web Vitals INP.",
+  "debugBar.cls.label": "CLS",
+  "debugBar.cls.tip": "Kumulative Layoutverschiebung für die aktuelle App-Lebensdauer.",
+  "debugBar.mem.label": "MEM",
+  "debugBar.mem.tipUnavailable": "Verwendeter JS-Heap vs Heap-Limit. Nur Chromium.",
+  "debugBar.mem.tip": "Verwendeter JS-Heap vs Heap-Limit. {{used}} von {{limit}}.",
+  "common.key.ctrl": "Strg",
+  "common.key.alt": "Alt",
+  "common.key.shift": "Umschalt",
+  "common.key.meta": "Meta",
+  "common.key.space": "Leertaste",
+  "common.key.backspace": "Rücktaste",
+  "common.key.enter": "Eingabe",
+  "common.key.tab": "Tab",
+  "common.key.delete": "Entf",
+  "common.key.home": "Pos1",
+  "common.key.end": "Ende",
+  "common.key.pageUp": "Bild auf",
+  "common.key.pageDown": "Bild ab",
+  "common.key.insert": "Einfg",
+  "common.unknown": "unbekannt",
+  "error.page.circular": "[Zirkulär]",
+  "error.globalSDK.noServerAvailable": "Kein Server verfügbar",
+  "error.globalSDK.serverNotAvailable": "Server nicht verfügbar",
+  "error.childStore.persistedCacheCreateFailed": "Dauerhafter Cache konnte nicht erstellt werden",
+  "error.childStore.persistedProjectMetadataCreateFailed": "Dauerhafte Projektmetadaten konnten nicht erstellt werden",
+  "error.childStore.persistedProjectIconCreateFailed": "Dauerhaftes Projekticon konnte nicht erstellt werden",
+  "error.childStore.storeCreateFailed": "Speicher konnte nicht erstellt werden",
+  "terminal.connectionLost.abnormalClose": "WebSocket abnormal geschlossen: {{code}}",
 } satisfies Partial<Record<Keys, string>>

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff