Procházet zdrojové kódy

Merge branch 'dev' into pr-18335

Bring the PR up to date with the latest server, workspace, and UI changes from dev while keeping the PR's GitLab dependency and PTY merge resolutions.
Dax Raad před 3 týdny
rodič
revize
ada7b11fd6
100 změnil soubory, kde provedl 3218 přidání a 2292 odebrání
  1. 5 1
      .github/VOUCHED.td
  2. 24 0
      .github/workflows/close-issues.yml
  3. 1 1
      .github/workflows/deploy.yml
  4. 1 1
      .github/workflows/nix-hashes.yml
  5. 0 33
      .github/workflows/stale-issues.yml
  6. 1 1
      .github/workflows/vouch-manage-by-issue.yml
  7. 23 0
      .opencode/command/changelog.md
  8. 1 1
      README.zh.md
  9. 1 1
      README.zht.md
  10. 172 163
      bun.lock
  11. 3 3
      flake.lock
  12. 0 1
      github/index.ts
  13. 4 4
      nix/hashes.json
  14. 4 4
      package.json
  15. 6 3
      packages/app/e2e/actions.ts
  16. 10 1
      packages/app/e2e/app/palette.spec.ts
  17. 5 2
      packages/app/e2e/prompt/prompt-history.spec.ts
  18. 2 1
      packages/app/e2e/selectors.ts
  19. 14 7
      packages/app/e2e/session/session-composer-dock.spec.ts
  20. 119 73
      packages/app/e2e/session/session-model-persistence.spec.ts
  21. 1 1
      packages/app/e2e/settings/settings-keybinds.spec.ts
  22. 242 53
      packages/app/e2e/settings/settings.spec.ts
  23. 80 1
      packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts
  24. 37 1
      packages/app/e2e/terminal/terminal-tabs.spec.ts
  25. 3 1
      packages/app/package.json
  26. 15 13
      packages/app/src/app.tsx
  27. 2 6
      packages/app/src/components/debug-bar.tsx
  28. 53 17
      packages/app/src/components/dialog-connect-provider.tsx
  29. 0 1
      packages/app/src/components/dialog-custom-provider-form.ts
  30. 0 2
      packages/app/src/components/dialog-custom-provider.test.ts
  31. 42 35
      packages/app/src/components/dialog-custom-provider.tsx
  32. 29 30
      packages/app/src/components/dialog-edit-project.tsx
  33. 61 13
      packages/app/src/components/dialog-select-mcp.tsx
  34. 14 6
      packages/app/src/components/dialog-select-model-unpaid.tsx
  35. 20 15
      packages/app/src/components/dialog-select-model.tsx
  36. 85 88
      packages/app/src/components/dialog-select-server.tsx
  37. 10 10
      packages/app/src/components/prompt-input.tsx
  38. 21 17
      packages/app/src/components/prompt-input/attachments.ts
  39. 26 0
      packages/app/src/components/prompt-input/build-request-parts.test.ts
  40. 5 2
      packages/app/src/components/prompt-input/history.test.ts
  41. 1 1
      packages/app/src/components/prompt-input/history.ts
  42. 3 1
      packages/app/src/components/session-context-usage.tsx
  43. 5 3
      packages/app/src/components/session/session-context-tab.tsx
  44. 8 1
      packages/app/src/components/session/session-sortable-terminal-tab.tsx
  45. 80 48
      packages/app/src/components/settings-general.tsx
  46. 443 0
      packages/app/src/components/status-popover-body.tsx
  47. 21 378
      packages/app/src/components/status-popover.tsx
  48. 4 1
      packages/app/src/components/terminal.tsx
  49. 1 1
      packages/app/src/components/titlebar.tsx
  50. 26 0
      packages/app/src/context/command-keybind.test.ts
  51. 35 32
      packages/app/src/context/global-sync.tsx
  52. 282 130
      packages/app/src/context/global-sync/bootstrap.ts
  53. 3 0
      packages/app/src/context/global-sync/child-store.ts
  54. 3 0
      packages/app/src/context/global-sync/event-reducer.ts
  55. 3 0
      packages/app/src/context/global-sync/types.ts
  56. 35 0
      packages/app/src/context/global-sync/utils.test.ts
  57. 15 1
      packages/app/src/context/global-sync/utils.ts
  58. 66 78
      packages/app/src/context/language.tsx
  59. 20 2
      packages/app/src/context/local.tsx
  60. 3 3
      packages/app/src/context/notification.tsx
  61. 55 27
      packages/app/src/context/settings.tsx
  62. 8 4
      packages/app/src/context/sync.tsx
  63. 12 39
      packages/app/src/context/terminal-title.ts
  64. 1 1
      packages/app/src/hooks/use-providers.ts
  65. 4 17
      packages/app/src/i18n/ar.ts
  66. 4 17
      packages/app/src/i18n/br.ts
  67. 4 17
      packages/app/src/i18n/bs.ts
  68. 4 17
      packages/app/src/i18n/da.ts
  69. 4 17
      packages/app/src/i18n/de.ts
  70. 7 18
      packages/app/src/i18n/en.ts
  71. 4 17
      packages/app/src/i18n/es.ts
  72. 4 17
      packages/app/src/i18n/fr.ts
  73. 4 17
      packages/app/src/i18n/ja.ts
  74. 4 17
      packages/app/src/i18n/ko.ts
  75. 4 17
      packages/app/src/i18n/no.ts
  76. 4 17
      packages/app/src/i18n/pl.ts
  77. 4 17
      packages/app/src/i18n/ru.ts
  78. 4 17
      packages/app/src/i18n/th.ts
  79. 4 18
      packages/app/src/i18n/tr.ts
  80. 4 18
      packages/app/src/i18n/zh.ts
  81. 4 17
      packages/app/src/i18n/zht.ts
  82. 1 0
      packages/app/src/index.ts
  83. 36 41
      packages/app/src/pages/directory-layout.tsx
  84. 8 0
      packages/app/src/pages/home.tsx
  85. 129 47
      packages/app/src/pages/layout.tsx
  86. 129 113
      packages/app/src/pages/layout/sidebar-items.tsx
  87. 15 23
      packages/app/src/pages/layout/sidebar-project.tsx
  88. 149 151
      packages/app/src/pages/session.tsx
  89. 44 40
      packages/app/src/pages/session/composer/session-question-dock.tsx
  90. 21 0
      packages/app/src/pages/session/helpers.test.ts
  91. 7 0
      packages/app/src/pages/session/helpers.ts
  92. 95 104
      packages/app/src/pages/session/message-timeline.tsx
  93. 5 6
      packages/app/src/pages/session/session-side-panel.tsx
  94. 23 11
      packages/app/src/pages/session/use-session-commands.tsx
  95. 18 0
      packages/app/src/pages/session/use-session-hash-scroll.ts
  96. 32 3
      packages/app/src/testing/model-selection.ts
  97. 44 0
      packages/app/src/utils/prompt.test.ts
  98. 23 1
      packages/app/src/utils/server-health.ts
  99. 81 96
      packages/app/src/utils/sound.ts
  100. 12 0
      packages/app/vite.js

+ 5 - 1
.github/VOUCHED.td

@@ -10,6 +10,9 @@
 adamdotdevin
 -agusbasari29 AI PR slop
 ariane-emory
+-atharvau AI review spamming literally every PR
+-danieljoshuanazareth
+-danieljoshuanazareth
 edemaine
 -florianleibert
 fwang
@@ -17,8 +20,9 @@ iamdavidhill
 jayair
 kitlangton
 kommander
+-opencode2026
 r44vc0rp
 rekram1-node
 -spider-yamet clawdbot/llm psychosis, spam pinging the team
 thdxr
--OpenCode2026
+-OpenCodeEngineer bot that spams issues

+ 24 - 0
.github/workflows/close-issues.yml

@@ -0,0 +1,24 @@
+name: close-issues
+
+on:
+  schedule:
+    - cron: "0 2 * * *" # Daily at 2:00 AM
+  workflow_dispatch:
+
+jobs:
+  close:
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      issues: write
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: oven-sh/setup-bun@v2
+        with:
+          bun-version: latest
+
+      - name: Close stale issues
+        env:
+          GITHUB_TOKEN: ${{ github.token }}
+        run: bun script/github/close-issues.ts

+ 1 - 1
.github/workflows/deploy.yml

@@ -11,7 +11,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
 
 jobs:
   deploy:
-    runs-on: blacksmith-4vcpu-ubuntu-2404
+    runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v3
 

+ 1 - 1
.github/workflows/nix-hashes.yml

@@ -56,7 +56,7 @@ jobs:
           nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
 
           # Extract hash from build log with portability
-          HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
+          HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
 
           if [ -z "$HASH" ]; then
             echo "::error::Failed to compute hash for ${SYSTEM}"

+ 0 - 33
.github/workflows/stale-issues.yml

@@ -1,33 +0,0 @@
-name: stale-issues
-
-on:
-  schedule:
-    - cron: "30 1 * * *" # Daily at 1:30 AM
-  workflow_dispatch:
-
-env:
-  DAYS_BEFORE_STALE: 90
-  DAYS_BEFORE_CLOSE: 7
-
-jobs:
-  stale:
-    runs-on: ubuntu-latest
-    permissions:
-      issues: write
-    steps:
-      - uses: actions/stale@v10
-        with:
-          days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
-          days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
-          stale-issue-label: "stale"
-          close-issue-message: |
-            [automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity.
-
-            Feel free to reopen if you still need this!
-          stale-issue-message: |
-            [automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days.
-
-            It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity.
-          remove-stale-when-updated: true
-          exempt-issue-labels: "pinned,security,feature-request,on-hold"
-          start-date: "2025-12-27"

+ 1 - 1
.github/workflows/vouch-manage-by-issue.yml

@@ -33,6 +33,6 @@ jobs:
         with:
           issue-id: ${{ github.event.issue.number }}
           comment-id: ${{ github.event.comment.id }}
-          roles: admin,maintain
+          roles: admin,maintain,write
         env:
           GITHUB_TOKEN: ${{ steps.committer.outputs.token }}

+ 23 - 0
.opencode/command/changelog.md

@@ -0,0 +1,23 @@
+---
+model: opencode/kimi-k2.5
+---
+
+create UPCOMING_CHANGELOG.md
+
+it should have sections
+
+```
+## TUI
+
+## Desktop
+
+## Core
+
+## Misc
+```
+
+fetch the latest github release for this repository to determine the last release version.
+
+find each PR that was merged since the last release
+
+for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section.

+ 1 - 1
README.zh.md

@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
 
 ---
 
-**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
+**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)

+ 1 - 1
README.zht.md

@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
 
 ---
 
-**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)
+**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 172 - 163
bun.lock


+ 3 - 3
flake.lock

@@ -2,11 +2,11 @@
   "nodes": {
     "nixpkgs": {
       "locked": {
-        "lastModified": 1772091128,
-        "narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=",
+        "lastModified": 1773909469,
+        "narHash": "sha256-vglVrLfHjFIzIdV9A27Ugul6rh3I1qHbbitGW7dk420=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "3f0336406035444b4a24b942788334af5f906259",
+        "rev": "7149c06513f335be57f26fcbbbe34afda923882b",
         "type": "github"
       },
       "original": {

+ 0 - 1
github/index.ts

@@ -496,7 +496,6 @@ async function subscribeSessionEvents() {
 
   const TOOL: Record<string, [string, string]> = {
     todowrite: ["Todo", "\x1b[33m\x1b[1m"],
-    todoread: ["Todo", "\x1b[33m\x1b[1m"],
     bash: ["Bash", "\x1b[31m\x1b[1m"],
     edit: ["Edit", "\x1b[32m\x1b[1m"],
     glob: ["Glob", "\x1b[34m\x1b[1m"],

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-Gv0pHYCinlj0SQXRQ/a9ozYPxECwdrC99ssTzpeOr1I=",
-    "aarch64-linux": "sha256-WzVt5goOrxoGe26juzRf73PWPqwnB1URu2TYjxye/Aw=",
-    "aarch64-darwin": "sha256-18Nn0TR1wK2gRUF/FFP4vFMY/td49XkfjOwFbD5iJNc=",
-    "x86_64-darwin": "sha256-zk2yaulPzUUiCerCPJaCOCLhklXKMp9mSv7v0N8AMfA="
+    "x86_64-linux": "sha256-YI/VXZYi/5BEKRGWCHVqEBmMgBP5VMVJyL06OJlfQxY=",
+    "aarch64-linux": "sha256-HvGPC4TuLnCNAty8nr+JwnPkV+MtrPnso3VPmgCe06Y=",
+    "aarch64-darwin": "sha256-DKzYPvFsKy8utQZbiWWPWukPEle/SuFQz1FakWzObA8=",
+    "x86_64-darwin": "sha256-311yDcV1P3gaFh75j3uoe3eTuZJn48E7OVgNjLxSpIo="
   }
 }

+ 4 - 4
package.json

@@ -4,7 +4,7 @@
   "description": "AI-powered development tool",
   "private": true,
   "type": "module",
-  "packageManager": "[email protected]0",
+  "packageManager": "[email protected]1",
   "scripts": {
     "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
     "dev:desktop": "bun --cwd packages/desktop tauri dev",
@@ -25,8 +25,8 @@
       "packages/slack"
     ],
     "catalog": {
-      "@effect/platform-node": "4.0.0-beta.35",
-      "@types/bun": "1.3.9",
+      "@effect/platform-node": "4.0.0-beta.37",
+      "@types/bun": "1.3.11",
       "@octokit/rest": "22.0.0",
       "@hono/zod-validator": "0.4.2",
       "ulid": "3.0.1",
@@ -45,7 +45,7 @@
       "dompurify": "3.3.1",
       "drizzle-kit": "1.0.0-beta.19-d95b7a4",
       "drizzle-orm": "1.0.0-beta.19-d95b7a4",
-      "effect": "4.0.0-beta.35",
+      "effect": "4.0.0-beta.37",
       "ai": "5.0.124",
       "hono": "4.10.7",
       "hono-openapi": "1.1.2",

+ 6 - 3
packages/app/e2e/actions.ts

@@ -175,9 +175,9 @@ export async function runTerminal(page: Page, input: { cmd: string; token: strin
   await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
 }
 
-export async function openPalette(page: Page) {
+export async function openPalette(page: Page, key = "K") {
   await defocus(page)
-  await page.keyboard.press(`${modKey}+P`)
+  await page.keyboard.press(`${modKey}+${key}`)
 
   const dialog = page.getByRole("dialog")
   await expect(dialog).toBeVisible()
@@ -465,10 +465,13 @@ export async function waitSession(page: Page, input: { directory: string; sessio
         if (!slug) return false
         const resolved = await resolveSlug(slug).catch(() => undefined)
         if (!resolved || resolved.directory !== target) return false
-        if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
+        const current = sessionIDFromUrl(page.url())
+        if (input.sessionID && current !== input.sessionID) return false
+        if (!input.sessionID && current) return false
 
         const state = await probeSession(page)
         if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
+        if (!input.sessionID && state?.sessionID) return false
         if (state?.dir) {
           const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
           if (dir !== target) return false

+ 10 - 1
packages/app/e2e/app/palette.spec.ts

@@ -1,5 +1,5 @@
 import { test, expect } from "../fixtures"
-import { openPalette } from "../actions"
+import { closeDialog, openPalette } from "../actions"
 
 test("search palette opens and closes", async ({ page, gotoSession }) => {
   await gotoSession()
@@ -9,3 +9,12 @@ test("search palette opens and closes", async ({ page, gotoSession }) => {
   await page.keyboard.press("Escape")
   await expect(dialog).toHaveCount(0)
 })
+
+test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openPalette(page, "P")
+
+  await closeDialog(page, dialog)
+  await expect(dialog).toHaveCount(0)
+})

+ 5 - 2
packages/app/e2e/prompt/prompt-history.spec.ts

@@ -108,7 +108,10 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
     await page.keyboard.type(draft)
     await wait(page, draft)
 
-    await edge(page, "start")
+    // Clear the draft before navigating history (ArrowUp only works when prompt is empty)
+    await prompt.fill("")
+    await wait(page, "")
+
     await page.keyboard.press("ArrowUp")
     await wait(page, second)
 
@@ -119,7 +122,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
     await wait(page, second)
 
     await page.keyboard.press("ArrowDown")
-    await wait(page, draft)
+    await wait(page, "")
   })
 })
 

+ 2 - 1
packages/app/e2e/selectors.ts

@@ -19,7 +19,8 @@ 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"]'
-export const settingsFontSelector = '[data-action="settings-font"]'
+export const settingsCodeFontSelector = '[data-action="settings-code-font"]'
+export const settingsUIFontSelector = '[data-action="settings-ui-font"]'
 export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]'
 export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
 export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'

+ 14 - 7
packages/app/e2e/session/session-composer-dock.spec.ts

@@ -93,7 +93,7 @@ async function todoDock(page: any, sessionID: string) {
 
   const write = async (driver: ComposerDriverState | undefined) => {
     await page.evaluate(
-      (input) => {
+      (input: { event: string; sessionID: string; driver: ComposerDriverState | undefined }) => {
         const win = window as ComposerWindow
         const composer = win.__opencode_e2e?.composer
         if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
@@ -118,7 +118,7 @@ async function todoDock(page: any, sessionID: string) {
   }
 
   const read = () =>
-    page.evaluate((sessionID) => {
+    page.evaluate((sessionID: string) => {
       const win = window as ComposerWindow
       return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
     }, sessionID) as Promise<ComposerProbeState | null>
@@ -186,6 +186,8 @@ async function withMockPermission<T>(
   opts: { child?: any } | undefined,
   fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
 ) {
+  const listUrl = /\/permission(?:\?.*)?$/
+  const replyUrls = [/\/session\/[^/]+\/permissions\/[^/?]+(?:\?.*)?$/, /\/permission\/[^/]+\/reply(?:\?.*)?$/]
   let pending = [
     {
       ...request,
@@ -204,7 +206,8 @@ async function withMockPermission<T>(
 
   const reply = async (route: any) => {
     const url = new URL(route.request().url())
-    const id = url.pathname.split("/").pop()
+    const parts = url.pathname.split("/").filter(Boolean)
+    const id = parts.at(-1) === "reply" ? parts.at(-2) : parts.at(-1)
     pending = pending.filter((item) => item.id !== id)
     await route.fulfill({
       status: 200,
@@ -213,8 +216,10 @@ async function withMockPermission<T>(
     })
   }
 
-  await page.route("**/permission", list)
-  await page.route("**/session/*/permissions/*", reply)
+  await page.route(listUrl, list)
+  for (const item of replyUrls) {
+    await page.route(item, reply)
+  }
 
   const sessionList = opts?.child
     ? async (route: any) => {
@@ -242,8 +247,10 @@ async function withMockPermission<T>(
   try {
     return await fn(state)
   } finally {
-    await page.unroute("**/permission", list)
-    await page.unroute("**/session/*/permissions/*", reply)
+    await page.unroute(listUrl, list)
+    for (const item of replyUrls) {
+      await page.unroute(item, reply)
+    }
     if (sessionList) await page.unroute("**/session?*", sessionList)
   }
 }

+ 119 - 73
packages/app/e2e/session/session-model-persistence.spec.ts

@@ -28,7 +28,17 @@ type Footer = {
 type Probe = {
   dir?: string
   sessionID?: string
-  model?: { providerID: string; modelID: string }
+  agent?: string
+  model?: { providerID: string; modelID: string; name?: string }
+  variant?: string | null
+  pick?: {
+    agent?: string
+    model?: { providerID: string; modelID: string }
+    variant?: string | null
+  }
+  variants?: string[]
+  models?: Array<{ providerID: string; modelID: string; name: string }>
+  agents?: Array<{ name: string }>
 }
 
 const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
@@ -50,6 +60,86 @@ async function probe(page: Page): Promise<Probe | null> {
   })
 }
 
+async function currentModel(page: Page) {
+  await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).not.toBe(null)
+  const value = await probe(page).then(modelKey)
+  if (!value) throw new Error("Failed to resolve current model key")
+  return value
+}
+
+async function waitControl(page: Page, key: "setAgent" | "setModel" | "setVariant") {
+  await expect
+    .poll(
+      () =>
+        page.evaluate((key) => {
+          const win = window as Window & {
+            __opencode_e2e?: {
+              model?: {
+                controls?: Record<string, unknown>
+              }
+            }
+          }
+          return !!win.__opencode_e2e?.model?.controls?.[key]
+        }, key),
+      { timeout: 30_000 },
+    )
+    .toBe(true)
+}
+
+async function pickAgent(page: Page, value: string) {
+  await waitControl(page, "setAgent")
+  await page.evaluate((value) => {
+    const win = window as Window & {
+      __opencode_e2e?: {
+        model?: {
+          controls?: {
+            setAgent?: (value: string | undefined) => void
+          }
+        }
+      }
+    }
+    const fn = win.__opencode_e2e?.model?.controls?.setAgent
+    if (!fn) throw new Error("Model e2e agent control is not enabled")
+    fn(value)
+  }, value)
+}
+
+async function pickModel(page: Page, value: { providerID: string; modelID: string }) {
+  await waitControl(page, "setModel")
+  await page.evaluate((value) => {
+    const win = window as Window & {
+      __opencode_e2e?: {
+        model?: {
+          controls?: {
+            setModel?: (value: { providerID: string; modelID: string } | undefined) => void
+          }
+        }
+      }
+    }
+    const fn = win.__opencode_e2e?.model?.controls?.setModel
+    if (!fn) throw new Error("Model e2e model control is not enabled")
+    fn(value)
+  }, value)
+}
+
+async function pickVariant(page: Page, value: string) {
+  await waitControl(page, "setVariant")
+  await page.evaluate((value) => {
+    const win = window as Window & {
+      __opencode_e2e?: {
+        model?: {
+          controls?: {
+            setVariant?: (value: string | undefined) => void
+          }
+        }
+      }
+    }
+    const fn = win.__opencode_e2e?.model?.controls?.setVariant
+    if (!fn) throw new Error("Model e2e variant control is not enabled")
+    fn(value)
+  }, value)
+}
+
 async function read(page: Page): Promise<Footer> {
   return {
     agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
@@ -82,31 +172,15 @@ async function waitModel(page: Page, value: string) {
 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()
+  await pickAgent(page, value)
 }
 
 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
+  return (await probe(page))?.variants?.length ?? 0
 }
 
 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)
+  return ((await probe(page))?.agents ?? []).map((item) => item.name).filter(Boolean)
 }
 
 async function ensureVariant(page: Page, directory: string): Promise<Footer> {
@@ -132,48 +206,23 @@ async function ensureVariant(page: Page, directory: string): Promise<Footer> {
 
 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 })
-  }
+  const next = (await probe(page))?.variants?.find((item) => item !== current.variant)
+  if (!next) throw new Error("Current model has no alternate variant to select")
 
-  throw new Error("Failed to choose a different variant")
+  await pickVariant(page, next)
+  return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
 }
 
-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 chooseOtherModel(page: Page, skip: string[] = []): Promise<Footer> {
+  const current = await currentModel(page)
+  const next = (await probe(page))?.models?.find((item) => {
+    const key = `${item.providerID}:${item.modelID}`
+    return key !== current && !skip.includes(key)
+  })
+  if (!next) throw new Error("Failed to choose a different model")
+  await pickModel(page, { providerID: next.providerID, modelID: next.modelID })
+  await expect.poll(async () => (await read(page)).model, { timeout: 30_000 }).toBe(next.name)
+  return read(page)
 }
 
 async function goto(page: Page, directory: string, sessionID?: string) {
@@ -249,17 +298,14 @@ async function newWorkspaceSession(page: Page, slug: string) {
   return waitSession(page, { directory: next.directory }).then((item) => item.directory)
 }
 
-test("session model and variant restore per session without leaking into new sessions", async ({
-  page,
-  withProject,
-}) => {
+test("session model 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 firstState = await chooseOtherModel(page)
+    const firstKey = await currentModel(page)
     const first = await submit(page, `session variant ${Date.now()}`)
     trackSession(first)
     await waitUser(directory, first)
@@ -269,10 +315,10 @@ test("session model and variant restore per session without leaking into new ses
     await waitFooter(page, firstState)
 
     await gotoSession()
-    const fresh = await ensureVariant(page, directory)
-    expect(fresh.variant).not.toBe(firstState.variant)
+    const fresh = await read(page)
+    expect(fresh.model).not.toBe(firstState.model)
 
-    const secondState = await chooseOtherModel(page)
+    const secondState = await chooseOtherModel(page, [firstKey])
     const second = await submit(page, `session model ${Date.now()}`)
     trackSession(second)
     await waitUser(directory, second)
@@ -294,8 +340,8 @@ test("session model restore across workspaces", async ({ page, withProject }) =>
   await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
     await gotoSession()
 
-    await ensureVariant(page, root)
-    const firstState = await chooseDifferentVariant(page)
+    const firstState = await chooseOtherModel(page)
+    const firstKey = await currentModel(page)
     const first = await submit(page, `root session ${Date.now()}`)
     trackSession(first, root)
     await waitUser(root, first)
@@ -307,7 +353,8 @@ test("session model restore across workspaces", async ({ page, withProject }) =>
     const oneDir = await newWorkspaceSession(page, one.slug)
     trackDirectory(oneDir)
 
-    const secondState = await chooseOtherModel(page)
+    const secondState = await chooseOtherModel(page, [firstKey])
+    const secondKey = await currentModel(page)
     const second = await submit(page, `workspace one ${Date.now()}`)
     trackSession(second, oneDir)
     await waitUser(oneDir, second)
@@ -316,8 +363,7 @@ test("session model restore across workspaces", async ({ page, withProject }) =>
     const twoDir = await newWorkspaceSession(page, two.slug)
     trackDirectory(twoDir)
 
-    await ensureVariant(page, twoDir)
-    const thirdState = await chooseDifferentVariant(page)
+    const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
     const third = await submit(page, `workspace two ${Date.now()}`)
     trackSession(third, twoDir)
     await waitUser(twoDir, third)

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

@@ -241,7 +241,7 @@ test("changing file open keybind works", async ({ page, gotoSession }) => {
   await expect(keybindButton).toBeVisible()
 
   const initialKeybind = await keybindButton.textContent()
-  expect(initialKeybind).toContain("P")
+  expect(initialKeybind).toContain("K")
 
   await keybindButton.click()
   await expect(keybindButton).toHaveText(/press/i)

+ 242 - 53
packages/app/e2e/settings/settings.spec.ts

@@ -2,7 +2,7 @@ import { test, expect, settingsKey } from "../fixtures"
 import { closeDialog, openSettings } from "../actions"
 import {
   settingsColorSchemeSelector,
-  settingsFontSelector,
+  settingsCodeFontSelector,
   settingsLanguageSelectSelector,
   settingsNotificationsAgentSelector,
   settingsNotificationsErrorsSelector,
@@ -12,6 +12,7 @@ import {
   settingsSoundsErrorsSelector,
   settingsSoundsPermissionsSelector,
   settingsThemeSelector,
+  settingsUIFontSelector,
   settingsUpdatesStartupSelector,
 } from "../selectors"
 
@@ -152,39 +153,199 @@ test("legacy oc-1 theme migrates to oc-2", async ({ page, gotoSession }) => {
     .toBeNull()
 })
 
-test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
+test("typing a code font with spaces persists and updates CSS variable", async ({ page, gotoSession }) => {
   await gotoSession()
 
   const dialog = await openSettings(page)
-  const select = dialog.locator(settingsFontSelector)
-  await expect(select).toBeVisible()
-
-  const initialFontFamily = await page.evaluate(() => {
-    return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
-  })
+  const input = dialog.locator(settingsCodeFontSelector)
+  await expect(input).toBeVisible()
+  await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
+
+  const initialFontFamily = await page.evaluate(() =>
+    getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
+  )
+  const initialUIFamily = await page.evaluate(() =>
+    getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
+  )
   expect(initialFontFamily).toContain("IBM Plex Mono")
 
-  await select.locator('[data-slot="select-select-trigger"]').click()
+  const next = "Test Mono"
 
-  const items = page.locator('[data-slot="select-select-item"]')
-  await items.nth(2).click()
+  await input.click()
+  await input.clear()
+  await input.pressSequentially(next)
+  await expect(input).toHaveValue(next)
 
-  await page.waitForTimeout(100)
+  await expect
+    .poll(async () => {
+      return await page.evaluate((key) => {
+        const raw = localStorage.getItem(key)
+        return raw ? JSON.parse(raw) : null
+      }, settingsKey)
+    })
+    .toMatchObject({
+      appearance: {
+        font: next,
+      },
+    })
 
-  const stored = await page.evaluate((key) => {
-    const raw = localStorage.getItem(key)
-    return raw ? JSON.parse(raw) : null
-  }, settingsKey)
+  const newFontFamily = await page.evaluate(() =>
+    getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
+  )
+  const newUIFamily = await page.evaluate(() =>
+    getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
+  )
+  expect(newFontFamily).toContain(next)
+  expect(newFontFamily).not.toBe(initialFontFamily)
+  expect(newUIFamily).toBe(initialUIFamily)
+})
 
-  expect(stored?.appearance?.font).not.toBe("ibm-plex-mono")
+test("typing a UI font with spaces persists and updates CSS variable", async ({ page, gotoSession }) => {
+  await gotoSession()
 
-  const newFontFamily = await page.evaluate(() => {
-    return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
-  })
+  const dialog = await openSettings(page)
+  const input = dialog.locator(settingsUIFontSelector)
+  await expect(input).toBeVisible()
+  await expect(input).toHaveAttribute("placeholder", "Inter")
+
+  const initialFontFamily = await page.evaluate(() =>
+    getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
+  )
+  const initialCodeFamily = await page.evaluate(() =>
+    getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
+  )
+  expect(initialFontFamily).toContain("Inter")
+
+  const next = "Test Sans"
+
+  await input.click()
+  await input.clear()
+  await input.pressSequentially(next)
+  await expect(input).toHaveValue(next)
+
+  await expect
+    .poll(async () => {
+      return await page.evaluate((key) => {
+        const raw = localStorage.getItem(key)
+        return raw ? JSON.parse(raw) : null
+      }, settingsKey)
+    })
+    .toMatchObject({
+      appearance: {
+        uiFont: next,
+      },
+    })
+
+  const newFontFamily = await page.evaluate(() =>
+    getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
+  )
+  const newCodeFamily = await page.evaluate(() =>
+    getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
+  )
+  expect(newFontFamily).toContain(next)
   expect(newFontFamily).not.toBe(initialFontFamily)
+  expect(newCodeFamily).toBe(initialCodeFamily)
 })
 
-test("color scheme and font rehydrate after reload", async ({ page, gotoSession }) => {
+test("clearing the code font field restores the default placeholder and stack", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const input = dialog.locator(settingsCodeFontSelector)
+  await expect(input).toBeVisible()
+
+  await input.click()
+  await input.clear()
+  await input.pressSequentially("Reset Mono")
+
+  await expect
+    .poll(async () => {
+      return await page.evaluate((key) => {
+        const raw = localStorage.getItem(key)
+        return raw ? JSON.parse(raw) : null
+      }, settingsKey)
+    })
+    .toMatchObject({
+      appearance: {
+        font: "Reset Mono",
+      },
+    })
+
+  await input.clear()
+  await input.press("Space")
+  await expect(input).toHaveValue("")
+  await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
+
+  await expect
+    .poll(async () => {
+      return await page.evaluate((key) => {
+        const raw = localStorage.getItem(key)
+        return raw ? JSON.parse(raw) : null
+      }, settingsKey)
+    })
+    .toMatchObject({
+      appearance: {
+        font: "",
+      },
+    })
+
+  const fontFamily = await page.evaluate(() =>
+    getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
+  )
+  expect(fontFamily).toContain("IBM Plex Mono")
+  expect(fontFamily).not.toContain("Reset Mono")
+})
+
+test("clearing the UI font field restores the default placeholder and stack", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const input = dialog.locator(settingsUIFontSelector)
+  await expect(input).toBeVisible()
+
+  await input.click()
+  await input.clear()
+  await input.pressSequentially("Reset Sans")
+
+  await expect
+    .poll(async () => {
+      return await page.evaluate((key) => {
+        const raw = localStorage.getItem(key)
+        return raw ? JSON.parse(raw) : null
+      }, settingsKey)
+    })
+    .toMatchObject({
+      appearance: {
+        uiFont: "Reset Sans",
+      },
+    })
+
+  await input.clear()
+  await input.press("Space")
+  await expect(input).toHaveValue("")
+  await expect(input).toHaveAttribute("placeholder", "Inter")
+
+  await expect
+    .poll(async () => {
+      return await page.evaluate((key) => {
+        const raw = localStorage.getItem(key)
+        return raw ? JSON.parse(raw) : null
+      }, settingsKey)
+    })
+    .toMatchObject({
+      appearance: {
+        uiFont: "",
+      },
+    })
+
+  const fontFamily = await page.evaluate(() =>
+    getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
+  )
+  expect(fontFamily).toContain("Inter")
+  expect(fontFamily).not.toContain("Reset Sans")
+})
+
+test("color scheme, code font, and UI font rehydrate after reload", async ({ page, gotoSession }) => {
   await gotoSession()
 
   const dialog = await openSettings(page)
@@ -195,31 +356,35 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
   await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
   await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
 
-  const fontSelect = dialog.locator(settingsFontSelector)
-  await expect(fontSelect).toBeVisible()
+  const code = dialog.locator(settingsCodeFontSelector)
+  const ui = dialog.locator(settingsUIFontSelector)
+  await expect(code).toBeVisible()
+  await expect(ui).toBeVisible()
 
-  const initialFontFamily = await page.evaluate(() => {
-    return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
-  })
+  const initialMono = await page.evaluate(() =>
+    getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
+  )
+  const initialSans = await page.evaluate(() =>
+    getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
+  )
 
   const initialSettings = await page.evaluate((key) => {
     const raw = localStorage.getItem(key)
     return raw ? JSON.parse(raw) : null
   }, settingsKey)
 
-  const currentFont =
-    (await fontSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
-  await fontSelect.locator('[data-slot="select-select-trigger"]').click()
+  const mono = initialSettings?.appearance?.font === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
+  const sans = initialSettings?.appearance?.uiFont === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
 
-  const fontItems = page.locator('[data-slot="select-select-item"]')
-  expect(await fontItems.count()).toBeGreaterThan(1)
+  await code.click()
+  await code.clear()
+  await code.pressSequentially(mono)
+  await expect(code).toHaveValue(mono)
 
-  if (currentFont) {
-    await fontItems.filter({ hasNotText: currentFont }).first().click()
-  }
-  if (!currentFont) {
-    await fontItems.nth(1).click()
-  }
+  await ui.click()
+  await ui.clear()
+  await ui.pressSequentially(sans)
+  await expect(ui).toHaveValue(sans)
 
   await expect
     .poll(async () => {
@@ -230,7 +395,8 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
     })
     .toMatchObject({
       appearance: {
-        font: expect.any(String),
+        font: mono,
+        uiFont: sans,
       },
     })
 
@@ -239,11 +405,18 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
     return raw ? JSON.parse(raw) : null
   }, settingsKey)
 
-  const updatedFontFamily = await page.evaluate(() => {
-    return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
-  })
-  expect(updatedFontFamily).not.toBe(initialFontFamily)
-  expect(updatedSettings?.appearance?.font).not.toBe(initialSettings?.appearance?.font)
+  const updatedMono = await page.evaluate(() =>
+    getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
+  )
+  const updatedSans = await page.evaluate(() =>
+    getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
+  )
+  expect(updatedMono).toContain(mono)
+  expect(updatedMono).not.toBe(initialMono)
+  expect(updatedSans).toContain(sans)
+  expect(updatedSans).not.toBe(initialSans)
+  expect(updatedSettings?.appearance?.font).toBe(mono)
+  expect(updatedSettings?.appearance?.uiFont).toBe(sans)
 
   await closeDialog(page, dialog)
   await page.reload()
@@ -259,7 +432,8 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
     })
     .toMatchObject({
       appearance: {
-        font: updatedSettings?.appearance?.font,
+        font: mono,
+        uiFont: sans,
       },
     })
 
@@ -270,17 +444,32 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
 
   await expect
     .poll(async () => {
-      return await page.evaluate(() => {
-        return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
-      })
+      return await page.evaluate(() =>
+        getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
+      )
     })
-    .not.toBe(initialFontFamily)
+    .toContain(mono)
 
-  const rehydratedFontFamily = await page.evaluate(() => {
-    return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
-  })
-  expect(rehydratedFontFamily).not.toBe(initialFontFamily)
-  expect(rehydratedSettings?.appearance?.font).toBe(updatedSettings?.appearance?.font)
+  await expect
+    .poll(async () => {
+      return await page.evaluate(() =>
+        getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
+      )
+    })
+    .toContain(sans)
+
+  const rehydratedMono = await page.evaluate(() =>
+    getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
+  )
+  const rehydratedSans = await page.evaluate(() =>
+    getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
+  )
+  expect(rehydratedMono).toContain(mono)
+  expect(rehydratedMono).not.toBe(initialMono)
+  expect(rehydratedSans).toContain(sans)
+  expect(rehydratedSans).not.toBe(initialSans)
+  expect(rehydratedSettings?.appearance?.font).toBe(mono)
+  expect(rehydratedSettings?.appearance?.uiFont).toBe(sans)
 })
 
 test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {

+ 80 - 1
packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts

@@ -1,6 +1,16 @@
 import { test, expect } from "../fixtures"
-import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
+import {
+  defocus,
+  cleanupSession,
+  cleanupTestProject,
+  closeSidebar,
+  createTestProject,
+  hoverSessionItem,
+  openSidebar,
+  waitSession,
+} from "../actions"
 import { projectSwitchSelector } from "../selectors"
+import { dirSlug } from "../utils"
 
 test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
   const stamp = Date.now()
@@ -37,3 +47,72 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
     await cleanupSession({ sdk, sessionID: two.id })
   }
 })
+
+test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  const other = await createTestProject()
+  const slug = dirSlug(other)
+
+  try {
+    await withProject(
+      async () => {
+        await openSidebar(page)
+
+        const project = page.locator(projectSwitchSelector(slug)).first()
+        const card = page.locator('[data-component="hover-card-content"]')
+
+        await expect(project).toBeVisible()
+        await project.hover()
+        await expect(card.getByText(/recent sessions/i)).toBeVisible()
+
+        await page.mouse.down()
+        await expect(card).toHaveCount(0)
+        await page.mouse.up()
+
+        await waitSession(page, { directory: other })
+        await expect(card).toHaveCount(0)
+      },
+      { extra: [other] },
+    )
+  } finally {
+    await cleanupTestProject(other)
+  }
+})
+
+test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  const other = await createTestProject()
+  const slug = dirSlug(other)
+
+  try {
+    await withProject(
+      async () => {
+        await openSidebar(page)
+        await defocus(page)
+
+        const project = page.locator(projectSwitchSelector(slug)).first()
+
+        await expect(project).toBeVisible()
+
+        let hit = false
+        for (let i = 0; i < 20; i++) {
+          hit = await project.evaluate((el) => {
+            return el.matches(":focus") || !!el.parentElement?.matches(":focus")
+          })
+          if (hit) break
+          await page.keyboard.press("Tab")
+        }
+
+        expect(hit).toBe(true)
+
+        await page.keyboard.press("Enter")
+        await waitSession(page, { directory: other })
+      },
+      { extra: [other] },
+    )
+  } finally {
+    await cleanupTestProject(other)
+  }
+})

+ 37 - 1
packages/app/e2e/terminal/terminal-tabs.spec.ts

@@ -1,7 +1,7 @@
 import type { Page } from "@playwright/test"
 import { runTerminal, waitTerminalReady } from "../actions"
 import { test, expect } from "../fixtures"
-import { terminalSelector } from "../selectors"
+import { dropdownMenuContentSelector, terminalSelector } from "../selectors"
 import { terminalToggleKey, workspacePersistKey } from "../utils"
 
 type State = {
@@ -130,3 +130,39 @@ test("closing the active terminal tab falls back to the previous tab", async ({
       .toEqual({ count: 1, first: true })
   })
 })
+
+test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
+  await withProject(async ({ directory, gotoSession }) => {
+    const key = workspacePersistKey(directory, "terminal")
+    const rename = `E2E term ${Date.now()}`
+    const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
+
+    await gotoSession()
+    await open(page)
+
+    await expect(tab).toContainText(/Terminal 1/)
+    await tab.click({ button: "right" })
+
+    const menu = page.locator(dropdownMenuContentSelector).first()
+    await expect(menu).toBeVisible()
+    await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
+    await expect(menu).toHaveCount(0)
+
+    const input = page.locator('#terminal-panel input[type="text"]').first()
+    await expect(input).toBeVisible()
+    await input.fill(rename)
+    await input.press("Enter")
+
+    await expect(input).toHaveCount(0)
+    await expect(tab).toContainText(rename)
+    await expect
+      .poll(
+        async () => {
+          const state = await store(page, key)
+          return state?.all[0]?.title
+        },
+        { timeout: 5_000 },
+      )
+      .toBe(rename)
+  })
+})

+ 3 - 1
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/app",
-  "version": "1.2.27",
+  "version": "1.3.3",
   "description": "",
   "type": "module",
   "exports": {
@@ -51,9 +51,11 @@
     "@solid-primitives/resize-observer": "2.1.3",
     "@solid-primitives/scroll": "2.1.3",
     "@solid-primitives/storage": "catalog:",
+    "@solid-primitives/timer": "1.4.4",
     "@solid-primitives/websocket": "1.3.1",
     "@solidjs/meta": "catalog:",
     "@solidjs/router": "catalog:",
+    "@tanstack/solid-query": "5.91.4",
     "@thisbeyond/solid-dnd": "0.7.5",
     "diff": "catalog:",
     "effect": "catalog:",

+ 15 - 13
packages/app/src/app.tsx

@@ -6,9 +6,10 @@ 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 { ThemeProvider } from "@opencode-ai/ui/theme/context"
 import { MetaProvider } from "@solidjs/meta"
 import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
+import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
 import { type Duration, Effect } from "effect"
 import {
   type Component,
@@ -31,12 +32,11 @@ import { FileProvider } from "@/context/file"
 import { GlobalSDKProvider } from "@/context/global-sdk"
 import { GlobalSyncProvider } from "@/context/global-sync"
 import { HighlightsProvider } from "@/context/highlights"
-import { LanguageProvider, useLanguage } from "@/context/language"
+import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
 import { LayoutProvider } from "@/context/layout"
 import { ModelsProvider } from "@/context/models"
 import { NotificationProvider } from "@/context/notification"
 import { PermissionProvider } from "@/context/permission"
-import { usePlatform } from "@/context/platform"
 import { PromptProvider } from "@/context/prompt"
 import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
 import { SettingsProvider } from "@/context/settings"
@@ -76,9 +76,9 @@ declare global {
   }
 }
 
-function MarkedProviderWithNativeParser(props: ParentProps) {
-  const platform = usePlatform()
-  return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
+function QueryProvider(props: ParentProps) {
+  const client = new QueryClient()
+  return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
 }
 
 function AppShellProviders(props: ParentProps) {
@@ -124,7 +124,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
   )
 }
 
-export function AppBaseProviders(props: ParentProps) {
+export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
   return (
     <MetaProvider>
       <Font />
@@ -133,14 +133,16 @@ export function AppBaseProviders(props: ParentProps) {
           void window.api?.setTitlebar?.({ mode })
         }}
       >
-        <LanguageProvider>
+        <LanguageProvider locale={props.locale}>
           <UiI18nBridge>
             <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
-              <DialogProvider>
-                <MarkedProviderWithNativeParser>
-                  <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
-                </MarkedProviderWithNativeParser>
-              </DialogProvider>
+              <QueryProvider>
+                <DialogProvider>
+                  <MarkedProvider>
+                    <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
+                  </MarkedProvider>
+                </DialogProvider>
+              </QueryProvider>
             </ErrorBoundary>
           </UiI18nBridge>
         </LanguageProvider>

+ 2 - 6
packages/app/src/components/debug-bar.tsx

@@ -55,7 +55,7 @@ function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string;
     <Tooltip value={props.tip} placement="top">
       <div
         classList={{
-          "flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] bg-white/5 px-0.5 py-1 text-center": true,
+          "flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true,
           "col-span-2": !!props.wide,
         }}
       >
@@ -363,11 +363,7 @@ export function DebugBar() {
   return (
     <aside
       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)",
-        "border-color": "color-mix(in srgb, white 14%, transparent)",
-      }}
+      class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
     >
       <div class="grid grid-cols-5 gap-px font-mono">
         <Cell

+ 53 - 17
packages/app/src/components/dialog-connect-provider.tsx

@@ -1,4 +1,4 @@
-import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
+import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
 import { Button } from "@opencode-ai/ui/button"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
@@ -9,20 +9,26 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Spinner } from "@opencode-ai/ui/spinner"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast } from "@opencode-ai/ui/toast"
-import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
+import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { Link } from "@/components/link"
-import { useLanguage } from "@/context/language"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { useGlobalSync } from "@/context/global-sync"
-import { DialogSelectModel } from "./dialog-select-model"
-import { DialogSelectProvider } from "./dialog-select-provider"
+import { useLanguage } from "@/context/language"
+import { useProviders } from "@/hooks/use-providers"
 
 export function DialogConnectProvider(props: { provider: string }) {
   const dialog = useDialog()
   const globalSync = useGlobalSync()
   const globalSDK = useGlobalSDK()
   const language = useLanguage()
+  const providers = useProviders()
+
+  const all = () => {
+    void import("./dialog-select-provider").then((x) => {
+      dialog.show(() => <x.DialogSelectProvider />)
+    })
+  }
 
   const alive = { value: true }
   const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -34,16 +40,30 @@ export function DialogConnectProvider(props: { provider: string }) {
     timer.current = undefined
   })
 
-  const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
-  const methods = createMemo(
+  const provider = createMemo(
     () =>
-      globalSync.data.provider_auth[props.provider] ?? [
-        {
-          type: "api",
-          label: language.t("provider.connect.method.apiKey"),
-        },
-      ],
+      providers.all().find((x) => x.id === props.provider) ??
+      globalSync.data.provider.all.find((x) => x.id === props.provider)!,
+  )
+  const fallback = createMemo<ProviderAuthMethod[]>(() => [
+    {
+      type: "api" as const,
+      label: language.t("provider.connect.method.apiKey"),
+    },
+  ])
+  const [auth] = createResource(
+    () => props.provider,
+    async () => {
+      const cached = globalSync.data.provider_auth[props.provider]
+      if (cached) return cached
+      const res = await globalSDK.client.provider.auth()
+      if (!alive.value) return fallback()
+      globalSync.set("provider_auth", res.data ?? {})
+      return res.data?.[props.provider] ?? fallback()
+    },
   )
+  const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
+  const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
   const [store, setStore] = createStore({
     methodIndex: undefined as undefined | number,
     authorization: undefined as undefined | ProviderAuthAuthorization,
@@ -178,7 +198,11 @@ export function DialogConnectProvider(props: { provider: string }) {
       index: 0,
     })
 
-    const prompts = createMemo(() => method()?.prompts ?? [])
+    const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
+      const value = method()
+      if (value?.type !== "oauth") return []
+      return value.prompts ?? []
+    })
     const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
       if (!prompt.when) return true
       const actual = value[prompt.when.key]
@@ -297,8 +321,12 @@ export function DialogConnectProvider(props: { provider: string }) {
     listRef?.onKeyDown(e)
   }
 
-  onMount(() => {
+  let auto = false
+  createEffect(() => {
+    if (auto) return
+    if (loading()) return
     if (methods().length === 1) {
+      auto = true
       selectMethod(0)
     }
   })
@@ -316,7 +344,7 @@ export function DialogConnectProvider(props: { provider: string }) {
 
   function goBack() {
     if (methods().length === 1) {
-      dialog.show(() => <DialogSelectProvider />)
+      all()
       return
     }
     if (store.authorization) {
@@ -327,7 +355,7 @@ export function DialogConnectProvider(props: { provider: string }) {
       dispatch({ type: "method.reset" })
       return
     }
-    dialog.show(() => <DialogSelectProvider />)
+    all()
   }
 
   function MethodSelection() {
@@ -574,6 +602,14 @@ export function DialogConnectProvider(props: { provider: string }) {
         <div class="px-2.5 pb-10 flex flex-col gap-6">
           <div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
             <Switch>
+              <Match when={loading()}>
+                <div class="text-14-regular text-text-base">
+                  <div class="flex items-center gap-x-2">
+                    <Spinner />
+                    <span>{language.t("provider.connect.status.inProgress")}</span>
+                  </div>
+                </div>
+              </Match>
               <Match when={store.methodIndex === undefined}>
                 <MethodSelection />
               </Match>

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

@@ -34,7 +34,6 @@ export type FormState = {
   apiKey: string
   models: ModelRow[]
   headers: HeaderRow[]
-  saving: boolean
   err: {
     providerID?: string
     name?: string

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

@@ -16,7 +16,6 @@ describe("validateCustomProvider", () => {
           { row: "h0", key: " X-Test ", value: " enabled ", err: {} },
           { row: "h1", key: "", value: "", err: {} },
         ],
-        saving: false,
         err: {},
       },
       t,
@@ -60,7 +59,6 @@ describe("validateCustomProvider", () => {
           { row: "h0", key: "Authorization", value: "one", err: {} },
           { row: "h1", key: "authorization", value: "two", err: {} },
         ],
-        saving: false,
         err: {},
       },
       t,

+ 42 - 35
packages/app/src/components/dialog-custom-provider.tsx

@@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
+import { useMutation } from "@tanstack/solid-query"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast } from "@opencode-ai/ui/toast"
 import { batch, For } from "solid-js"
@@ -31,7 +32,6 @@ export function DialogCustomProvider(props: Props) {
     apiKey: "",
     models: [modelRow()],
     headers: [headerRow()],
-    saving: false,
     err: {},
   })
 
@@ -116,48 +116,49 @@ export function DialogCustomProvider(props: Props) {
     return output.result
   }
 
-  const save = async (e: SubmitEvent) => {
-    e.preventDefault()
-    if (form.saving) return
-
-    const result = validate()
-    if (!result) return
+  const saveMutation = useMutation(() => ({
+    mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
+      const disabledProviders = globalSync.data.config.disabled_providers ?? []
+      const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
 
-    setForm("saving", true)
-
-    const disabledProviders = globalSync.data.config.disabled_providers ?? []
-    const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
-
-    const auth = result.key
-      ? globalSDK.client.auth.set({
+      if (result.key) {
+        await globalSDK.client.auth.set({
           providerID: result.providerID,
           auth: {
             type: "api",
             key: result.key,
           },
         })
-      : Promise.resolve()
+      }
 
-    auth
-      .then(() =>
-        globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }),
-      )
-      .then(() => {
-        dialog.close()
-        showToast({
-          variant: "success",
-          icon: "circle-check",
-          title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
-          description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
-        })
+      await globalSync.updateConfig({
+        provider: { [result.providerID]: result.config },
+        disabled_providers: nextDisabled,
       })
-      .catch((err: unknown) => {
-        const message = err instanceof Error ? err.message : String(err)
-        showToast({ title: language.t("common.requestFailed"), description: message })
-      })
-      .finally(() => {
-        setForm("saving", false)
+      return result
+    },
+    onSuccess: (result) => {
+      dialog.close()
+      showToast({
+        variant: "success",
+        icon: "circle-check",
+        title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
+        description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
       })
+    },
+    onError: (err) => {
+      const message = err instanceof Error ? err.message : String(err)
+      showToast({ title: language.t("common.requestFailed"), description: message })
+    },
+  }))
+
+  const save = (e: SubmitEvent) => {
+    e.preventDefault()
+    if (saveMutation.isPending) return
+
+    const result = validate()
+    if (!result) return
+    saveMutation.mutate(result)
   }
 
   return (
@@ -312,8 +313,14 @@ export function DialogCustomProvider(props: Props) {
             </Button>
           </div>
 
-          <Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
-            {form.saving ? language.t("common.saving") : language.t("common.submit")}
+          <Button
+            class="w-auto self-start"
+            type="submit"
+            size="large"
+            variant="primary"
+            disabled={saveMutation.isPending}
+          >
+            {saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
           </Button>
         </form>
       </div>

+ 29 - 30
packages/app/src/components/dialog-edit-project.tsx

@@ -2,6 +2,7 @@ import { Button } from "@opencode-ai/ui/button"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { TextField } from "@opencode-ai/ui/text-field"
+import { useMutation } from "@tanstack/solid-query"
 import { Icon } from "@opencode-ai/ui/icon"
 import { createMemo, For, Show } from "solid-js"
 import { createStore } from "solid-js/store"
@@ -28,7 +29,6 @@ export function DialogEditProject(props: { project: LocalProject }) {
     color: props.project.icon?.color || "pink",
     iconUrl: props.project.icon?.override || "",
     startup: props.project.commands?.start ?? "",
-    saving: false,
     dragOver: false,
     iconHover: false,
   })
@@ -71,38 +71,37 @@ export function DialogEditProject(props: { project: LocalProject }) {
     setStore("iconUrl", "")
   }
 
-  async function handleSubmit(e: SubmitEvent) {
-    e.preventDefault()
-
-    await Promise.resolve()
-      .then(async () => {
-        setStore("saving", true)
-        const name = store.name.trim() === folderName() ? "" : store.name.trim()
-        const start = store.startup.trim()
+  const saveMutation = useMutation(() => ({
+    mutationFn: async () => {
+      const name = store.name.trim() === folderName() ? "" : store.name.trim()
+      const start = store.startup.trim()
 
-        if (props.project.id && props.project.id !== "global") {
-          await globalSDK.client.project.update({
-            projectID: props.project.id,
-            directory: props.project.worktree,
-            name,
-            icon: { color: store.color, override: store.iconUrl },
-            commands: { start },
-          })
-          globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
-          dialog.close()
-          return
-        }
-
-        globalSync.project.meta(props.project.worktree, {
+      if (props.project.id && props.project.id !== "global") {
+        await globalSDK.client.project.update({
+          projectID: props.project.id,
+          directory: props.project.worktree,
           name,
-          icon: { color: store.color, override: store.iconUrl || undefined },
-          commands: { start: start || undefined },
+          icon: { color: store.color, override: store.iconUrl },
+          commands: { start },
         })
+        globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
         dialog.close()
+        return
+      }
+
+      globalSync.project.meta(props.project.worktree, {
+        name,
+        icon: { color: store.color, override: store.iconUrl || undefined },
+        commands: { start: start || undefined },
       })
-      .finally(() => {
-        setStore("saving", false)
-      })
+      dialog.close()
+    },
+  }))
+
+  function handleSubmit(e: SubmitEvent) {
+    e.preventDefault()
+    if (saveMutation.isPending) return
+    saveMutation.mutate()
   }
 
   return (
@@ -246,8 +245,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
           <Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
             {language.t("common.cancel")}
           </Button>
-          <Button type="submit" variant="primary" size="large" disabled={store.saving}>
-            {store.saving ? language.t("common.saving") : language.t("common.save")}
+          <Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
+            {saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
           </Button>
         </div>
       </form>

+ 61 - 13
packages/app/src/components/dialog-select-mcp.tsx

@@ -1,9 +1,12 @@
-import { Component, createMemo, createSignal, Show } from "solid-js"
+import { useMutation } from "@tanstack/solid-query"
+import { Component, createEffect, createMemo, on, Show } from "solid-js"
+import { createStore } from "solid-js/store"
 import { useSync } from "@/context/sync"
 import { useSDK } from "@/context/sdk"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { List } from "@opencode-ai/ui/list"
 import { Switch } from "@opencode-ai/ui/switch"
+import { showToast } from "@opencode-ai/ui/toast"
 import { useLanguage } from "@/context/language"
 
 const statusLabels = {
@@ -17,7 +20,48 @@ export const DialogSelectMcp: Component = () => {
   const sync = useSync()
   const sdk = useSDK()
   const language = useLanguage()
-  const [loading, setLoading] = createSignal<string | null>(null)
+  const [state, setState] = createStore({
+    done: false,
+    loading: false,
+  })
+
+  createEffect(
+    on(
+      () => sync.data.mcp_ready,
+      (ready, prev) => {
+        if (!ready && prev) setState("done", false)
+      },
+      { defer: true },
+    ),
+  )
+
+  createEffect(() => {
+    if (state.done || state.loading) return
+    if (sync.data.mcp_ready) {
+      setState("done", true)
+      return
+    }
+
+    setState("loading", true)
+    void sdk.client.mcp
+      .status()
+      .then((result) => {
+        sync.set("mcp", result.data ?? {})
+        sync.set("mcp_ready", true)
+        setState("done", true)
+      })
+      .catch((err) => {
+        setState("done", true)
+        showToast({
+          variant: "error",
+          title: language.t("common.requestFailed"),
+          description: err instanceof Error ? err.message : String(err),
+        })
+      })
+      .finally(() => {
+        setState("loading", false)
+      })
+  })
 
   const items = createMemo(() =>
     Object.entries(sync.data.mcp ?? {})
@@ -25,10 +69,8 @@ export const DialogSelectMcp: Component = () => {
       .sort((a, b) => a.name.localeCompare(b.name)),
   )
 
-  const toggle = async (name: string) => {
-    if (loading()) return
-    setLoading(name)
-    try {
+  const toggle = useMutation(() => ({
+    mutationFn: async (name: string) => {
       const status = sync.data.mcp[name]
       if (status?.status === "connected") {
         await sdk.client.mcp.disconnect({ name })
@@ -38,10 +80,8 @@ export const DialogSelectMcp: Component = () => {
 
       const result = await sdk.client.mcp.status()
       if (result.data) sync.set("mcp", result.data)
-    } finally {
-      setLoading(null)
-    }
-  }
+    },
+  }))
 
   const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
   const totalCount = createMemo(() => items().length)
@@ -59,7 +99,8 @@ export const DialogSelectMcp: Component = () => {
         filterKeys={["name", "status"]}
         sortBy={(a, b) => a.name.localeCompare(b.name)}
         onSelect={(x) => {
-          if (x) toggle(x.name)
+          if (!x || toggle.isPending) return
+          toggle.mutate(x.name)
         }}
       >
         {(i) => {
@@ -83,7 +124,7 @@ export const DialogSelectMcp: Component = () => {
                   <Show when={statusLabel()}>
                     <span class="text-11-regular text-text-weaker">{statusLabel()}</span>
                   </Show>
-                  <Show when={loading() === i.name}>
+                  <Show when={toggle.isPending && toggle.variables === i.name}>
                     <span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
                   </Show>
                 </div>
@@ -92,7 +133,14 @@ export const DialogSelectMcp: Component = () => {
                 </Show>
               </div>
               <div onClick={(e) => e.stopPropagation()}>
-                <Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
+                <Switch
+                  checked={enabled()}
+                  disabled={toggle.isPending && toggle.variables === i.name}
+                  onChange={() => {
+                    if (toggle.isPending) return
+                    toggle.mutate(i.name)
+                  }}
+                />
               </div>
             </div>
           )

+ 14 - 6
packages/app/src/components/dialog-select-model-unpaid.tsx

@@ -8,8 +8,6 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { type Component, Show } from "solid-js"
 import { useLocal } from "@/context/local"
 import { popularProviders, useProviders } from "@/hooks/use-providers"
-import { DialogConnectProvider } from "./dialog-connect-provider"
-import { DialogSelectProvider } from "./dialog-select-provider"
 import { ModelTooltip } from "./model-tooltip"
 import { useLanguage } from "@/context/language"
 
@@ -21,6 +19,18 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
   const providers = useProviders()
   const language = useLanguage()
 
+  const connect = (provider: string) => {
+    void import("./dialog-connect-provider").then((x) => {
+      dialog.show(() => <x.DialogConnectProvider provider={provider} />)
+    })
+  }
+
+  const all = () => {
+    void import("./dialog-select-provider").then((x) => {
+      dialog.show(() => <x.DialogSelectProvider />)
+    })
+  }
+
   let listRef: ListRef | undefined
   const handleKeyDown = (e: KeyboardEvent) => {
     if (e.key === "Escape") return
@@ -91,7 +101,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
                 }}
                 onSelect={(x) => {
                   if (!x) return
-                  dialog.show(() => <DialogConnectProvider provider={x.id} />)
+                  connect(x.id)
                 }}
               >
                 {(i) => (
@@ -122,9 +132,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
                 variant="ghost"
                 class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
                 icon="dot-grid"
-                onClick={() => {
-                  dialog.show(() => <DialogSelectProvider />)
-                }}
+                onClick={all}
               >
                 {language.t("dialog.provider.viewAll")}
               </Button>

+ 20 - 15
packages/app/src/components/dialog-select-model.tsx

@@ -10,8 +10,6 @@ import { Tag } from "@opencode-ai/ui/tag"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { List } from "@opencode-ai/ui/list"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { DialogSelectProvider } from "./dialog-select-provider"
-import { DialogManageModels } from "./dialog-manage-models"
 import { ModelTooltip } from "./model-tooltip"
 import { useLanguage } from "@/context/language"
 
@@ -107,12 +105,16 @@ export function ModelSelectorPopover(props: {
 
   const handleManage = () => {
     setStore("open", false)
-    dialog.show(() => <DialogManageModels />)
+    void import("./dialog-manage-models").then((x) => {
+      dialog.show(() => <x.DialogManageModels />)
+    })
   }
 
   const handleConnectProvider = () => {
     setStore("open", false)
-    dialog.show(() => <DialogSelectProvider />)
+    void import("./dialog-select-provider").then((x) => {
+      dialog.show(() => <x.DialogSelectProvider />)
+    })
   }
   const language = useLanguage()
 
@@ -193,26 +195,29 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
   const dialog = useDialog()
   const language = useLanguage()
 
+  const provider = () => {
+    void import("./dialog-select-provider").then((x) => {
+      dialog.show(() => <x.DialogSelectProvider />)
+    })
+  }
+
+  const manage = () => {
+    void import("./dialog-manage-models").then((x) => {
+      dialog.show(() => <x.DialogManageModels />)
+    })
+  }
+
   return (
     <Dialog
       title={language.t("dialog.model.select.title")}
       action={
-        <Button
-          class="h-7 -my-1 text-14-medium"
-          icon="plus-small"
-          tabIndex={-1}
-          onClick={() => dialog.show(() => <DialogSelectProvider />)}
-        >
+        <Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={provider}>
           {language.t("command.provider.connect")}
         </Button>
       }
     >
       <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"
-        onClick={() => dialog.show(() => <DialogManageModels />)}
-      >
+      <Button variant="ghost" class="ml-3 mt-5 mb-6 text-text-base self-start" onClick={manage}>
         {language.t("dialog.model.manage")}
       </Button>
     </Dialog>

+ 85 - 88
packages/app/src/components/dialog-select-server.tsx

@@ -6,6 +6,7 @@ import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { List } from "@opencode-ai/ui/list"
 import { TextField } from "@opencode-ai/ui/text-field"
+import { useMutation } from "@tanstack/solid-query"
 import { showToast } from "@opencode-ai/ui/toast"
 import { useNavigate } from "@solidjs/router"
 import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
@@ -186,7 +187,6 @@ export function DialogSelectServer() {
       name: "",
       username: DEFAULT_USERNAME,
       password: "",
-      adding: false,
       error: "",
       showForm: false,
       status: undefined as boolean | undefined,
@@ -198,7 +198,6 @@ export function DialogSelectServer() {
       username: "",
       password: "",
       error: "",
-      busy: false,
       status: undefined as boolean | undefined,
     },
   })
@@ -209,7 +208,6 @@ export function DialogSelectServer() {
       name: "",
       username: DEFAULT_USERNAME,
       password: "",
-      adding: false,
       error: "",
       showForm: false,
       status: undefined,
@@ -224,10 +222,78 @@ export function DialogSelectServer() {
       password: "",
       error: "",
       status: undefined,
-      busy: false,
     })
   }
 
+  const addMutation = useMutation(() => ({
+    mutationFn: async (value: string) => {
+      const normalized = normalizeServerUrl(value)
+      if (!normalized) {
+        resetAdd()
+        return
+      }
+
+      const conn: ServerConnection.Http = {
+        type: "http",
+        http: { url: normalized },
+      }
+      if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
+      if (store.addServer.password) conn.http.password = store.addServer.password
+      if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
+      const result = await checkServerHealth(conn.http)
+      if (!result.healthy) {
+        setStore("addServer", { error: language.t("dialog.server.add.error") })
+        return
+      }
+
+      resetAdd()
+      await select(conn, true)
+    },
+  }))
+
+  const editMutation = useMutation(() => ({
+    mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
+      if (input.original.type !== "http") return
+      const normalized = normalizeServerUrl(input.value)
+      if (!normalized) {
+        resetEdit()
+        return
+      }
+
+      const name = store.editServer.name.trim() || undefined
+      const username = store.editServer.username || undefined
+      const password = store.editServer.password || undefined
+      const existingName = input.original.displayName
+      if (
+        normalized === input.original.http.url &&
+        name === existingName &&
+        username === input.original.http.username &&
+        password === input.original.http.password
+      ) {
+        resetEdit()
+        return
+      }
+
+      const conn: ServerConnection.Http = {
+        type: "http",
+        displayName: name,
+        http: { url: normalized, username, password },
+      }
+      const result = await checkServerHealth(conn.http)
+      if (!result.healthy) {
+        setStore("editServer", { error: language.t("dialog.server.add.error") })
+        return
+      }
+      if (normalized === input.original.http.url) {
+        server.add(conn)
+      } else {
+        replaceServer(input.original, conn)
+      }
+
+      resetEdit()
+    },
+  }))
+
   const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
     const active = server.key
     const newConn = server.add(next)
@@ -296,7 +362,7 @@ export function DialogSelectServer() {
   }
 
   const handleAddChange = (value: string) => {
-    if (store.addServer.adding) return
+    if (addMutation.isPending) return
     setStore("addServer", { url: value, error: "" })
     void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
       setStore("addServer", { status: next }),
@@ -304,12 +370,12 @@ export function DialogSelectServer() {
   }
 
   const handleAddNameChange = (value: string) => {
-    if (store.addServer.adding) return
+    if (addMutation.isPending) return
     setStore("addServer", { name: value, error: "" })
   }
 
   const handleAddUsernameChange = (value: string) => {
-    if (store.addServer.adding) return
+    if (addMutation.isPending) return
     setStore("addServer", { username: value, error: "" })
     void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
       setStore("addServer", { status: next }),
@@ -317,7 +383,7 @@ export function DialogSelectServer() {
   }
 
   const handleAddPasswordChange = (value: string) => {
-    if (store.addServer.adding) return
+    if (addMutation.isPending) return
     setStore("addServer", { password: value, error: "" })
     void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
       setStore("addServer", { status: next }),
@@ -325,7 +391,7 @@ export function DialogSelectServer() {
   }
 
   const handleEditChange = (value: string) => {
-    if (store.editServer.busy) return
+    if (editMutation.isPending) return
     setStore("editServer", { value, error: "" })
     void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
       setStore("editServer", { status: next }),
@@ -333,12 +399,12 @@ export function DialogSelectServer() {
   }
 
   const handleEditNameChange = (value: string) => {
-    if (store.editServer.busy) return
+    if (editMutation.isPending) return
     setStore("editServer", { name: value, error: "" })
   }
 
   const handleEditUsernameChange = (value: string) => {
-    if (store.editServer.busy) return
+    if (editMutation.isPending) return
     setStore("editServer", { username: value, error: "" })
     void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
       setStore("editServer", { status: next }),
@@ -346,85 +412,13 @@ export function DialogSelectServer() {
   }
 
   const handleEditPasswordChange = (value: string) => {
-    if (store.editServer.busy) return
+    if (editMutation.isPending) return
     setStore("editServer", { password: value, error: "" })
     void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
       setStore("editServer", { status: next }),
     )
   }
 
-  async function handleAdd(value: string) {
-    if (store.addServer.adding) return
-    const normalized = normalizeServerUrl(value)
-    if (!normalized) {
-      resetAdd()
-      return
-    }
-
-    setStore("addServer", { adding: true, error: "" })
-
-    const conn: ServerConnection.Http = {
-      type: "http",
-      http: { url: normalized },
-    }
-    if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
-    if (store.addServer.password) conn.http.password = store.addServer.password
-    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") })
-      return
-    }
-
-    resetAdd()
-    await select(conn, true)
-  }
-
-  async function handleEdit(original: ServerConnection.Any, value: string) {
-    if (store.editServer.busy || original.type !== "http") return
-    const normalized = normalizeServerUrl(value)
-    if (!normalized) {
-      resetEdit()
-      return
-    }
-
-    const name = store.editServer.name.trim() || undefined
-    const username = store.editServer.username || undefined
-    const password = store.editServer.password || undefined
-    const existingName = original.displayName
-    if (
-      normalized === original.http.url &&
-      name === existingName &&
-      username === original.http.username &&
-      password === original.http.password
-    ) {
-      resetEdit()
-      return
-    }
-
-    setStore("editServer", { busy: true, error: "" })
-
-    const conn: ServerConnection.Http = {
-      type: "http",
-      displayName: name,
-      http: { url: normalized, username, password },
-    }
-    const result = await checkServerHealth(conn.http)
-    setStore("editServer", { busy: false })
-    if (!result.healthy) {
-      setStore("editServer", { error: language.t("dialog.server.add.error") })
-      return
-    }
-    if (normalized === original.http.url) {
-      server.add(conn)
-    } else {
-      replaceServer(original, conn)
-    }
-
-    resetEdit()
-  }
-
   const mode = createMemo<"list" | "add" | "edit">(() => {
     if (store.editServer.id) return "edit"
     if (store.addServer.showForm) return "add"
@@ -464,23 +458,26 @@ export function DialogSelectServer() {
       password: conn.http.password ?? "",
       error: "",
       status: store.status[ServerConnection.key(conn)]?.healthy,
-      busy: false,
     })
   }
 
   const submitForm = () => {
     if (mode() === "add") {
-      void handleAdd(store.addServer.url)
+      if (addMutation.isPending) return
+      setStore("addServer", { error: "" })
+      addMutation.mutate(store.addServer.url)
       return
     }
     const original = editing()
     if (!original) return
-    void handleEdit(original, store.editServer.value)
+    if (editMutation.isPending) return
+    setStore("editServer", { error: "" })
+    editMutation.mutate({ original, value: store.editServer.value })
   }
 
   const isFormMode = createMemo(() => mode() !== "list")
   const isAddMode = createMemo(() => mode() === "add")
-  const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy))
+  const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
 
   const formTitle = createMemo(() => {
     if (!isFormMode()) return language.t("dialog.server.title")

+ 10 - 10
packages/app/src/components/prompt-input.tsx

@@ -27,7 +27,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Select } from "@opencode-ai/ui/select"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { ModelSelectorPopover } from "@/components/dialog-select-model"
-import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
 import { useProviders } from "@/hooks/use-providers"
 import { useCommand } from "@/context/command"
 import { Persist, persisted } from "@/utils/persist"
@@ -572,6 +571,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const open = recent()
       const seen = new Set(open)
       const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
+      if (!query.trim()) return [...agents, ...pinned]
       const paths = await files.searchFilesAndDirectories(query)
       const fileOptions: AtOption[] = paths
         .filter((path) => !seen.has(path))
@@ -1043,7 +1043,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     return true
   }
 
-  const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({
+  const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
     editor: () => editorRef,
     isDialogActive: () => !!dialog.active,
     setDraggingType: (type) => setStore("draggingType", type),
@@ -1388,11 +1388,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               class="hidden"
               onChange={(e) => {
                 const list = e.currentTarget.files
-                if (list) {
-                  for (const file of Array.from(list)) {
-                    void addAttachment(file)
-                  }
-                }
+                if (list) void addAttachments(Array.from(list))
                 e.currentTarget.value = ""
               }}
             />
@@ -1497,11 +1493,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                           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} />)}
+                          onClick={() => {
+                            void import("@/components/dialog-select-model-unpaid").then((x) => {
+                              dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
+                            })
+                          }}
                         >
                           <Show when={local.model.current()?.provider?.id}>
                             <ProviderIcon
-                              id={local.model.current()!.provider.id}
+                              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)" }}
                             />
@@ -1533,7 +1533,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                       >
                         <Show when={local.model.current()?.provider?.id}>
                           <ProviderIcon
-                            id={local.model.current()!.provider.id}
+                            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)" }}
                           />

+ 21 - 17
packages/app/src/components/prompt-input/attachments.ts

@@ -71,6 +71,18 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
 
   const addAttachment = (file: File) => add(file)
 
+  const addAttachments = async (files: File[], toast = true) => {
+    let found = false
+
+    for (const file of files) {
+      const ok = await add(file, false)
+      if (ok) found = true
+    }
+
+    if (!found && files.length > 0 && toast) warn()
+    return found
+  }
+
   const removeAttachment = (id: string) => {
     const current = prompt.current()
     const next = current.filter((part) => part.type !== "image" || part.id !== id)
@@ -84,18 +96,14 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
     event.preventDefault()
     event.stopPropagation()
 
-    const items = Array.from(clipboardData.items)
-    const fileItems = items.filter((item) => item.kind === "file")
+    const files = Array.from(clipboardData.items).flatMap((item) => {
+      if (item.kind !== "file") return []
+      const file = item.getAsFile()
+      return file ? [file] : []
+    })
 
-    if (fileItems.length > 0) {
-      let found = false
-      for (const item of fileItems) {
-        const file = item.getAsFile()
-        if (!file) continue
-        const ok = await add(file, false)
-        if (ok) found = true
-      }
-      if (!found) warn()
+    if (files.length > 0) {
+      await addAttachments(files)
       return
     }
 
@@ -169,12 +177,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
     const dropped = event.dataTransfer?.files
     if (!dropped) return
 
-    let found = false
-    for (const file of Array.from(dropped)) {
-      const ok = await add(file, false)
-      if (ok) found = true
-    }
-    if (!found && dropped.length > 0) warn()
+    await addAttachments(Array.from(dropped))
   }
 
   onMount(() => {
@@ -191,6 +194,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
 
   return {
     addAttachment,
+    addAttachments,
     removeAttachment,
     handlePaste,
   }

+ 26 - 0
packages/app/src/components/prompt-input/build-request-parts.test.ts

@@ -49,6 +49,32 @@ describe("buildRequestParts", () => {
     expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
   })
 
+  test("keeps multiple uploaded attachments in order", () => {
+    const result = buildRequestParts({
+      prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
+      context: [],
+      images: [
+        { type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
+        {
+          type: "image",
+          id: "img_2",
+          filename: "b.pdf",
+          mime: "application/pdf",
+          dataUrl: "data:application/pdf;base64,BBB",
+        },
+      ],
+      text: "check these",
+      messageID: "msg_multi",
+      sessionID: "ses_multi",
+      sessionDirectory: "/repo",
+    })
+
+    const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
+
+    expect(files).toHaveLength(2)
+    expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
+  })
+
   test("deduplicates context files when prompt already includes same path", () => {
     const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
 

+ 5 - 2
packages/app/src/components/prompt-input/history.test.ts

@@ -126,7 +126,7 @@ describe("prompt-input history", () => {
   test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
     const value = "a\nb\nc"
 
-    expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
+    expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false)
     expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
 
     expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
@@ -135,11 +135,14 @@ describe("prompt-input history", () => {
     expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
     expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
 
-    expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
+    expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false)
     expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
     expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
     expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
 
+    expect(canNavigateHistoryAtCursor("up", "", 0)).toBe(true)
+    expect(canNavigateHistoryAtCursor("down", "", 0)).toBe(true)
+
     expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
     expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
     expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)

+ 1 - 1
packages/app/src/components/prompt-input/history.ts

@@ -27,7 +27,7 @@ export function canNavigateHistoryAtCursor(direction: "up" | "down", text: strin
   const atStart = position === 0
   const atEnd = position === text.length
   if (inHistory) return atStart || atEnd
-  if (direction === "up") return position === 0
+  if (direction === "up") return position === 0 && text.length === 0
   return position === text.length
 }
 

+ 3 - 1
packages/app/src/components/session-context-usage.tsx

@@ -7,6 +7,7 @@ import { useFile } from "@/context/file"
 import { useLayout } from "@/context/layout"
 import { useSync } from "@/context/sync"
 import { useLanguage } from "@/context/language"
+import { useProviders } from "@/hooks/use-providers"
 import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { createSessionTabs } from "@/pages/session/helpers"
@@ -32,6 +33,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
   const file = useFile()
   const layout = useLayout()
   const language = useLanguage()
+  const providers = useProviders()
   const { params, tabs, view } = useSessionLayout()
 
   const variant = createMemo(() => props.variant ?? "button")
@@ -50,7 +52,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
       }),
   )
 
-  const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
+  const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
   const context = createMemo(() => metrics().context)
   const cost = createMemo(() => {
     return usd().format(metrics().totalCost)

+ 5 - 3
packages/app/src/components/session/session-context-tab.tsx

@@ -12,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 { useProviders } from "@/hooks/use-providers"
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { getSessionContextMetrics } from "./session-context-metrics"
 import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
@@ -92,6 +93,7 @@ const emptyUserMessages: UserMessage[] = []
 export function SessionContextTab() {
   const sync = useSync()
   const language = useLanguage()
+  const providers = useProviders()
   const { params, view } = useSessionLayout()
 
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
@@ -130,7 +132,7 @@ export function SessionContextTab() {
       }),
   )
 
-  const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
+  const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
   const ctx = createMemo(() => metrics().context)
   const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
 
@@ -267,14 +269,14 @@ export function SessionContextTab() {
 
   return (
     <ScrollView
-      class="@container h-full pb-10"
+      class="@container h-full"
       viewportRef={(el) => {
         scroll = el
         restoreScroll()
       }}
       onScroll={handleScroll}
     >
-      <div class="px-6 pt-4 flex flex-col gap-10">
+      <div class="px-6 pt-4 pb-10 flex flex-col gap-10">
         <div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
           <For each={stats}>
             {(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}

+ 8 - 1
packages/app/src/components/session/session-sortable-terminal-tab.tsx

@@ -24,6 +24,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
   })
   let input: HTMLInputElement | undefined
   let blurFrame: number | undefined
+  let editRequested = false
 
   const isDefaultTitle = () => {
     const number = props.terminal.titleNumber
@@ -168,8 +169,14 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
                 left: `${store.menuPosition.x}px`,
                 top: `${store.menuPosition.y}px`,
               }}
+              onCloseAutoFocus={(e) => {
+                if (!editRequested) return
+                e.preventDefault()
+                editRequested = false
+                requestAnimationFrame(() => edit())
+              }}
             >
-              <DropdownMenu.Item onSelect={edit}>
+              <DropdownMenu.Item onSelect={() => (editRequested = true)}>
                 <Icon name="edit" class="w-4 h-4 mr-2" />
                 {language.t("common.rename")}
               </DropdownMenu.Item>

+ 80 - 48
packages/app/src/components/settings-general.tsx

@@ -1,27 +1,43 @@
-import { Component, Show, createMemo, createResource, type JSX } from "solid-js"
+import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
 import { createStore } from "solid-js/store"
 import { Button } from "@opencode-ai/ui/button"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Select } from "@opencode-ai/ui/select"
 import { Switch } from "@opencode-ai/ui/switch"
+import { TextField } from "@opencode-ai/ui/text-field"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
+import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
 import { showToast } from "@opencode-ai/ui/toast"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
-import { useSettings, monoFontFamily } from "@/context/settings"
-import { playSound, SOUND_OPTIONS } from "@/utils/sound"
+import {
+  monoDefault,
+  monoFontFamily,
+  monoInput,
+  sansDefault,
+  sansFontFamily,
+  sansInput,
+  useSettings,
+} from "@/context/settings"
+import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
 import { Link } from "./link"
 import { SettingsList } from "./settings-list"
 
 let demoSoundState = {
   cleanup: undefined as (() => void) | undefined,
   timeout: undefined as NodeJS.Timeout | undefined,
+  run: 0,
+}
+
+type ThemeOption = {
+  id: string
+  name: string
 }
 
 // To prevent audio from overlapping/playing very quickly when navigating the settings menus,
 // delay the playback by 100ms during quick selection changes and pause existing sounds.
 const stopDemoSound = () => {
+  demoSoundState.run += 1
   if (demoSoundState.cleanup) {
     demoSoundState.cleanup()
   }
@@ -29,12 +45,19 @@ const stopDemoSound = () => {
   demoSoundState.cleanup = undefined
 }
 
-const playDemoSound = (src: string | undefined) => {
+const playDemoSound = (id: string | undefined) => {
   stopDemoSound()
-  if (!src) return
+  if (!id) return
 
+  const run = ++demoSoundState.run
   demoSoundState.timeout = setTimeout(() => {
-    demoSoundState.cleanup = playSound(src)
+    void playSoundById(id).then((cleanup) => {
+      if (demoSoundState.run !== run) {
+        cleanup?.()
+        return
+      }
+      demoSoundState.cleanup = cleanup
+    })
   }, 100)
 }
 
@@ -44,6 +67,10 @@ export const SettingsGeneral: Component = () => {
   const platform = usePlatform()
   const settings = useSettings()
 
+  onMount(() => {
+    void theme.loadThemes()
+  })
+
   const [store, setStore] = createStore({
     checking: false,
   })
@@ -104,9 +131,7 @@ export const SettingsGeneral: Component = () => {
       .finally(() => setStore("checking", false))
   }
 
-  const themeOptions = createMemo(() =>
-    Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
-  )
+  const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
 
   const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
     { value: "system", label: language.t("theme.scheme.system") },
@@ -126,25 +151,10 @@ export const SettingsGeneral: Component = () => {
     })),
   )
 
-  const fontOptions = [
-    { value: "ibm-plex-mono", label: "font.option.ibmPlexMono" },
-    { value: "cascadia-code", label: "font.option.cascadiaCode" },
-    { value: "fira-code", label: "font.option.firaCode" },
-    { value: "hack", label: "font.option.hack" },
-    { value: "inconsolata", label: "font.option.inconsolata" },
-    { value: "intel-one-mono", label: "font.option.intelOneMono" },
-    { value: "iosevka", label: "font.option.iosevka" },
-    { value: "jetbrains-mono", label: "font.option.jetbrainsMono" },
-    { value: "meslo-lgs", label: "font.option.mesloLgs" },
-    { value: "roboto-mono", label: "font.option.robotoMono" },
-    { value: "source-code-pro", label: "font.option.sourceCodePro" },
-    { value: "ubuntu-mono", label: "font.option.ubuntuMono" },
-    { value: "geist-mono", label: "font.option.geistMono" },
-  ] as const
-  const fontOptionsList = [...fontOptions]
-
-  const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const
+  const noneSound = { id: "none", label: "sound.option.none" } as const
   const soundOptions = [noneSound, ...SOUND_OPTIONS]
+  const mono = () => monoInput(settings.appearance.font())
+  const sans = () => sansInput(settings.appearance.uiFont())
 
   const soundSelectProps = (
     enabled: () => boolean,
@@ -158,7 +168,7 @@ export const SettingsGeneral: Component = () => {
     label: (o: (typeof soundOptions)[number]) => language.t(o.label),
     onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
       if (!option) return
-      playDemoSound(option.src)
+      playDemoSound(option.id === "none" ? undefined : option.id)
     },
     onSelect: (option: (typeof soundOptions)[number] | undefined) => {
       if (!option) return
@@ -169,7 +179,7 @@ export const SettingsGeneral: Component = () => {
       }
       setEnabled(true)
       set(option.id)
-      playDemoSound(option.src)
+      playDemoSound(option.id)
     },
     variant: "secondary" as const,
     size: "small" as const,
@@ -311,28 +321,50 @@ export const SettingsGeneral: Component = () => {
           />
         </SettingsRow>
 
+        <SettingsRow
+          title={language.t("settings.general.row.uiFont.title")}
+          description={language.t("settings.general.row.uiFont.description")}
+        >
+          <div class="w-full sm:w-[220px]">
+            <TextField
+              data-action="settings-ui-font"
+              label={language.t("settings.general.row.uiFont.title")}
+              hideLabel
+              type="text"
+              value={sans()}
+              onChange={(value) => settings.appearance.setUIFont(value)}
+              placeholder={sansDefault}
+              spellcheck={false}
+              autocorrect="off"
+              autocomplete="off"
+              autocapitalize="off"
+              class="text-12-regular"
+              style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
+            />
+          </div>
+        </SettingsRow>
+
         <SettingsRow
           title={language.t("settings.general.row.font.title")}
           description={language.t("settings.general.row.font.description")}
         >
-          <Select
-            data-action="settings-font"
-            options={fontOptionsList}
-            current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
-            value={(o) => o.value}
-            label={(o) => language.t(o.label)}
-            onSelect={(option) => option && settings.appearance.setFont(option.value)}
-            variant="secondary"
-            size="small"
-            triggerVariant="settings"
-            triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
-          >
-            {(option) => (
-              <span style={{ "font-family": monoFontFamily(option?.value) }}>
-                {option ? language.t(option.label) : ""}
-              </span>
-            )}
-          </Select>
+          <div class="w-full sm:w-[220px]">
+            <TextField
+              data-action="settings-code-font"
+              label={language.t("settings.general.row.font.title")}
+              hideLabel
+              type="text"
+              value={mono()}
+              onChange={(value) => settings.appearance.setFont(value)}
+              placeholder={monoDefault}
+              spellcheck={false}
+              autocorrect="off"
+              autocomplete="off"
+              autocapitalize="off"
+              class="text-12-regular"
+              style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
+            />
+          </div>
         </SettingsRow>
       </SettingsList>
     </div>

+ 443 - 0
packages/app/src/components/status-popover-body.tsx

@@ -0,0 +1,443 @@
+import { Button } from "@opencode-ai/ui/button"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Switch } from "@opencode-ai/ui/switch"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { useMutation } from "@tanstack/solid-query"
+import { showToast } from "@opencode-ai/ui/toast"
+import { useNavigate } from "@solidjs/router"
+import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
+import { createStore, reconcile } from "solid-js/store"
+import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
+import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
+import { useSDK } from "@/context/sdk"
+import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
+import { useSync } from "@/context/sync"
+import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
+
+const pollMs = 10_000
+
+const pluginEmptyMessage = (value: string, file: string): JSXElement => {
+  const parts = value.split(file)
+  if (parts.length === 1) return value
+  return (
+    <>
+      {parts[0]}
+      <code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
+      {parts.slice(1).join(file)}
+    </>
+  )
+}
+
+const listServersByHealth = (
+  list: ServerConnection.Any[],
+  active: ServerConnection.Key | undefined,
+  status: Record<ServerConnection.Key, ServerHealth | undefined>,
+) => {
+  if (!list.length) return list
+  const order = new Map(list.map((url, index) => [url, index] as const))
+  const rank = (value?: ServerHealth) => {
+    if (value?.healthy === true) return 0
+    if (value?.healthy === false) return 2
+    return 1
+  }
+
+  return list.slice().sort((a, b) => {
+    if (ServerConnection.key(a) === active) return -1
+    if (ServerConnection.key(b) === active) return 1
+    const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
+    if (diff !== 0) return diff
+    return (order.get(a) ?? 0) - (order.get(b) ?? 0)
+  })
+}
+
+const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
+  const checkServerHealth = useCheckServerHealth()
+  const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
+
+  createEffect(() => {
+    if (!enabled()) {
+      setStatus(reconcile({}))
+      return
+    }
+    const list = servers()
+    let dead = false
+
+    const refresh = async () => {
+      const results: Record<string, ServerHealth> = {}
+      await Promise.all(
+        list.map(async (conn) => {
+          results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
+        }),
+      )
+      if (dead) return
+      setStatus(reconcile(results))
+    }
+
+    void refresh()
+    const id = setInterval(() => void refresh(), pollMs)
+    onCleanup(() => {
+      dead = true
+      clearInterval(id)
+    })
+  })
+
+  return status
+}
+
+const useDefaultServerKey = (
+  get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
+) => {
+  const [state, setState] = createStore({
+    url: undefined as string | undefined,
+    tick: 0,
+  })
+
+  createEffect(() => {
+    state.tick
+    let dead = false
+    const result = get?.()
+    if (!result) {
+      setState("url", undefined)
+      onCleanup(() => {
+        dead = true
+      })
+      return
+    }
+
+    if (result instanceof Promise) {
+      void result.then((next) => {
+        if (dead) return
+        setState("url", next ? normalizeServerUrl(next) : undefined)
+      })
+      onCleanup(() => {
+        dead = true
+      })
+      return
+    }
+
+    setState("url", normalizeServerUrl(result))
+    onCleanup(() => {
+      dead = true
+    })
+  })
+
+  return {
+    key: () => {
+      const u = state.url
+      if (!u) return
+      return ServerConnection.key({ type: "http", http: { url: u } })
+    },
+    refresh: () => setState("tick", (value) => value + 1),
+  }
+}
+
+const useMcpToggleMutation = () => {
+  const sync = useSync()
+  const sdk = useSDK()
+  const language = useLanguage()
+
+  return useMutation(() => ({
+    mutationFn: async (name: string) => {
+      const status = sync.data.mcp[name]
+      await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
+      const result = await sdk.client.mcp.status()
+      if (result.data) sync.set("mcp", result.data)
+    },
+    onError: (err) => {
+      showToast({
+        variant: "error",
+        title: language.t("common.requestFailed"),
+        description: err instanceof Error ? err.message : String(err),
+      })
+    },
+  }))
+}
+
+export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
+  const sync = useSync()
+  const server = useServer()
+  const platform = usePlatform()
+  const dialog = useDialog()
+  const language = useLanguage()
+  const navigate = useNavigate()
+  const sdk = useSDK()
+
+  const [load, setLoad] = createStore({
+    lspDone: false,
+    lspLoading: false,
+    mcpDone: false,
+    mcpLoading: false,
+  })
+
+  const fail = (err: unknown) => {
+    showToast({
+      variant: "error",
+      title: language.t("common.requestFailed"),
+      description: err instanceof Error ? err.message : String(err),
+    })
+  }
+
+  createEffect(() => {
+    if (!props.shown()) return
+
+    if (!sync.data.mcp_ready && !load.mcpDone && !load.mcpLoading) {
+      setLoad("mcpLoading", true)
+      void sdk.client.mcp
+        .status()
+        .then((result) => {
+          sync.set("mcp", result.data ?? {})
+          sync.set("mcp_ready", true)
+        })
+        .catch((err) => {
+          setLoad("mcpDone", true)
+          fail(err)
+        })
+        .finally(() => {
+          setLoad("mcpLoading", false)
+        })
+    }
+
+    if (!sync.data.lsp_ready && !load.lspDone && !load.lspLoading) {
+      setLoad("lspLoading", true)
+      void sdk.client.lsp
+        .status()
+        .then((result) => {
+          sync.set("lsp", result.data ?? [])
+          sync.set("lsp_ready", true)
+        })
+        .catch((err) => {
+          setLoad("lspDone", true)
+          fail(err)
+        })
+        .finally(() => {
+          setLoad("lspLoading", false)
+        })
+    }
+  })
+
+  let dialogRun = 0
+  let dialogDead = false
+  onCleanup(() => {
+    dialogDead = true
+    dialogRun += 1
+  })
+  const servers = createMemo(() => {
+    const current = server.current
+    const list = server.list
+    if (!current) return list
+    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, props.shown)
+  const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
+  const toggleMcp = useMcpToggleMutation()
+  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)
+  const lspItems = createMemo(() => sync.data.lsp ?? [])
+  const lspCount = createMemo(() => lspItems().length)
+  const plugins = createMemo(() => sync.data.config.plugin ?? [])
+  const pluginCount = createMemo(() => plugins().length)
+  const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
+
+  return (
+    <div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
+      <Tabs
+        aria-label={language.t("status.popover.ariaLabel")}
+        class="tabs bg-background-strong rounded-xl overflow-hidden"
+        data-component="tabs"
+        data-active="servers"
+        defaultValue="servers"
+        variant="alt"
+      >
+        <Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
+          <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
+            {sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
+            {language.t("status.popover.tab.servers")}
+          </Tabs.Trigger>
+          <Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
+            {mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
+            {language.t("status.popover.tab.mcp")}
+          </Tabs.Trigger>
+          <Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
+            {lspCount() > 0 ? `${lspCount()} ` : ""}
+            {language.t("status.popover.tab.lsp")}
+          </Tabs.Trigger>
+          <Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
+            {pluginCount() > 0 ? `${pluginCount()} ` : ""}
+            {language.t("status.popover.tab.plugins")}
+          </Tabs.Trigger>
+        </Tabs.List>
+
+        <Tabs.Content value="servers">
+          <div class="flex flex-col px-2 pb-2">
+            <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
+              <For each={sortedServers()}>
+                {(s) => {
+                  const key = ServerConnection.key(s)
+                  const blocked = () => health[key]?.healthy === false
+                  return (
+                    <button
+                      type="button"
+                      class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
+                      classList={{
+                        "hover:bg-surface-raised-base-hover": !blocked(),
+                        "cursor-not-allowed": blocked(),
+                      }}
+                      aria-disabled={blocked()}
+                      onClick={() => {
+                        if (blocked()) return
+                        navigate("/")
+                        queueMicrotask(() => server.setActive(key))
+                      }}
+                    >
+                      <ServerHealthIndicator health={health[key]} />
+                      <ServerRow
+                        conn={s}
+                        dimmed={blocked()}
+                        status={health[key]}
+                        class="flex items-center gap-2 w-full min-w-0"
+                        nameClass="text-14-regular text-text-base truncate"
+                        versionClass="text-12-regular text-text-weak truncate"
+                        badge={
+                          <Show when={key === defaultServer.key()}>
+                            <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
+                              {language.t("common.default")}
+                            </span>
+                          </Show>
+                        }
+                      >
+                        <div class="flex-1" />
+                        <Show when={server.current && key === ServerConnection.key(server.current)}>
+                          <Icon name="check" size="small" class="text-icon-weak shrink-0" />
+                        </Show>
+                      </ServerRow>
+                    </button>
+                  )
+                }}
+              </For>
+
+              <Button
+                variant="secondary"
+                class="mt-3 self-start h-8 px-3 py-1.5"
+                onClick={() => {
+                  const run = ++dialogRun
+                  void import("./dialog-select-server").then((x) => {
+                    if (dialogDead || dialogRun !== run) return
+                    dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
+                  })
+                }}
+              >
+                {language.t("status.popover.action.manageServers")}
+              </Button>
+            </div>
+          </div>
+        </Tabs.Content>
+
+        <Tabs.Content value="mcp">
+          <div class="flex flex-col px-2 pb-2">
+            <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
+              <Show
+                when={mcpNames().length > 0}
+                fallback={
+                  <div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.mcp.empty")}</div>
+                }
+              >
+                <For each={mcpNames()}>
+                  {(name) => {
+                    const status = () => mcpStatus(name)
+                    const enabled = () => status() === "connected"
+                    return (
+                      <button
+                        type="button"
+                        class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
+                        onClick={() => {
+                          if (toggleMcp.isPending) return
+                          toggleMcp.mutate(name)
+                        }}
+                        disabled={toggleMcp.isPending && toggleMcp.variables === name}
+                      >
+                        <div
+                          classList={{
+                            "size-1.5 rounded-full shrink-0": true,
+                            "bg-icon-success-base": status() === "connected",
+                            "bg-icon-critical-base": status() === "failed",
+                            "bg-border-weak-base": status() === "disabled",
+                            "bg-icon-warning-base":
+                              status() === "needs_auth" || status() === "needs_client_registration",
+                          }}
+                        />
+                        <span class="text-14-regular text-text-base truncate flex-1">{name}</span>
+                        <div onClick={(event) => event.stopPropagation()}>
+                          <Switch
+                            checked={enabled()}
+                            disabled={toggleMcp.isPending && toggleMcp.variables === name}
+                            onChange={() => {
+                              if (toggleMcp.isPending) return
+                              toggleMcp.mutate(name)
+                            }}
+                          />
+                        </div>
+                      </button>
+                    )
+                  }}
+                </For>
+              </Show>
+            </div>
+          </div>
+        </Tabs.Content>
+
+        <Tabs.Content value="lsp">
+          <div class="flex flex-col px-2 pb-2">
+            <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
+              <Show
+                when={lspItems().length > 0}
+                fallback={
+                  <div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.lsp.empty")}</div>
+                }
+              >
+                <For each={lspItems()}>
+                  {(item) => (
+                    <div class="flex items-center gap-2 w-full px-2 py-1">
+                      <div
+                        classList={{
+                          "size-1.5 rounded-full shrink-0": true,
+                          "bg-icon-success-base": item.status === "connected",
+                          "bg-icon-critical-base": item.status === "error",
+                        }}
+                      />
+                      <span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
+                    </div>
+                  )}
+                </For>
+              </Show>
+            </div>
+          </div>
+        </Tabs.Content>
+
+        <Tabs.Content value="plugins">
+          <div class="flex flex-col px-2 pb-2">
+            <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
+              <Show
+                when={plugins().length > 0}
+                fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
+              >
+                <For each={plugins()}>
+                  {(plugin) => (
+                    <div class="flex items-center gap-2 w-full px-2 py-1">
+                      <div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
+                      <span class="text-14-regular text-text-base truncate">{plugin}</span>
+                    </div>
+                  )}
+                </For>
+              </Show>
+            </div>
+          </div>
+        </Tabs.Content>
+      </Tabs>
+    </div>
+  )
+}

+ 21 - 378
packages/app/src/components/status-popover.tsx

@@ -1,203 +1,24 @@
 import { Button } from "@opencode-ai/ui/button"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Popover } from "@opencode-ai/ui/popover"
-import { Switch } from "@opencode-ai/ui/switch"
-import { Tabs } from "@opencode-ai/ui/tabs"
-import { showToast } from "@opencode-ai/ui/toast"
-import { useNavigate } from "@solidjs/router"
-import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
-import { createStore, reconcile } from "solid-js/store"
-import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
+import { Suspense, createMemo, createSignal, lazy, Show } from "solid-js"
 import { useLanguage } from "@/context/language"
-import { usePlatform } from "@/context/platform"
-import { useSDK } from "@/context/sdk"
-import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
+import { useServer } from "@/context/server"
 import { useSync } from "@/context/sync"
-import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
-import { DialogSelectServer } from "./dialog-select-server"
 
-const pollMs = 10_000
-
-const pluginEmptyMessage = (value: string, file: string): JSXElement => {
-  const parts = value.split(file)
-  if (parts.length === 1) return value
-  return (
-    <>
-      {parts[0]}
-      <code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
-      {parts.slice(1).join(file)}
-    </>
-  )
-}
-
-const listServersByHealth = (
-  list: ServerConnection.Any[],
-  active: ServerConnection.Key | undefined,
-  status: Record<ServerConnection.Key, ServerHealth | undefined>,
-) => {
-  if (!list.length) return list
-  const order = new Map(list.map((url, index) => [url, index] as const))
-  const rank = (value?: ServerHealth) => {
-    if (value?.healthy === true) return 0
-    if (value?.healthy === false) return 2
-    return 1
-  }
-
-  return list.slice().sort((a, b) => {
-    if (ServerConnection.key(a) === active) return -1
-    if (ServerConnection.key(b) === active) return 1
-    const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
-    if (diff !== 0) return diff
-    return (order.get(a) ?? 0) - (order.get(b) ?? 0)
-  })
-}
-
-const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => {
-  const checkServerHealth = useCheckServerHealth()
-  const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
-
-  createEffect(() => {
-    const list = servers()
-    let dead = false
-
-    const refresh = async () => {
-      const results: Record<string, ServerHealth> = {}
-      await Promise.all(
-        list.map(async (conn) => {
-          results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
-        }),
-      )
-      if (dead) return
-      setStatus(reconcile(results))
-    }
-
-    void refresh()
-    const id = setInterval(() => void refresh(), pollMs)
-    onCleanup(() => {
-      dead = true
-      clearInterval(id)
-    })
-  })
-
-  return status
-}
-
-const useDefaultServerKey = (
-  get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
-) => {
-  const [state, setState] = createStore({
-    url: undefined as string | undefined,
-    tick: 0,
-  })
-
-  createEffect(() => {
-    state.tick
-    let dead = false
-    const result = get?.()
-    if (!result) {
-      setState("url", undefined)
-      onCleanup(() => {
-        dead = true
-      })
-      return
-    }
-
-    if (result instanceof Promise) {
-      void result.then((next) => {
-        if (dead) return
-        setState("url", next ? normalizeServerUrl(next) : undefined)
-      })
-      onCleanup(() => {
-        dead = true
-      })
-      return
-    }
-
-    setState("url", normalizeServerUrl(result))
-    onCleanup(() => {
-      dead = true
-    })
-  })
-
-  return {
-    key: () => {
-      const u = state.url
-      if (!u) return
-      return ServerConnection.key({ type: "http", http: { url: u } })
-    },
-    refresh: () => setState("tick", (value) => value + 1),
-  }
-}
-
-const useMcpToggle = (input: {
-  sync: ReturnType<typeof useSync>
-  sdk: ReturnType<typeof useSDK>
-  language: ReturnType<typeof useLanguage>
-}) => {
-  const [loading, setLoading] = createSignal<string | null>(null)
-
-  const toggle = async (name: string) => {
-    if (loading()) return
-    setLoading(name)
-
-    try {
-      const status = input.sync.data.mcp[name]
-      await (status?.status === "connected"
-        ? input.sdk.client.mcp.disconnect({ name })
-        : input.sdk.client.mcp.connect({ name }))
-      const result = await input.sdk.client.mcp.status()
-      if (result.data) input.sync.set("mcp", result.data)
-    } catch (err) {
-      showToast({
-        variant: "error",
-        title: input.language.t("common.requestFailed"),
-        description: err instanceof Error ? err.message : String(err),
-      })
-    } finally {
-      setLoading(null)
-    }
-  }
-
-  return { loading, toggle }
-}
+const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
 
 export function StatusPopover() {
-  const sync = useSync()
-  const sdk = useSDK()
-  const server = useServer()
-  const platform = usePlatform()
-  const dialog = useDialog()
   const language = useLanguage()
-  const navigate = useNavigate()
-
+  const server = useServer()
+  const sync = useSync()
   const [shown, setShown] = createSignal(false)
-  const servers = createMemo(() => {
-    const current = server.current
-    const list = server.list
-    if (!current) return list
-    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)
-  const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
-  const mcp = useMcpToggle({ sync, sdk, language })
-  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)
-  const lspItems = createMemo(() => sync.data.lsp ?? [])
-  const lspCount = createMemo(() => lspItems().length)
-  const plugins = createMemo(() => sync.data.config.plugin ?? [])
-  const pluginCount = createMemo(() => plugins().length)
-  const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
-  const overallHealthy = createMemo(() => {
+  const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready)
+  const healthy = createMemo(() => {
     const serverHealthy = server.healthy() === true
-    const anyMcpIssue = mcpNames().some((name) => {
-      const status = mcpStatus(name)
-      return status !== "connected" && status !== "disabled"
-    })
-    return serverHealthy && !anyMcpIssue
+    const mcp = Object.values(sync.data.mcp ?? {})
+    const issue = mcp.some((item) => item.status !== "connected" && item.status !== "disabled")
+    return serverHealthy && !issue
   })
 
   return (
@@ -219,9 +40,9 @@ export function StatusPopover() {
           <div
             classList={{
               "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,
+              "bg-icon-success-base": ready() && healthy(),
+              "bg-icon-critical-base": server.healthy() === false || (ready() && !healthy()),
+              "bg-border-weak-base": server.healthy() === undefined || !ready(),
             }}
           />
         </div>
@@ -231,193 +52,15 @@ export function StatusPopover() {
       placement="bottom-end"
       shift={-168}
     >
-      <div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
-        <Tabs
-          aria-label={language.t("status.popover.ariaLabel")}
-          class="tabs bg-background-strong rounded-xl overflow-hidden"
-          data-component="tabs"
-          data-active="servers"
-          defaultValue="servers"
-          variant="alt"
+      <Show when={shown()}>
+        <Suspense
+          fallback={
+            <div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
+          }
         >
-          <Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
-            <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
-              {sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
-              {language.t("status.popover.tab.servers")}
-            </Tabs.Trigger>
-            <Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
-              {mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
-              {language.t("status.popover.tab.mcp")}
-            </Tabs.Trigger>
-            <Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
-              {lspCount() > 0 ? `${lspCount()} ` : ""}
-              {language.t("status.popover.tab.lsp")}
-            </Tabs.Trigger>
-            <Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
-              {pluginCount() > 0 ? `${pluginCount()} ` : ""}
-              {language.t("status.popover.tab.plugins")}
-            </Tabs.Trigger>
-          </Tabs.List>
-
-          <Tabs.Content value="servers">
-            <div class="flex flex-col px-2 pb-2">
-              <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
-                <For each={sortedServers()}>
-                  {(s) => {
-                    const key = ServerConnection.key(s)
-                    const isBlocked = () => health[key]?.healthy === false
-                    return (
-                      <button
-                        type="button"
-                        class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
-                        classList={{
-                          "hover:bg-surface-raised-base-hover": !isBlocked(),
-                          "cursor-not-allowed": isBlocked(),
-                        }}
-                        aria-disabled={isBlocked()}
-                        onClick={() => {
-                          if (isBlocked()) return
-                          navigate("/")
-                          queueMicrotask(() => server.setActive(key))
-                        }}
-                      >
-                        <ServerHealthIndicator health={health[key]} />
-                        <ServerRow
-                          conn={s}
-                          dimmed={isBlocked()}
-                          status={health[key]}
-                          class="flex items-center gap-2 w-full min-w-0"
-                          nameClass="text-14-regular text-text-base truncate"
-                          versionClass="text-12-regular text-text-weak truncate"
-                          badge={
-                            <Show when={key === defaultServer.key()}>
-                              <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
-                                {language.t("common.default")}
-                              </span>
-                            </Show>
-                          }
-                        >
-                          <div class="flex-1" />
-                          <Show when={server.current && key === ServerConnection.key(server.current)}>
-                            <Icon name="check" size="small" class="text-icon-weak shrink-0" />
-                          </Show>
-                        </ServerRow>
-                      </button>
-                    )
-                  }}
-                </For>
-
-                <Button
-                  variant="secondary"
-                  class="mt-3 self-start h-8 px-3 py-1.5"
-                  onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
-                >
-                  {language.t("status.popover.action.manageServers")}
-                </Button>
-              </div>
-            </div>
-          </Tabs.Content>
-
-          <Tabs.Content value="mcp">
-            <div class="flex flex-col px-2 pb-2">
-              <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
-                <Show
-                  when={mcpNames().length > 0}
-                  fallback={
-                    <div class="text-14-regular text-text-base text-center my-auto">
-                      {language.t("dialog.mcp.empty")}
-                    </div>
-                  }
-                >
-                  <For each={mcpNames()}>
-                    {(name) => {
-                      const status = () => mcpStatus(name)
-                      const enabled = () => status() === "connected"
-                      return (
-                        <button
-                          type="button"
-                          class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
-                          onClick={() => mcp.toggle(name)}
-                          disabled={mcp.loading() === name}
-                        >
-                          <div
-                            classList={{
-                              "size-1.5 rounded-full shrink-0": true,
-                              "bg-icon-success-base": status() === "connected",
-                              "bg-icon-critical-base": status() === "failed",
-                              "bg-border-weak-base": status() === "disabled",
-                              "bg-icon-warning-base":
-                                status() === "needs_auth" || status() === "needs_client_registration",
-                            }}
-                          />
-                          <span class="text-14-regular text-text-base truncate flex-1">{name}</span>
-                          <div onClick={(event) => event.stopPropagation()}>
-                            <Switch
-                              checked={enabled()}
-                              disabled={mcp.loading() === name}
-                              onChange={() => mcp.toggle(name)}
-                            />
-                          </div>
-                        </button>
-                      )
-                    }}
-                  </For>
-                </Show>
-              </div>
-            </div>
-          </Tabs.Content>
-
-          <Tabs.Content value="lsp">
-            <div class="flex flex-col px-2 pb-2">
-              <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
-                <Show
-                  when={lspItems().length > 0}
-                  fallback={
-                    <div class="text-14-regular text-text-base text-center my-auto">
-                      {language.t("dialog.lsp.empty")}
-                    </div>
-                  }
-                >
-                  <For each={lspItems()}>
-                    {(item) => (
-                      <div class="flex items-center gap-2 w-full px-2 py-1">
-                        <div
-                          classList={{
-                            "size-1.5 rounded-full shrink-0": true,
-                            "bg-icon-success-base": item.status === "connected",
-                            "bg-icon-critical-base": item.status === "error",
-                          }}
-                        />
-                        <span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
-                      </div>
-                    )}
-                  </For>
-                </Show>
-              </div>
-            </div>
-          </Tabs.Content>
-
-          <Tabs.Content value="plugins">
-            <div class="flex flex-col px-2 pb-2">
-              <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
-                <Show
-                  when={plugins().length > 0}
-                  fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
-                >
-                  <For each={plugins()}>
-                    {(plugin) => (
-                      <div class="flex items-center gap-2 w-full px-2 py-1">
-                        <div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
-                        <span class="text-14-regular text-text-base truncate">{plugin}</span>
-                      </div>
-                    )}
-                  </For>
-                </Show>
-              </div>
-            </div>
-          </Tabs.Content>
-        </Tabs>
-      </div>
+          <Body shown={shown} />
+        </Suspense>
+      </Show>
     </Popover>
   )
 }

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

@@ -1,4 +1,7 @@
-import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
+import { withAlpha } from "@opencode-ai/ui/theme/color"
+import { useTheme } from "@opencode-ai/ui/theme/context"
+import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
+import type { HexColor } from "@opencode-ai/ui/theme/types"
 import { showToast } from "@opencode-ai/ui/toast"
 import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
 import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"

+ 1 - 1
packages/app/src/components/titlebar.tsx

@@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Button } from "@opencode-ai/ui/button"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
-import { useTheme } from "@opencode-ai/ui/theme"
+import { useTheme } from "@opencode-ai/ui/theme/context"
 
 import { useLayout } from "@/context/layout"
 import { usePlatform } from "@/context/platform"

+ 26 - 0
packages/app/src/context/command-keybind.test.ts

@@ -32,6 +32,25 @@ describe("command keybind helpers", () => {
     expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
   })
 
+  test("matchKeybind supports bracket keys", () => {
+    const keybinds = parseKeybind("mod+alt+[, mod+alt+]")
+    const prev = keybinds[0]
+    const next = keybinds[1]
+
+    expect(
+      matchKeybind(
+        keybinds,
+        new KeyboardEvent("keydown", { key: "[", ctrlKey: prev?.ctrl, metaKey: prev?.meta, altKey: true }),
+      ),
+    ).toBe(true)
+    expect(
+      matchKeybind(
+        keybinds,
+        new KeyboardEvent("keydown", { key: "]", ctrlKey: next?.ctrl, metaKey: next?.meta, altKey: true }),
+      ),
+    ).toBe(true)
+  })
+
   test("formatKeybind returns human readable output", () => {
     const display = formatKeybind("ctrl+alt+arrowup")
 
@@ -40,4 +59,11 @@ describe("command keybind helpers", () => {
     expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
     expect(formatKeybind("none")).toBe("")
   })
+
+  test("formatKeybind prefers the first combo", () => {
+    const display = formatKeybind("mod+k,mod+p")
+
+    expect(display.includes("K") || display.includes("k")).toBe(true)
+    expect(display.includes("P") || display.includes("p")).toBe(false)
+  })
 })

+ 35 - 32
packages/app/src/context/global-sync.tsx

@@ -9,23 +9,13 @@ import type {
 } from "@opencode-ai/sdk/v2/client"
 import { showToast } from "@opencode-ai/ui/toast"
 import { getFilename } from "@opencode-ai/util/path"
-import {
-  createContext,
-  getOwner,
-  Match,
-  onCleanup,
-  onMount,
-  type ParentProps,
-  Switch,
-  untrack,
-  useContext,
-} from "solid-js"
+import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { useLanguage } from "@/context/language"
 import { Persist, persisted } from "@/utils/persist"
 import type { InitError } from "../pages/error"
 import { useGlobalSDK } from "./global-sdk"
-import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
+import { bootstrapDirectory, bootstrapGlobal, clearProviderRev } 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"
@@ -80,6 +70,8 @@ function createGlobalSync() {
 
   let active = true
   let projectWritten = false
+  let bootedAt = 0
+  let bootingRoot = false
 
   onCleanup(() => {
     active = false
@@ -162,6 +154,7 @@ function createGlobalSync() {
       queue.clear(directory)
       sessionMeta.delete(directory)
       sdkCache.delete(directory)
+      clearProviderRev(directory)
       clearSessionPrefetchDirectory(directory)
     },
     translate: language.t,
@@ -258,6 +251,12 @@ function createGlobalSync() {
       const sdk = sdkFor(directory)
       await bootstrapDirectory({
         directory,
+        global: {
+          config: globalStore.config,
+          path: globalStore.path,
+          project: globalStore.project,
+          provider: globalStore.provider,
+        },
         sdk,
         store: child[0],
         setStore: child[1],
@@ -278,15 +277,20 @@ function createGlobalSync() {
   const unsub = globalSDK.event.listen((e) => {
     const directory = e.name
     const event = e.details
+    const recent = bootingRoot || Date.now() - bootedAt < 1500
 
     if (directory === "global") {
       applyGlobalEvent({
         event,
         project: globalStore.project,
-        refresh: queue.refresh,
+        refresh: () => {
+          if (recent) return
+          queue.refresh()
+        },
         setGlobalProject: setProjects,
       })
       if (event.type === "server.connected" || event.type === "global.disposed") {
+        if (recent) return
         for (const directory of Object.keys(children.children)) {
           queue.push(directory)
         }
@@ -309,7 +313,10 @@ function createGlobalSync() {
       loadLsp: () => {
         sdkFor(directory)
           .lsp.status()
-          .then((x) => setStore("lsp", x.data ?? []))
+          .then((x) => {
+            setStore("lsp", x.data ?? [])
+            setStore("lsp_ready", true)
+          })
       },
     })
   })
@@ -325,17 +332,19 @@ function createGlobalSync() {
   })
 
   async function bootstrap() {
-    await bootstrapGlobal({
-      globalSDK: globalSDK.client,
-      connectErrorTitle: language.t("dialog.server.add.error"),
-      connectErrorDescription: language.t("error.globalSync.connectFailed", {
-        url: globalSDK.url,
-      }),
-      requestFailedTitle: language.t("common.requestFailed"),
-      translate: language.t,
-      formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
-      setGlobalStore: setBootStore,
-    })
+    bootingRoot = true
+    try {
+      await bootstrapGlobal({
+        globalSDK: globalSDK.client,
+        requestFailedTitle: language.t("common.requestFailed"),
+        translate: language.t,
+        formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
+        setGlobalStore: setBootStore,
+      })
+      bootedAt = Date.now()
+    } finally {
+      bootingRoot = false
+    }
   }
 
   onMount(() => {
@@ -392,13 +401,7 @@ const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
 
 export function GlobalSyncProvider(props: ParentProps) {
   const value = createGlobalSync()
-  return (
-    <Switch>
-      <Match when={value.ready}>
-        <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
-      </Match>
-    </Switch>
-  )
+  return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
 }
 
 export function useGlobalSync() {

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

@@ -7,6 +7,7 @@ import type {
   ProviderAuthResponse,
   ProviderListResponse,
   QuestionRequest,
+  Session,
   Todo,
 } from "@opencode-ai/sdk/v2/client"
 import { showToast } from "@opencode-ai/ui/toast"
@@ -15,7 +16,7 @@ import { retry } from "@opencode-ai/util/retry"
 import { batch } from "solid-js"
 import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
 import type { State, VcsCache } from "./types"
-import { cmp, normalizeProviderList } from "./utils"
+import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
 import { formatServerError } from "@/utils/server-errors"
 
 type GlobalStore = {
@@ -31,73 +32,108 @@ type GlobalStore = {
   reload: undefined | "pending" | "complete"
 }
 
+function waitForPaint() {
+  return new Promise<void>((resolve) => {
+    let done = false
+    const finish = () => {
+      if (done) return
+      done = true
+      resolve()
+    }
+    const timer = setTimeout(finish, 50)
+    if (typeof requestAnimationFrame !== "function") return
+    requestAnimationFrame(() => {
+      clearTimeout(timer)
+      finish()
+    })
+  })
+}
+
+function errors(list: PromiseSettledResult<unknown>[]) {
+  return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
+}
+
+const providerRev = new Map<string, number>()
+
+export function clearProviderRev(directory: string) {
+  providerRev.delete(directory)
+}
+
+function runAll(list: Array<() => Promise<unknown>>) {
+  return Promise.allSettled(list.map((item) => item()))
+}
+
+function showErrors(input: {
+  errors: unknown[]
+  title: string
+  translate: (key: string, vars?: Record<string, string | number>) => string
+  formatMoreCount: (count: number) => string
+}) {
+  if (input.errors.length === 0) return
+  const message = formatServerError(input.errors[0], input.translate)
+  const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
+  showToast({
+    variant: "error",
+    title: input.title,
+    description: message + more,
+  })
+}
+
 export async function bootstrapGlobal(input: {
   globalSDK: OpencodeClient
-  connectErrorTitle: string
-  connectErrorDescription: string
   requestFailedTitle: string
   translate: (key: string, vars?: Record<string, string | number>) => string
   formatMoreCount: (count: number) => string
   setGlobalStore: SetStoreFunction<GlobalStore>
 }) {
-  const health = await input.globalSDK.global
-    .health()
-    .then((x) => x.data)
-    .catch(() => undefined)
-  if (!health?.healthy) {
-    showToast({
-      variant: "error",
-      title: input.connectErrorTitle,
-      description: input.connectErrorDescription,
-    })
-    input.setGlobalStore("ready", true)
-    return
-  }
+  const fast = [
+    () =>
+      retry(() =>
+        input.globalSDK.path.get().then((x) => {
+          input.setGlobalStore("path", x.data!)
+        }),
+      ),
+    () =>
+      retry(() =>
+        input.globalSDK.global.config.get().then((x) => {
+          input.setGlobalStore("config", x.data!)
+        }),
+      ),
+    () =>
+      retry(() =>
+        input.globalSDK.provider.list().then((x) => {
+          input.setGlobalStore("provider", normalizeProviderList(x.data!))
+        }),
+      ),
+  ]
 
-  const tasks = [
-    retry(() =>
-      input.globalSDK.path.get().then((x) => {
-        input.setGlobalStore("path", x.data!)
-      }),
-    ),
-    retry(() =>
-      input.globalSDK.global.config.get().then((x) => {
-        input.setGlobalStore("config", x.data!)
-      }),
-    ),
-    retry(() =>
-      input.globalSDK.project.list().then((x) => {
-        const projects = (x.data ?? [])
-          .filter((p) => !!p?.id)
-          .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
-          .slice()
-          .sort((a, b) => cmp(a.id, b.id))
-        input.setGlobalStore("project", projects)
-      }),
-    ),
-    retry(() =>
-      input.globalSDK.provider.list().then((x) => {
-        input.setGlobalStore("provider", normalizeProviderList(x.data!))
-      }),
-    ),
-    retry(() =>
-      input.globalSDK.provider.auth().then((x) => {
-        input.setGlobalStore("provider_auth", x.data ?? {})
-      }),
-    ),
+  const slow = [
+    () =>
+      retry(() =>
+        input.globalSDK.project.list().then((x) => {
+          const projects = (x.data ?? [])
+            .filter((p) => !!p?.id)
+            .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
+            .slice()
+            .sort((a, b) => cmp(a.id, b.id))
+          input.setGlobalStore("project", projects)
+        }),
+      ),
   ]
 
-  const results = await Promise.allSettled(tasks)
-  const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
-  if (errors.length) {
-    const message = formatServerError(errors[0], input.translate)
-    const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
-    showToast({
-      variant: "error",
-      title: input.requestFailedTitle,
-      description: message + more,
-    })
-  }
+  showErrors({
+    errors: errors(await runAll(fast)),
+    title: input.requestFailedTitle,
+    translate: input.translate,
+    formatMoreCount: input.formatMoreCount,
+  })
+  await waitForPaint()
+  showErrors({
+    errors: errors(await runAll(slow)),
+    title: input.requestFailedTitle,
+    translate: input.translate,
+    formatMoreCount: input.formatMoreCount,
+  })
   input.setGlobalStore("ready", true)
 }
 
@@ -111,6 +147,44 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
   }, {})
 }
 
+function projectID(directory: string, projects: Project[]) {
+  return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
+}
+
+function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
+  setStore("session", (list) => {
+    const next = list.slice()
+    const idx = next.findIndex((item) => item.id >= session.id)
+    if (idx === -1) return [...next, session]
+    if (next[idx]?.id === session.id) {
+      next[idx] = session
+      return next
+    }
+    next.splice(idx, 0, session)
+    return next
+  })
+}
+
+function warmSessions(input: {
+  ids: string[]
+  store: Store<State>
+  setStore: SetStoreFunction<State>
+  sdk: OpencodeClient
+}) {
+  const known = new Set(input.store.session.map((item) => item.id))
+  const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
+  if (ids.length === 0) return Promise.resolve()
+  return Promise.all(
+    ids.map((sessionID) =>
+      retry(() => input.sdk.session.get({ sessionID })).then((x) => {
+        const session = x.data
+        if (!session?.id) return
+        mergeSession(input.setStore, session)
+      }),
+    ),
+  ).then(() => undefined)
+}
+
 export async function bootstrapDirectory(input: {
   directory: string
   sdk: OpencodeClient
@@ -119,88 +193,166 @@ export async function bootstrapDirectory(input: {
   vcsCache: VcsCache
   loadSessions: (directory: string) => Promise<void> | void
   translate: (key: string, vars?: Record<string, string | number>) => string
+  global: {
+    config: Config
+    path: Path
+    project: Project[]
+    provider: ProviderListResponse
+  }
 }) {
-  if (input.store.status !== "complete") input.setStore("status", "loading")
+  const loading = input.store.status !== "complete"
+  const seededProject = projectID(input.directory, input.global.project)
+  const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined
+  if (seededProject) input.setStore("project", seededProject)
+  if (seededPath) input.setStore("path", seededPath)
+  if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
+    input.setStore("provider", input.global.provider)
+  }
+  if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
+    input.setStore("config", input.global.config)
+  }
+  if (loading || input.store.provider.all.length === 0) {
+    input.setStore("provider_ready", false)
+  }
+  input.setStore("mcp_ready", false)
+  input.setStore("mcp", {})
+  input.setStore("lsp_ready", false)
+  input.setStore("lsp", [])
+  if (loading) input.setStore("status", "partial")
 
-  const blockingRequests = {
-    project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
-    provider: () =>
-      input.sdk.provider.list().then((x) => {
-        input.setStore("provider", normalizeProviderList(x.data!))
-      }),
-    agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
-    config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
+  const fast = [
+    () =>
+      seededProject
+        ? Promise.resolve()
+        : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
+    () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
+    () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
+    () =>
+      seededPath
+        ? Promise.resolve()
+        : retry(() =>
+            input.sdk.path.get().then((x) => {
+              input.setStore("path", x.data!)
+              const next = projectID(x.data?.directory ?? input.directory, input.global.project)
+              if (next) input.setStore("project", next)
+            }),
+          ),
+    () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
+    () =>
+      retry(() =>
+        input.sdk.vcs.get().then((x) => {
+          const next = x.data ?? input.store.vcs
+          input.setStore("vcs", next)
+          if (next?.branch) input.vcsCache.setStore("value", next)
+        }),
+      ),
+    () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
+    () =>
+      retry(() =>
+        input.sdk.permission.list().then((x) => {
+          const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
+          const grouped = groupBySession(
+            (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
+          )
+          return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
+            batch(() => {
+              for (const sessionID of Object.keys(input.store.permission)) {
+                if (grouped[sessionID]) continue
+                input.setStore("permission", sessionID, [])
+              }
+              for (const [sessionID, permissions] of Object.entries(grouped)) {
+                input.setStore(
+                  "permission",
+                  sessionID,
+                  reconcile(
+                    permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
+                    { key: "id" },
+                  ),
+                )
+              }
+            }),
+          )
+        }),
+      ),
+    () =>
+      retry(() =>
+        input.sdk.question.list().then((x) => {
+          const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
+          const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
+          return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
+            batch(() => {
+              for (const sessionID of Object.keys(input.store.question)) {
+                if (grouped[sessionID]) continue
+                input.setStore("question", sessionID, [])
+              }
+              for (const [sessionID, questions] of Object.entries(grouped)) {
+                input.setStore(
+                  "question",
+                  sessionID,
+                  reconcile(
+                    questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
+                    { key: "id" },
+                  ),
+                )
+              }
+            }),
+          )
+        }),
+      ),
+  ]
+
+  const slow = [
+    () => Promise.resolve(input.loadSessions(input.directory)),
+    () =>
+      retry(() =>
+        input.sdk.mcp.status().then((x) => {
+          input.setStore("mcp", x.data!)
+          input.setStore("mcp_ready", true)
+        }),
+      ),
+  ]
+
+  const errs = errors(await runAll(fast))
+  if (errs.length > 0) {
+    console.error("Failed to bootstrap instance", errs[0])
+    const project = getFilename(input.directory)
+    showToast({
+      variant: "error",
+      title: input.translate("toast.project.reloadFailed.title", { project }),
+      description: formatServerError(errs[0], input.translate),
+    })
   }
 
-  try {
-    await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
-  } catch (err) {
-    console.error("Failed to bootstrap instance", err)
+  await waitForPaint()
+  const slowErrs = errors(await runAll(slow))
+  if (slowErrs.length > 0) {
+    console.error("Failed to finish bootstrap instance", slowErrs[0])
     const project = getFilename(input.directory)
     showToast({
       variant: "error",
       title: input.translate("toast.project.reloadFailed.title", { project }),
-      description: formatServerError(err, input.translate),
+      description: formatServerError(slowErrs[0], input.translate),
     })
-    input.setStore("status", "partial")
-    return
   }
 
-  if (input.store.status !== "complete") input.setStore("status", "partial")
-
-  Promise.all([
-    input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
-    input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
-    input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
-    input.loadSessions(input.directory),
-    input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
-    input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
-    input.sdk.vcs.get().then((x) => {
-      const next = x.data ?? input.store.vcs
-      input.setStore("vcs", next)
-      if (next?.branch) input.vcsCache.setStore("value", next)
-    }),
-    input.sdk.permission.list().then((x) => {
-      const grouped = groupBySession(
-        (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
-      )
-      batch(() => {
-        for (const sessionID of Object.keys(input.store.permission)) {
-          if (grouped[sessionID]) continue
-          input.setStore("permission", sessionID, [])
-        }
-        for (const [sessionID, permissions] of Object.entries(grouped)) {
-          input.setStore(
-            "permission",
-            sessionID,
-            reconcile(
-              permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
-              { key: "id" },
-            ),
-          )
-        }
-      })
-    }),
-    input.sdk.question.list().then((x) => {
-      const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
-      batch(() => {
-        for (const sessionID of Object.keys(input.store.question)) {
-          if (grouped[sessionID]) continue
-          input.setStore("question", sessionID, [])
-        }
-        for (const [sessionID, questions] of Object.entries(grouped)) {
-          input.setStore(
-            "question",
-            sessionID,
-            reconcile(
-              questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
-              { key: "id" },
-            ),
-          )
-        }
+  if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
+
+  const rev = (providerRev.get(input.directory) ?? 0) + 1
+  providerRev.set(input.directory, rev)
+  void retry(() => input.sdk.provider.list())
+    .then((x) => {
+      if (providerRev.get(input.directory) !== rev) return
+      input.setStore("provider", normalizeProviderList(x.data!))
+      input.setStore("provider_ready", true)
+    })
+    .catch((err) => {
+      if (providerRev.get(input.directory) !== rev) return
+      console.error("Failed to refresh provider list", err)
+      const project = getFilename(input.directory)
+      showToast({
+        variant: "error",
+        title: input.translate("toast.project.reloadFailed.title", { project }),
+        description: formatServerError(err, input.translate),
       })
-    }),
-  ]).then(() => {
-    input.setStore("status", "complete")
-  })
+    })
 }

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

@@ -160,6 +160,7 @@ export function createChildStoreManager(input: {
             project: "",
             projectMeta: initialMeta,
             icon: initialIcon,
+            provider_ready: false,
             provider: { all: [], connected: [], default: {} },
             config: {},
             path: { state: "", config: "", worktree: "", directory: "", home: "" },
@@ -173,7 +174,9 @@ export function createChildStoreManager(input: {
             todo: {},
             permission: {},
             question: {},
+            mcp_ready: false,
             mcp: {},
+            lsp_ready: false,
             lsp: [],
             vcs: vcsStore.value,
             limit: 5,

+ 3 - 0
packages/app/src/context/global-sync/event-reducer.ts

@@ -15,6 +15,8 @@ import type { State, VcsCache } from "./types"
 import { trimSessions } from "./session-trim"
 import { dropSessionCaches } from "./session-cache"
 
+const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
+
 export function applyGlobalEvent(input: {
   event: { type: string; properties?: unknown }
   project: Project[]
@@ -211,6 +213,7 @@ export function applyDirectoryEvent(input: {
     }
     case "message.part.updated": {
       const part = (event.properties as { part: Part }).part
+      if (SKIP_PARTS.has(part.type)) break
       const parts = input.store.part[part.messageID]
       if (!parts) {
         input.setStore("part", part.messageID, [part])

+ 3 - 0
packages/app/src/context/global-sync/types.ts

@@ -38,6 +38,7 @@ export type State = {
   project: string
   projectMeta: ProjectMeta | undefined
   icon: string | undefined
+  provider_ready: boolean
   provider: ProviderListResponse
   config: Config
   path: Path
@@ -58,9 +59,11 @@ export type State = {
   question: {
     [sessionID: string]: QuestionRequest[]
   }
+  mcp_ready: boolean
   mcp: {
     [name: string]: McpStatus
   }
+  lsp_ready: boolean
   lsp: LspStatus[]
   vcs: VcsInfo | undefined
   limit: number

+ 35 - 0
packages/app/src/context/global-sync/utils.test.ts

@@ -0,0 +1,35 @@
+import { describe, expect, test } from "bun:test"
+import type { Agent } from "@opencode-ai/sdk/v2/client"
+import { normalizeAgentList } from "./utils"
+
+const agent = (name = "build") =>
+  ({
+    name,
+    mode: "primary",
+    permission: {},
+    options: {},
+  }) as Agent
+
+describe("normalizeAgentList", () => {
+  test("keeps array payloads", () => {
+    expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
+  })
+
+  test("wraps a single agent payload", () => {
+    expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
+  })
+
+  test("extracts agents from keyed objects", () => {
+    expect(
+      normalizeAgentList({
+        build: agent("build"),
+        docs: agent("docs"),
+      }),
+    ).toEqual([agent("build"), agent("docs")])
+  })
+
+  test("drops invalid payloads", () => {
+    expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
+    expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
+  })
+})

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

@@ -1,7 +1,21 @@
-import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
+import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
 
 export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
 
+function isAgent(input: unknown): input is Agent {
+  if (!input || typeof input !== "object") return false
+  const item = input as { name?: unknown; mode?: unknown }
+  if (typeof item.name !== "string") return false
+  return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
+}
+
+export function normalizeAgentList(input: unknown): Agent[] {
+  if (Array.isArray(input)) return input.filter(isAgent)
+  if (isAgent(input)) return [input]
+  if (!input || typeof input !== "object") return []
+  return Object.values(input).filter(isAgent)
+}
+
 export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
   return {
     ...input,

+ 66 - 78
packages/app/src/context/language.tsx

@@ -1,42 +1,10 @@
 import * as i18n from "@solid-primitives/i18n"
-import { createEffect, createMemo } from "solid-js"
+import { createEffect, createMemo, createResource } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { Persist, persisted } from "@/utils/persist"
 import { dict as en } from "@/i18n/en"
-import { dict as zh } from "@/i18n/zh"
-import { dict as zht } from "@/i18n/zht"
-import { dict as ko } from "@/i18n/ko"
-import { dict as de } from "@/i18n/de"
-import { dict as es } from "@/i18n/es"
-import { dict as fr } from "@/i18n/fr"
-import { dict as da } from "@/i18n/da"
-import { dict as ja } from "@/i18n/ja"
-import { dict as pl } from "@/i18n/pl"
-import { dict as ru } from "@/i18n/ru"
-import { dict as ar } from "@/i18n/ar"
-import { dict as no } from "@/i18n/no"
-import { dict as br } from "@/i18n/br"
-import { dict as th } from "@/i18n/th"
-import { dict as bs } from "@/i18n/bs"
-import { dict as tr } from "@/i18n/tr"
 import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
-import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
-import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
-import { dict as uiKo } from "@opencode-ai/ui/i18n/ko"
-import { dict as uiDe } from "@opencode-ai/ui/i18n/de"
-import { dict as uiEs } from "@opencode-ai/ui/i18n/es"
-import { dict as uiFr } from "@opencode-ai/ui/i18n/fr"
-import { dict as uiDa } from "@opencode-ai/ui/i18n/da"
-import { dict as uiJa } from "@opencode-ai/ui/i18n/ja"
-import { dict as uiPl } from "@opencode-ai/ui/i18n/pl"
-import { dict as uiRu } from "@opencode-ai/ui/i18n/ru"
-import { dict as uiAr } from "@opencode-ai/ui/i18n/ar"
-import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
-import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
-import { dict as uiTh } from "@opencode-ai/ui/i18n/th"
-import { dict as uiBs } from "@opencode-ai/ui/i18n/bs"
-import { dict as uiTr } from "@opencode-ai/ui/i18n/tr"
 
 export type Locale =
   | "en"
@@ -59,6 +27,7 @@ export type Locale =
 
 type RawDictionary = typeof en & typeof uiEn
 type Dictionary = i18n.Flatten<RawDictionary>
+type Source = { dict: Record<string, string> }
 
 function cookie(locale: Locale) {
   return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
@@ -125,24 +94,43 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
 }
 
 const base = i18n.flatten({ ...en, ...uiEn })
-const DICT: Record<Locale, Dictionary> = {
-  en: base,
-  zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) },
-  zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) },
-  ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) },
-  de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) },
-  es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) },
-  fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) },
-  da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) },
-  ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) },
-  pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) },
-  ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) },
-  ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) },
-  no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) },
-  br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) },
-  th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) },
-  bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) },
-  tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) },
+const dicts = new Map<Locale, Dictionary>([["en", base]])
+
+const merge = (app: Promise<Source>, ui: Promise<Source>) =>
+  Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary)
+
+const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
+  zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")),
+  zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")),
+  ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")),
+  de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")),
+  es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")),
+  fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")),
+  da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")),
+  ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")),
+  pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")),
+  ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
+  ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
+  no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
+  br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
+  th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
+  bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
+  tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
+}
+
+function loadDict(locale: Locale) {
+  const hit = dicts.get(locale)
+  if (hit) return Promise.resolve(hit)
+  if (locale === "en") return Promise.resolve(base)
+  const load = loaders[locale]
+  return load().then((next: Dictionary) => {
+    dicts.set(locale, next)
+    return next
+  })
+}
+
+export function loadLocaleDict(locale: Locale) {
+  return loadDict(locale).then(() => undefined)
 }
 
 const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
@@ -168,27 +156,6 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
   { locale: "tr", match: (language) => language.startsWith("tr") },
 ]
 
-type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
-const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
-  zh,
-  zht,
-  ko,
-  de,
-  es,
-  fr,
-  da,
-  ja,
-  pl,
-  ru,
-  ar,
-  no,
-  br,
-  th,
-  bs,
-  tr,
-}
-void PARITY_CHECK
-
 function detectLocale(): Locale {
   if (typeof navigator !== "object") return "en"
 
@@ -203,27 +170,48 @@ function detectLocale(): Locale {
   return "en"
 }
 
-function normalizeLocale(value: string): Locale {
+export function normalizeLocale(value: string): Locale {
   return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
 }
 
+function readStoredLocale() {
+  if (typeof localStorage !== "object") return
+  try {
+    const raw = localStorage.getItem("opencode.global.dat:language")
+    if (!raw) return
+    const next = JSON.parse(raw) as { locale?: string }
+    if (typeof next?.locale !== "string") return
+    return normalizeLocale(next.locale)
+  } catch {
+    return
+  }
+}
+
+const warm = readStoredLocale() ?? detectLocale()
+if (warm !== "en") void loadDict(warm)
+
 export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
   name: "Language",
-  init: () => {
+  init: (props: { locale?: Locale }) => {
+    const initial = props.locale ?? readStoredLocale() ?? detectLocale()
     const [store, setStore, _, ready] = persisted(
       Persist.global("language", ["language.v1"]),
       createStore({
-        locale: detectLocale() as Locale,
+        locale: initial,
       }),
     )
 
     const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
-    console.log("locale", locale())
     const intl = createMemo(() => INTL[locale()])
 
-    const dict = createMemo<Dictionary>(() => DICT[locale()])
+    const [dict] = createResource(locale, loadDict, {
+      initialValue: dicts.get(initial) ?? base,
+    })
 
-    const t = i18n.translator(dict, i18n.resolveTemplate)
+    const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
+      key: keyof Dictionary,
+      params?: Record<string, string | number | boolean>,
+    ) => string
 
     const label = (value: Locale) => t(LABEL_KEY[value])
 

+ 20 - 2
packages/app/src/context/local.tsx

@@ -390,10 +390,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     }
 
     if (modelEnabled()) {
+      const probe = Symbol("model-probe")
+
+      modelProbe.bind(probe, {
+        setAgent: agent.set,
+        setModel: model.set,
+        setVariant: model.variant.set,
+      })
+
       createEffect(() => {
         const agent = result.agent.current()
         const model = result.model.current()
-        modelProbe.set({
+        modelProbe.set(probe, {
           dir: sdk.directory,
           sessionID: id(),
           last: store.last,
@@ -411,10 +419,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           pick: scope(),
           base: undefined,
           current: store.current,
+          variants: result.model.variant.list(),
+          models: result.model
+            .list()
+            .filter((item) => result.model.visible({ providerID: item.provider.id, modelID: item.id }))
+            .map((item) => ({
+              providerID: item.provider.id,
+              modelID: item.id,
+              name: item.name,
+            })),
+          agents: result.agent.list().map((item) => ({ name: item.name })),
         })
       })
 
-      onCleanup(() => modelProbe.clear())
+      onCleanup(() => modelProbe.clear(probe))
     }
 
     return result

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

@@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
 import { decode64 } from "@/utils/base64"
 import { EventSessionError } from "@opencode-ai/sdk/v2"
 import { Persist, persisted } from "@/utils/persist"
-import { playSound, soundSrc } from "@/utils/sound"
+import { playSoundById } from "@/utils/sound"
 
 type NotificationBase = {
   directory?: string
@@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
         if (session.parentID) return
 
         if (settings.sounds.agentEnabled()) {
-          playSound(soundSrc(settings.sounds.agent()))
+          void playSoundById(settings.sounds.agent())
         }
 
         append({
@@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
         if (session?.parentID) return
 
         if (settings.sounds.errorsEnabled()) {
-          playSound(soundSrc(settings.sounds.errors()))
+          void playSoundById(settings.sounds.errors())
         }
 
         const error = "error" in event.properties ? event.properties.error : undefined

+ 55 - 27
packages/app/src/context/settings.tsx

@@ -33,6 +33,7 @@ export interface Settings {
   appearance: {
     fontSize: number
     font: string
+    uiFont: string
   }
   keybinds: Record<string, string>
   permissions: {
@@ -42,13 +43,56 @@ export interface Settings {
   sounds: SoundSettings
 }
 
+export const monoDefault = "IBM Plex Mono"
+export const sansDefault = "Inter"
+
+const monoFallback =
+  'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
+const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
+
+const monoBase = `"${monoDefault}", "IBM Plex Mono Fallback", ${monoFallback}`
+const sansBase = `"${sansDefault}", "Inter Fallback", ${sansFallback}`
+const monoKey = "ibm-plex-mono"
+
+function input(font: string | undefined, key?: string) {
+  if (!font || font === key || !font.trim()) return ""
+  return font
+}
+
+function family(font: string) {
+  if (/^[\w-]+$/.test(font)) return font
+  return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
+}
+
+function stack(font: string | undefined, base: string, key?: string) {
+  const value = input(font, key).trim()
+  if (!value) return base
+  return `${family(value)}, ${base}`
+}
+
+export function monoInput(font: string | undefined) {
+  return input(font, monoKey)
+}
+
+export function sansInput(font: string | undefined) {
+  return input(font)
+}
+
+export function monoFontFamily(font: string | undefined) {
+  return stack(font, monoBase, monoKey)
+}
+
+export function sansFontFamily(font: string | undefined) {
+  return stack(font, sansBase)
+}
+
 const defaultSettings: Settings = {
   general: {
     autoSave: true,
     releaseNotes: true,
     followup: "steer",
     showReasoningSummaries: false,
-    shellToolPartsExpanded: true,
+    shellToolPartsExpanded: false,
     editToolPartsExpanded: false,
   },
   updates: {
@@ -56,7 +100,8 @@ const defaultSettings: Settings = {
   },
   appearance: {
     fontSize: 14,
-    font: "ibm-plex-mono",
+    font: "",
+    uiFont: "",
   },
   keybinds: {},
   permissions: {
@@ -77,29 +122,6 @@ const defaultSettings: Settings = {
   },
 }
 
-const monoFallback =
-  'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
-
-const monoFonts: Record<string, string> = {
-  "ibm-plex-mono": `"IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
-  "cascadia-code": `"Cascadia Code Nerd Font", "Cascadia Code NF", "Cascadia Mono NF", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
-  "fira-code": `"Fira Code Nerd Font", "FiraMono Nerd Font", "FiraMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
-  hack: `"Hack Nerd Font", "Hack Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
-  inconsolata: `"Inconsolata Nerd Font", "Inconsolata Nerd Font Mono","IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
-  "intel-one-mono": `"Intel One Mono Nerd Font", "IntoneMono Nerd Font", "IntoneMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
-  iosevka: `"Iosevka Nerd Font", "Iosevka Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
-  "jetbrains-mono": `"JetBrains Mono Nerd Font", "JetBrainsMono Nerd Font Mono", "JetBrainsMonoNL Nerd Font", "JetBrainsMonoNL Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
-  "meslo-lgs": `"Meslo LGS Nerd Font", "MesloLGS Nerd Font", "MesloLGM Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
-  "roboto-mono": `"Roboto Mono Nerd Font", "RobotoMono Nerd Font", "RobotoMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
-  "source-code-pro": `"Source Code Pro Nerd Font", "SauceCodePro Nerd Font", "SauceCodePro Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
-  "ubuntu-mono": `"Ubuntu Mono Nerd Font", "UbuntuMono Nerd Font", "UbuntuMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
-  "geist-mono": `"GeistMono Nerd Font", "GeistMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
-}
-
-export function monoFontFamily(font: string | undefined) {
-  return monoFonts[font ?? defaultSettings.appearance.font] ?? monoFonts[defaultSettings.appearance.font]
-}
-
 function withFallback<T>(read: () => T | undefined, fallback: T) {
   return createMemo(() => read() ?? fallback)
 }
@@ -111,7 +133,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
 
     createEffect(() => {
       if (typeof document === "undefined") return
-      document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
+      const root = document.documentElement
+      root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
+      root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.uiFont))
     })
 
     return {
@@ -167,7 +191,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
         },
         font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font),
         setFont(value: string) {
-          setStore("appearance", "font", value)
+          setStore("appearance", "font", value.trim() ? value : "")
+        },
+        uiFont: withFallback(() => store.appearance?.uiFont, defaultSettings.appearance.uiFont),
+        setUIFont(value: string) {
+          setStore("appearance", "uiFont", value.trim() ? value : "")
         },
       },
       keybinds: {

+ 8 - 4
packages/app/src/context/sync.tsx

@@ -14,6 +14,8 @@ import { useSDK } from "./sdk"
 import type { Message, Part } from "@opencode-ai/sdk/v2/client"
 import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
 
+const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
+
 function sortParts(parts: Part[]) {
   return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
 }
@@ -178,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       return globalSync.child(directory)
     }
     const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
-    const messagePageSize = 200
+    const initialMessagePageSize = 80
+    const historyMessagePageSize = 200
     const inflight = new Map<string, Promise<void>>()
     const inflightDiff = new Map<string, Promise<void>>()
     const inflightTodo = new Map<string, Promise<void>>()
@@ -336,7 +339,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           batch(() => {
             input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
             for (const p of next.part) {
-              input.setStore("part", p.id, p.part)
+              const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
+              if (filtered.length) input.setStore("part", p.id, filtered)
             }
             setMeta("limit", key, message.length)
             setMeta("cursor", key, next.cursor)
@@ -460,7 +464,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
             if (cached && hasSession && !opts?.force) return
 
-            const limit = meta.limit[key] ?? messagePageSize
+            const limit = meta.limit[key] ?? initialMessagePageSize
             const sessionReq =
               hasSession && !opts?.force
                 ? Promise.resolve()
@@ -557,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             const [, setStore] = globalSync.child(directory)
             touch(directory, setStore, sessionID)
             const key = keyFor(directory, sessionID)
-            const step = count ?? messagePageSize
+            const step = count ?? historyMessagePageSize
             if (meta.loading[key]) return
             if (meta.complete[key]) return
             const before = meta.cursor[key]

+ 12 - 39
packages/app/src/context/terminal-title.ts

@@ -1,45 +1,18 @@
-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 template = "Terminal {{number}}"
 
-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"],
-  ]),
-)
+const numbered = [
+  template,
+  "محطة طرفية {{number}}",
+  "Терминал {{number}}",
+  "ターミナル {{number}}",
+  "터미널 {{number}}",
+  "เทอร์มินัล {{number}}",
+  "终端 {{number}}",
+  "終端機 {{number}}",
+]
 
 export function defaultTitle(number: number) {
-  return en["terminal.title.numbered"].replace("{{number}}", String(number))
+  return template.replace("{{number}}", String(number))
 }
 
 export function isDefaultTitle(title: string, number: number) {

+ 1 - 1
packages/app/src/hooks/use-providers.ts

@@ -22,7 +22,7 @@ export function useProviders() {
   const providers = () => {
     if (dir()) {
       const [projectStore] = globalSync.child(dir())
-      return projectStore.provider
+      if (projectStore.provider_ready) return projectStore.provider
     }
     return globalSync.data.provider
   }

+ 4 - 17
packages/app/src/i18n/ar.ts

@@ -564,8 +564,10 @@ export const dict = {
   "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.font.title": "خط الكود",
+  "settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية والطرفيات",
+  "settings.general.row.uiFont.title": "خط الواجهة",
+  "settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها",
   "settings.general.row.followup.title": "سلوك المتابعة",
   "settings.general.row.followup.description": "اختر ما إذا كانت طلبات المتابعة توجه فورًا أو تنتظر في قائمة انتظار",
   "settings.general.row.followup.option.queue": "قائمة انتظار",
@@ -592,19 +594,6 @@ export const dict = {
   "settings.updates.action.checking": "جارٍ التحقق...",
   "settings.updates.toast.latest.title": "أنت على آخر إصدار",
   "settings.updates.toast.latest.description": "أنت تستخدم أحدث إصدار من OpenCode.",
-  "font.option.ibmPlexMono": "IBM Plex Mono",
-  "font.option.cascadiaCode": "Cascadia Code",
-  "font.option.firaCode": "Fira Code",
-  "font.option.hack": "Hack",
-  "font.option.inconsolata": "Inconsolata",
-  "font.option.intelOneMono": "Intel One Mono",
-  "font.option.iosevka": "Iosevka",
-  "font.option.jetbrainsMono": "JetBrains Mono",
-  "font.option.mesloLgs": "Meslo LGS",
-  "font.option.robotoMono": "Roboto Mono",
-  "font.option.sourceCodePro": "Source Code Pro",
-  "font.option.ubuntuMono": "Ubuntu Mono",
-  "font.option.geistMono": "Geist Mono",
   "sound.option.none": "بلا",
   "sound.option.alert01": "تنبيه 01",
   "sound.option.alert02": "تنبيه 02",
@@ -722,8 +711,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "تحميل مهارة بالاسم",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة",
-  "settings.permissions.tool.todoread.title": "قراءة المهام",
-  "settings.permissions.tool.todoread.description": "قراءة قائمة المهام",
   "settings.permissions.tool.todowrite.title": "كتابة المهام",
   "settings.permissions.tool.todowrite.description": "تحديث قائمة المهام",
   "settings.permissions.tool.webfetch.title": "جلب الويب",

+ 4 - 17
packages/app/src/i18n/br.ts

@@ -571,8 +571,10 @@ export const dict = {
   "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.font.title": "Fonte de código",
+  "settings.general.row.font.description": "Personalize a fonte usada em blocos de código e terminais",
+  "settings.general.row.uiFont.title": "Fonte da interface",
+  "settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface",
   "settings.general.row.followup.title": "Comportamento de acompanhamento",
   "settings.general.row.followup.description":
     "Escolha se os prompts de acompanhamento orientam imediatamente ou esperam na fila",
@@ -600,19 +602,6 @@ export const dict = {
   "settings.updates.action.checking": "Verificando...",
   "settings.updates.toast.latest.title": "Você está atualizado",
   "settings.updates.toast.latest.description": "Você está usando a versão mais recente do OpenCode.",
-  "font.option.ibmPlexMono": "IBM Plex Mono",
-  "font.option.cascadiaCode": "Cascadia Code",
-  "font.option.firaCode": "Fira Code",
-  "font.option.hack": "Hack",
-  "font.option.inconsolata": "Inconsolata",
-  "font.option.intelOneMono": "Intel One Mono",
-  "font.option.iosevka": "Iosevka",
-  "font.option.jetbrainsMono": "JetBrains Mono",
-  "font.option.mesloLgs": "Meslo LGS",
-  "font.option.robotoMono": "Roboto Mono",
-  "font.option.sourceCodePro": "Source Code Pro",
-  "font.option.ubuntuMono": "Ubuntu Mono",
-  "font.option.geistMono": "Geist Mono",
   "sound.option.none": "Nenhum",
   "sound.option.alert01": "Alerta 01",
   "sound.option.alert02": "Alerta 02",
@@ -732,8 +721,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Carregar uma habilidade por nome",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Executar consultas de servidor de linguagem",
-  "settings.permissions.tool.todoread.title": "Ler Tarefas",
-  "settings.permissions.tool.todoread.description": "Ler a lista de tarefas",
   "settings.permissions.tool.todowrite.title": "Escrever Tarefas",
   "settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas",
   "settings.permissions.tool.webfetch.title": "Buscar Web",

+ 4 - 17
packages/app/src/i18n/bs.ts

@@ -636,8 +636,10 @@ export const dict = {
   "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.font.title": "Font za kod",
+  "settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda i terminalima",
+  "settings.general.row.uiFont.title": "UI font",
+  "settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu",
   "settings.general.row.followup.title": "Ponašanje nadovezivanja",
   "settings.general.row.followup.description": "Odaberi da li upiti nadovezivanja usmjeravaju odmah ili čekaju u redu",
   "settings.general.row.followup.option.queue": "Red čekanja",
@@ -667,19 +669,6 @@ export const dict = {
   "settings.updates.action.checking": "Provjera...",
   "settings.updates.toast.latest.title": "Sve je ažurno",
   "settings.updates.toast.latest.description": "Koristiš najnoviju verziju OpenCode-a.",
-  "font.option.ibmPlexMono": "IBM Plex Mono",
-  "font.option.cascadiaCode": "Cascadia Code",
-  "font.option.firaCode": "Fira Code",
-  "font.option.hack": "Hack",
-  "font.option.inconsolata": "Inconsolata",
-  "font.option.intelOneMono": "Intel One Mono",
-  "font.option.iosevka": "Iosevka",
-  "font.option.jetbrainsMono": "JetBrains Mono",
-  "font.option.mesloLgs": "Meslo LGS",
-  "font.option.robotoMono": "Roboto Mono",
-  "font.option.sourceCodePro": "Source Code Pro",
-  "font.option.ubuntuMono": "Ubuntu Mono",
-  "font.option.geistMono": "Geist Mono",
   "sound.option.none": "Nijedan",
   "sound.option.alert01": "Upozorenje 01",
   "sound.option.alert02": "Upozorenje 02",
@@ -806,8 +795,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Učitaj vještinu po nazivu",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Pokreni upite jezičnog servera",
-  "settings.permissions.tool.todoread.title": "Čitanje liste zadataka",
-  "settings.permissions.tool.todoread.description": "Čitanje liste zadataka",
   "settings.permissions.tool.todowrite.title": "Ažuriranje liste zadataka",
   "settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka",
   "settings.permissions.tool.webfetch.title": "Web preuzimanje",

+ 4 - 17
packages/app/src/i18n/da.ts

@@ -631,8 +631,10 @@ export const dict = {
   "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.font.title": "Kode-skrifttype",
+  "settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke og terminaler",
+  "settings.general.row.uiFont.title": "UI-skrifttype",
+  "settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen",
   "settings.general.row.followup.title": "Opfølgningsadfærd",
   "settings.general.row.followup.description": "Vælg om opfølgende forespørgsler skal styre straks eller vente i kø",
   "settings.general.row.followup.option.queue": "Kø",
@@ -662,19 +664,6 @@ export const dict = {
   "settings.updates.toast.latest.title": "Du er opdateret",
   "settings.updates.toast.latest.description": "Du kører den nyeste version af OpenCode.",
 
-  "font.option.ibmPlexMono": "IBM Plex Mono",
-  "font.option.cascadiaCode": "Cascadia Code",
-  "font.option.firaCode": "Fira Code",
-  "font.option.hack": "Hack",
-  "font.option.inconsolata": "Inconsolata",
-  "font.option.intelOneMono": "Intel One Mono",
-  "font.option.iosevka": "Iosevka",
-  "font.option.jetbrainsMono": "JetBrains Mono",
-  "font.option.mesloLgs": "Meslo LGS",
-  "font.option.robotoMono": "Roboto Mono",
-  "font.option.sourceCodePro": "Source Code Pro",
-  "font.option.ubuntuMono": "Ubuntu Mono",
-  "font.option.geistMono": "Geist Mono",
   "sound.option.none": "Ingen",
   "sound.option.alert01": "Alarm 01",
   "sound.option.alert02": "Alarm 02",
@@ -800,8 +789,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler",
-  "settings.permissions.tool.todoread.title": "Læs To-do",
-  "settings.permissions.tool.todoread.description": "Læs to-do listen",
   "settings.permissions.tool.todowrite.title": "Skriv To-do",
   "settings.permissions.tool.todowrite.description": "Opdater to-do listen",
   "settings.permissions.tool.webfetch.title": "Webhentning",

+ 4 - 17
packages/app/src/i18n/de.ts

@@ -581,8 +581,10 @@ export const dict = {
     "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.font.title": "Code-Schriftart",
+  "settings.general.row.font.description": "Die in Codeblöcken und Terminals verwendete Schriftart anpassen",
+  "settings.general.row.uiFont.title": "UI-Schriftart",
+  "settings.general.row.uiFont.description": "Die im gesamten Interface verwendete Schriftart anpassen",
   "settings.general.row.followup.title": "Verhalten bei Folgefragen",
   "settings.general.row.followup.description":
     "Wählen Sie, ob Folgefragen sofort steuern oder in einer Warteschlange warten",
@@ -611,19 +613,6 @@ export const dict = {
   "settings.updates.action.checking": "Wird geprüft...",
   "settings.updates.toast.latest.title": "Du bist auf dem neuesten Stand",
   "settings.updates.toast.latest.description": "Du verwendest die aktuelle Version von OpenCode.",
-  "font.option.ibmPlexMono": "IBM Plex Mono",
-  "font.option.cascadiaCode": "Cascadia Code",
-  "font.option.firaCode": "Fira Code",
-  "font.option.hack": "Hack",
-  "font.option.inconsolata": "Inconsolata",
-  "font.option.intelOneMono": "Intel One Mono",
-  "font.option.iosevka": "Iosevka",
-  "font.option.jetbrainsMono": "JetBrains Mono",
-  "font.option.mesloLgs": "Meslo LGS",
-  "font.option.robotoMono": "Roboto Mono",
-  "font.option.sourceCodePro": "Source Code Pro",
-  "font.option.ubuntuMono": "Ubuntu Mono",
-  "font.option.geistMono": "Geist Mono",
   "sound.option.none": "Keine",
   "sound.option.alert01": "Alarm 01",
   "sound.option.alert02": "Alarm 02",
@@ -743,8 +732,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Eine Fähigkeit nach Namen laden",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Language-Server-Abfragen ausführen",
-  "settings.permissions.tool.todoread.title": "Todo lesen",
-  "settings.permissions.tool.todoread.description": "Die Todo-Liste lesen",
   "settings.permissions.tool.todowrite.title": "Todo schreiben",
   "settings.permissions.tool.todowrite.description": "Die Todo-Liste aktualisieren",
   "settings.permissions.tool.webfetch.title": "Web-Abruf",

+ 7 - 18
packages/app/src/i18n/en.ts

@@ -23,6 +23,8 @@ export const dict = {
 
   "command.sidebar.toggle": "Toggle sidebar",
   "command.project.open": "Open project",
+  "command.project.previous": "Previous project",
+  "command.project.next": "Next project",
   "command.provider.connect": "Connect provider",
   "command.server.switch": "Switch server",
   "command.settings.open": "Open settings",
@@ -274,7 +276,7 @@ export const dict = {
   "prompt.context.includeActiveFile": "Include active file",
   "prompt.context.removeActiveFile": "Remove active file from context",
   "prompt.context.removeFile": "Remove file from context",
-  "prompt.action.attachFile": "Add file",
+  "prompt.action.attachFile": "Add files",
   "prompt.attachment.remove": "Remove attachment",
   "prompt.action.send": "Send",
   "prompt.action.stop": "Stop",
@@ -727,8 +729,10 @@ export const dict = {
   "settings.general.row.colorScheme.description": "Choose whether OpenCode follows the system, light, or dark theme",
   "settings.general.row.theme.title": "Theme",
   "settings.general.row.theme.description": "Customise how OpenCode is themed.",
-  "settings.general.row.font.title": "Font",
-  "settings.general.row.font.description": "Customise the mono font used in code blocks",
+  "settings.general.row.font.title": "Code Font",
+  "settings.general.row.font.description": "Customise the font used in code blocks and terminals",
+  "settings.general.row.uiFont.title": "UI Font",
+  "settings.general.row.uiFont.description": "Customise the font used throughout the interface",
   "settings.general.row.followup.title": "Follow-up behavior",
   "settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
   "settings.general.row.followup.option.queue": "Queue",
@@ -758,19 +762,6 @@ export const dict = {
   "settings.updates.action.checking": "Checking...",
   "settings.updates.toast.latest.title": "You're up to date",
   "settings.updates.toast.latest.description": "You're running the latest version of OpenCode.",
-  "font.option.ibmPlexMono": "IBM Plex Mono",
-  "font.option.cascadiaCode": "Cascadia Code",
-  "font.option.firaCode": "Fira Code",
-  "font.option.hack": "Hack",
-  "font.option.inconsolata": "Inconsolata",
-  "font.option.intelOneMono": "Intel One Mono",
-  "font.option.iosevka": "Iosevka",
-  "font.option.jetbrainsMono": "JetBrains Mono",
-  "font.option.mesloLgs": "Meslo LGS",
-  "font.option.robotoMono": "Roboto Mono",
-  "font.option.sourceCodePro": "Source Code Pro",
-  "font.option.ubuntuMono": "Ubuntu Mono",
-  "font.option.geistMono": "Geist Mono",
   "sound.option.none": "None",
   "sound.option.alert01": "Alert 01",
   "sound.option.alert02": "Alert 02",
@@ -898,8 +889,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Load a skill by name",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Run language server queries",
-  "settings.permissions.tool.todoread.title": "Todo Read",
-  "settings.permissions.tool.todoread.description": "Read the todo list",
   "settings.permissions.tool.todowrite.title": "Todo Write",
   "settings.permissions.tool.todowrite.description": "Update the todo list",
   "settings.permissions.tool.webfetch.title": "Web Fetch",

+ 4 - 17
packages/app/src/i18n/es.ts

@@ -639,8 +639,10 @@ export const dict = {
   "settings.general.row.colorScheme.description": "Elige si OpenCode sigue el tema del sistema, claro u oscuro",
   "settings.general.row.theme.title": "Tema",
   "settings.general.row.theme.description": "Personaliza el tema de OpenCode.",
-  "settings.general.row.font.title": "Fuente",
-  "settings.general.row.font.description": "Personaliza la fuente monoespaciada usada en bloques de código",
+  "settings.general.row.font.title": "Fuente de código",
+  "settings.general.row.font.description": "Personaliza la fuente usada en bloques de código y terminales",
+  "settings.general.row.uiFont.title": "Fuente de la interfaz",
+  "settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz",
   "settings.general.row.followup.title": "Comportamiento de seguimiento",
   "settings.general.row.followup.description":
     "Elige si los prompts de seguimiento se dirigen inmediatamente o esperan en una cola",
@@ -672,19 +674,6 @@ export const dict = {
   "settings.updates.action.checking": "Buscando...",
   "settings.updates.toast.latest.title": "Estás al día",
   "settings.updates.toast.latest.description": "Estás usando la última versión de OpenCode.",
-  "font.option.ibmPlexMono": "IBM Plex Mono",
-  "font.option.cascadiaCode": "Cascadia Code",
-  "font.option.firaCode": "Fira Code",
-  "font.option.hack": "Hack",
-  "font.option.inconsolata": "Inconsolata",
-  "font.option.intelOneMono": "Intel One Mono",
-  "font.option.iosevka": "Iosevka",
-  "font.option.jetbrainsMono": "JetBrains Mono",
-  "font.option.mesloLgs": "Meslo LGS",
-  "font.option.robotoMono": "Roboto Mono",
-  "font.option.sourceCodePro": "Source Code Pro",
-  "font.option.ubuntuMono": "Ubuntu Mono",
-  "font.option.geistMono": "Geist Mono",
   "sound.option.none": "Ninguno",
   "sound.option.alert01": "Alerta 01",
   "sound.option.alert02": "Alerta 02",
@@ -813,8 +802,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Cargar una habilidad por nombre",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Ejecutar consultas de servidor de lenguaje",
-  "settings.permissions.tool.todoread.title": "Leer Todo",
-  "settings.permissions.tool.todoread.description": "Leer la lista de tareas",
   "settings.permissions.tool.todowrite.title": "Escribir Todo",
   "settings.permissions.tool.todowrite.description": "Actualizar la lista de tareas",
   "settings.permissions.tool.webfetch.title": "Web Fetch",

+ 4 - 17
packages/app/src/i18n/fr.ts

@@ -578,8 +578,10 @@ export const dict = {
   "settings.general.row.colorScheme.description": "Choisissez si OpenCode suit le thème système, clair ou sombre",
   "settings.general.row.theme.title": "Thème",
   "settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.",
-  "settings.general.row.font.title": "Police",
-  "settings.general.row.font.description": "Personnaliser la police mono utilisée dans les blocs de code",
+  "settings.general.row.font.title": "Police de code",
+  "settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code et les terminaux",
+  "settings.general.row.uiFont.title": "Police de l'interface",
+  "settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface",
   "settings.general.row.followup.title": "Comportement de suivi",
   "settings.general.row.followup.description":
     "Choisissez si les messages de suivi dirigent immédiatement ou attendent dans une file d'attente",
@@ -608,19 +610,6 @@ export const dict = {
   "settings.updates.action.checking": "Vérification...",
   "settings.updates.toast.latest.title": "Vous êtes à jour",
   "settings.updates.toast.latest.description": "Vous utilisez la dernière version d'OpenCode.",
-  "font.option.ibmPlexMono": "IBM Plex Mono",
-  "font.option.cascadiaCode": "Cascadia Code",
-  "font.option.firaCode": "Fira Code",
-  "font.option.hack": "Hack",
-  "font.option.inconsolata": "Inconsolata",
-  "font.option.intelOneMono": "Intel One Mono",
-  "font.option.iosevka": "Iosevka",
-  "font.option.jetbrainsMono": "JetBrains Mono",
-  "font.option.mesloLgs": "Meslo LGS",
-  "font.option.robotoMono": "Roboto Mono",
-  "font.option.sourceCodePro": "Source Code Pro",
-  "font.option.ubuntuMono": "Ubuntu Mono",
-  "font.option.geistMono": "Geist Mono",
   "sound.option.none": "Aucun",
   "sound.option.alert01": "Alerte 01",
   "sound.option.alert02": "Alerte 02",
@@ -741,8 +730,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Charger une compétence par son nom",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Exécuter des requêtes de serveur de langage",
-  "settings.permissions.tool.todoread.title": "Lire Todo",
-  "settings.permissions.tool.todoread.description": "Lire la liste de tâches",
   "settings.permissions.tool.todowrite.title": "Écrire Todo",
   "settings.permissions.tool.todowrite.description": "Mettre à jour la liste de tâches",
   "settings.permissions.tool.webfetch.title": "Récupération Web",

+ 4 - 17
packages/app/src/i18n/ja.ts

@@ -568,8 +568,10 @@ export const dict = {
   "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.font.title": "コードフォント",
+  "settings.general.row.font.description": "コードブロックとターミナルで使用するフォントをカスタマイズします",
+  "settings.general.row.uiFont.title": "UIフォント",
+  "settings.general.row.uiFont.description": "インターフェース全体で使用するフォントをカスタマイズします",
   "settings.general.row.followup.title": "フォローアップの動作",
   "settings.general.row.followup.description":
     "フォローアッププロンプトを即座に実行するか、キューで待機させるかを選択します",
@@ -597,19 +599,6 @@ export const dict = {
   "settings.updates.action.checking": "確認中...",
   "settings.updates.toast.latest.title": "最新です",
   "settings.updates.toast.latest.description": "OpenCode は最新バージョンです。",
-  "font.option.ibmPlexMono": "IBM Plex Mono",
-  "font.option.cascadiaCode": "Cascadia Code",
-  "font.option.firaCode": "Fira Code",
-  "font.option.hack": "Hack",
-  "font.option.inconsolata": "Inconsolata",
-  "font.option.intelOneMono": "Intel One Mono",
-  "font.option.iosevka": "Iosevka",
-  "font.option.jetbrainsMono": "JetBrains Mono",
-  "font.option.mesloLgs": "Meslo LGS",
-  "font.option.robotoMono": "Roboto Mono",
-  "font.option.sourceCodePro": "Source Code Pro",
-  "font.option.ubuntuMono": "Ubuntu Mono",
-  "font.option.geistMono": "Geist Mono",
   "sound.option.none": "なし",
   "sound.option.alert01": "アラート 01",
   "sound.option.alert02": "アラート 02",
@@ -727,8 +716,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "名前によるスキルの読み込み",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "言語サーバークエリの実行",
-  "settings.permissions.tool.todoread.title": "Todo読み込み",
-  "settings.permissions.tool.todoread.description": "Todoリストの読み込み",
   "settings.permissions.tool.todowrite.title": "Todo書き込み",
   "settings.permissions.tool.todowrite.description": "Todoリストの更新",
   "settings.permissions.tool.webfetch.title": "Web取得",

+ 4 - 17
packages/app/src/i18n/ko.ts

@@ -569,8 +569,10 @@ export const dict = {
   "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.font.title": "코드 글꼴",
+  "settings.general.row.font.description": "코드 블록과 터미널에 사용되는 글꼴을 사용자 지정",
+  "settings.general.row.uiFont.title": "UI 글꼴",
+  "settings.general.row.uiFont.description": "인터페이스 전반에 사용되는 글꼴을 사용자 지정",
   "settings.general.row.followup.title": "후속 조치 동작",
   "settings.general.row.followup.description": "후속 프롬프트를 즉시 실행할지 대기열에 넣을지 선택하세요",
   "settings.general.row.followup.option.queue": "대기열",
@@ -597,19 +599,6 @@ export const dict = {
   "settings.updates.action.checking": "확인 중...",
   "settings.updates.toast.latest.title": "최신 상태입니다",
   "settings.updates.toast.latest.description": "현재 최신 버전의 OpenCode를 사용 중입니다.",
-  "font.option.ibmPlexMono": "IBM Plex Mono",
-  "font.option.cascadiaCode": "Cascadia Code",
-  "font.option.firaCode": "Fira Code",
-  "font.option.hack": "Hack",
-  "font.option.inconsolata": "Inconsolata",
-  "font.option.intelOneMono": "Intel One Mono",
-  "font.option.iosevka": "Iosevka",
-  "font.option.jetbrainsMono": "JetBrains Mono",
-  "font.option.mesloLgs": "Meslo LGS",
-  "font.option.robotoMono": "Roboto Mono",
-  "font.option.sourceCodePro": "Source Code Pro",
-  "font.option.ubuntuMono": "Ubuntu Mono",
-  "font.option.geistMono": "Geist Mono",
   "sound.option.none": "없음",
   "sound.option.alert01": "알림 01",
   "sound.option.alert02": "알림 02",
@@ -726,8 +715,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "이름으로 기술 로드",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "언어 서버 쿼리 실행",
-  "settings.permissions.tool.todoread.title": "할 일 읽기",
-  "settings.permissions.tool.todoread.description": "할 일 목록 읽기",
   "settings.permissions.tool.todowrite.title": "할 일 쓰기",
   "settings.permissions.tool.todowrite.description": "할 일 목록 업데이트",
   "settings.permissions.tool.webfetch.title": "웹 가져오기",

+ 4 - 17
packages/app/src/i18n/no.ts

@@ -639,8 +639,10 @@ export const dict = {
   "settings.general.row.colorScheme.description": "Velg om OpenCode skal følge systemets, lyst eller mørkt tema",
   "settings.general.row.theme.title": "Tema",
   "settings.general.row.theme.description": "Tilpass hvordan OpenCode er tematisert.",
-  "settings.general.row.font.title": "Skrift",
-  "settings.general.row.font.description": "Tilpass mono-skriften som brukes i kodeblokker",
+  "settings.general.row.font.title": "Kodefont",
+  "settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker og terminaler",
+  "settings.general.row.uiFont.title": "UI-skrift",
+  "settings.general.row.uiFont.description": "Tilpass skrifttypen som brukes i hele grensesnittet",
   "settings.general.row.followup.title": "Oppfølgingsadferd",
   "settings.general.row.followup.description": "Velg om oppfølgingsspørsmål skal kjøres umiddelbart eller vente i kø",
   "settings.general.row.followup.option.queue": "Kø",
@@ -668,19 +670,6 @@ export const dict = {
   "settings.updates.action.checking": "Sjekker...",
   "settings.updates.toast.latest.title": "Du er oppdatert",
   "settings.updates.toast.latest.description": "Du bruker den nyeste versjonen av OpenCode.",
-  "font.option.ibmPlexMono": "IBM Plex Mono",
-  "font.option.cascadiaCode": "Cascadia Code",
-  "font.option.firaCode": "Fira Code",
-  "font.option.hack": "Hack",
-  "font.option.inconsolata": "Inconsolata",
-  "font.option.intelOneMono": "Intel One Mono",
-  "font.option.iosevka": "Iosevka",
-  "font.option.jetbrainsMono": "JetBrains Mono",
-  "font.option.mesloLgs": "Meslo LGS",
-  "font.option.robotoMono": "Roboto Mono",
-  "font.option.sourceCodePro": "Source Code Pro",
-  "font.option.ubuntuMono": "Ubuntu Mono",
-  "font.option.geistMono": "Geist Mono",
   "sound.option.none": "Ingen",
   "sound.option.alert01": "Varsel 01",
   "sound.option.alert02": "Varsel 02",
@@ -807,8 +796,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Last en ferdighet etter navn",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Kjør språkserverforespørsler",
-  "settings.permissions.tool.todoread.title": "Les gjøremål",
-  "settings.permissions.tool.todoread.description": "Les gjøremålslisten",
   "settings.permissions.tool.todowrite.title": "Skriv gjøremål",
   "settings.permissions.tool.todowrite.description": "Oppdater gjøremålslisten",
   "settings.permissions.tool.webfetch.title": "Webhenting",

+ 4 - 17
packages/app/src/i18n/pl.ts

@@ -570,8 +570,10 @@ export const dict = {
     "Wybierz, czy OpenCode ma używać motywu systemowego, jasnego czy ciemnego",
   "settings.general.row.theme.title": "Motyw",
   "settings.general.row.theme.description": "Dostosuj motyw OpenCode.",
-  "settings.general.row.font.title": "Czcionka",
-  "settings.general.row.font.description": "Dostosuj czcionkę mono używaną w blokach kodu",
+  "settings.general.row.font.title": "Czcionka kodu",
+  "settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu i terminalach",
+  "settings.general.row.uiFont.title": "Czcionka interfejsu",
+  "settings.general.row.uiFont.description": "Dostosuj czcionkę używaną w całym interfejsie",
   "settings.general.row.followup.title": "Zachowanie kontynuacji",
   "settings.general.row.followup.description": "Wybierz, czy kontynuacja ma być natychmiastowa, czy czekać w kolejce",
   "settings.general.row.followup.option.queue": "Kolejka",
@@ -598,19 +600,6 @@ export const dict = {
   "settings.updates.action.checking": "Sprawdzanie...",
   "settings.updates.toast.latest.title": "Masz najnowszą wersję",
   "settings.updates.toast.latest.description": "Korzystasz z najnowszej wersji OpenCode.",
-  "font.option.ibmPlexMono": "IBM Plex Mono",
-  "font.option.cascadiaCode": "Cascadia Code",
-  "font.option.firaCode": "Fira Code",
-  "font.option.hack": "Hack",
-  "font.option.inconsolata": "Inconsolata",
-  "font.option.intelOneMono": "Intel One Mono",
-  "font.option.iosevka": "Iosevka",
-  "font.option.jetbrainsMono": "JetBrains Mono",
-  "font.option.mesloLgs": "Meslo LGS",
-  "font.option.robotoMono": "Roboto Mono",
-  "font.option.sourceCodePro": "Source Code Pro",
-  "font.option.ubuntuMono": "Ubuntu Mono",
-  "font.option.geistMono": "Geist Mono",
   "sound.option.none": "Brak",
   "sound.option.alert01": "Alert 01",
   "sound.option.alert02": "Alert 02",
@@ -729,8 +718,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Ładowanie umiejętności według nazwy",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Uruchamianie zapytań serwera językowego",
-  "settings.permissions.tool.todoread.title": "Odczyt Todo",
-  "settings.permissions.tool.todoread.description": "Odczyt listy zadań",
   "settings.permissions.tool.todowrite.title": "Zapis Todo",
   "settings.permissions.tool.todowrite.description": "Aktualizacja listy zadań",
   "settings.permissions.tool.webfetch.title": "Pobieranie z sieci",

+ 4 - 17
packages/app/src/i18n/ru.ts

@@ -636,8 +636,10 @@ export const dict = {
   "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.font.title": "Шрифт кода",
+  "settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода и терминалах",
+  "settings.general.row.uiFont.title": "Шрифт интерфейса",
+  "settings.general.row.uiFont.description": "Настройте шрифт, используемый во всем интерфейсе",
   "settings.general.row.followup.title": "Поведение уточняющих вопросов",
   "settings.general.row.followup.description":
     "Выберите, отправлять ли уточняющие вопросы сразу или помещать их в очередь",
@@ -668,19 +670,6 @@ export const dict = {
   "settings.updates.action.checking": "Проверка...",
   "settings.updates.toast.latest.title": "У вас последняя версия",
   "settings.updates.toast.latest.description": "Вы используете последнюю версию OpenCode.",
-  "font.option.ibmPlexMono": "IBM Plex Mono",
-  "font.option.cascadiaCode": "Cascadia Code",
-  "font.option.firaCode": "Fira Code",
-  "font.option.hack": "Hack",
-  "font.option.inconsolata": "Inconsolata",
-  "font.option.intelOneMono": "Intel One Mono",
-  "font.option.iosevka": "Iosevka",
-  "font.option.jetbrainsMono": "JetBrains Mono",
-  "font.option.mesloLgs": "Meslo LGS",
-  "font.option.robotoMono": "Roboto Mono",
-  "font.option.sourceCodePro": "Source Code Pro",
-  "font.option.ubuntuMono": "Ubuntu Mono",
-  "font.option.geistMono": "Geist Mono",
   "sound.option.none": "Нет",
   "sound.option.alert01": "Alert 01",
   "sound.option.alert02": "Alert 02",
@@ -808,8 +797,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Загрузка навыка по имени",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Запросы к языковому серверу",
-  "settings.permissions.tool.todoread.title": "Todo Read",
-  "settings.permissions.tool.todoread.description": "Чтение списка задач",
   "settings.permissions.tool.todowrite.title": "Todo Write",
   "settings.permissions.tool.todowrite.description": "Обновление списка задач",
   "settings.permissions.tool.webfetch.title": "Web Fetch",

+ 4 - 17
packages/app/src/i18n/th.ts

@@ -630,8 +630,10 @@ export const dict = {
   "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.font.title": "ฟอนต์โค้ด",
+  "settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ดและเทอร์มินัล",
+  "settings.general.row.uiFont.title": "ฟอนต์ UI",
+  "settings.general.row.uiFont.description": "ปรับแต่งฟอนต์ที่ใช้ทั่วทั้งอินเทอร์เฟซ",
   "settings.general.row.followup.title": "พฤติกรรมการติดตามผล",
   "settings.general.row.followup.description": "เลือกว่าจะให้พร้อมท์ติดตามผลทำงานทันทีหรือรอในคิว",
   "settings.general.row.followup.option.queue": "คิว",
@@ -659,19 +661,6 @@ export const dict = {
   "settings.updates.toast.latest.title": "คุณเป็นเวอร์ชันล่าสุดแล้ว",
   "settings.updates.toast.latest.description": "คุณกำลังใช้งาน OpenCode เวอร์ชันล่าสุด",
 
-  "font.option.ibmPlexMono": "IBM Plex Mono",
-  "font.option.cascadiaCode": "Cascadia Code",
-  "font.option.firaCode": "Fira Code",
-  "font.option.hack": "Hack",
-  "font.option.inconsolata": "Inconsolata",
-  "font.option.intelOneMono": "Intel One Mono",
-  "font.option.iosevka": "Iosevka",
-  "font.option.jetbrainsMono": "JetBrains Mono",
-  "font.option.mesloLgs": "Meslo LGS",
-  "font.option.robotoMono": "Roboto Mono",
-  "font.option.sourceCodePro": "Source Code Pro",
-  "font.option.ubuntuMono": "Ubuntu Mono",
-  "font.option.geistMono": "Geist Mono",
   "sound.option.none": "ไม่มี",
   "sound.option.alert01": "เสียงเตือน 01",
   "sound.option.alert02": "เสียงเตือน 02",
@@ -796,8 +785,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "โหลดทักษะตามชื่อ",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา",
-  "settings.permissions.tool.todoread.title": "อ่านรายการงาน",
-  "settings.permissions.tool.todoread.description": "อ่านรายการงาน",
   "settings.permissions.tool.todowrite.title": "เขียนรายการงาน",
   "settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน",
   "settings.permissions.tool.webfetch.title": "ดึงข้อมูลจากเว็บ",

+ 4 - 18
packages/app/src/i18n/tr.ts

@@ -643,8 +643,10 @@ export const dict = {
     "OpenCode'un sistem, açık veya koyu temayı takip etip etmeyeceğini seçin",
   "settings.general.row.theme.title": "Tema",
   "settings.general.row.theme.description": "OpenCode'un temasını özelleştirin.",
-  "settings.general.row.font.title": "Yazı Tipi",
-  "settings.general.row.font.description": "Kod bloklarında kullanılan monospace yazı tipini özelleştirin",
+  "settings.general.row.font.title": "Kod Yazı Tipi",
+  "settings.general.row.font.description": "Kod bloklarında ve terminallerde kullanılan yazı tipini özelleştirin",
+  "settings.general.row.uiFont.title": "Arayüz Yazı Tipi",
+  "settings.general.row.uiFont.description": "Arayüz genelinde kullanılan yazı tipini özelleştirin",
   "settings.general.row.followup.title": "Takip davranışı",
   "settings.general.row.followup.description":
     "Takip komutlarının hemen yönlendirilmesini mi yoksa sırada beklemesini mi istediğinizi seçin",
@@ -677,20 +679,6 @@ export const dict = {
   "settings.updates.toast.latest.title": "Güncelsiniz",
   "settings.updates.toast.latest.description": "OpenCode'un en son sürümünü kullanıyorsunuz.",
 
-  "font.option.ibmPlexMono": "IBM Plex Mono",
-  "font.option.cascadiaCode": "Cascadia Code",
-  "font.option.firaCode": "Fira Code",
-  "font.option.hack": "Hack",
-  "font.option.inconsolata": "Inconsolata",
-  "font.option.intelOneMono": "Intel One Mono",
-  "font.option.iosevka": "Iosevka",
-  "font.option.jetbrainsMono": "JetBrains Mono",
-  "font.option.mesloLgs": "Meslo LGS",
-  "font.option.robotoMono": "Roboto Mono",
-  "font.option.sourceCodePro": "Source Code Pro",
-  "font.option.ubuntuMono": "Ubuntu Mono",
-  "font.option.geistMono": "Geist Mono",
-
   "sound.option.none": "Yok",
   "sound.option.alert01": "Uyarı 01",
   "sound.option.alert02": "Uyarı 02",
@@ -816,8 +804,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "Ada göre bir beceri yükle",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "Dil sunucusu sorguları çalıştır",
-  "settings.permissions.tool.todoread.title": "Görev Oku",
-  "settings.permissions.tool.todoread.description": "Görev listesini oku",
   "settings.permissions.tool.todowrite.title": "Görev Yaz",
   "settings.permissions.tool.todowrite.description": "Görev listesini güncelle",
   "settings.permissions.tool.webfetch.title": "Web Getir",

+ 4 - 18
packages/app/src/i18n/zh.ts

@@ -630,8 +630,10 @@ export const dict = {
   "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.font.title": "代码字体",
+  "settings.general.row.font.description": "自定义代码块和终端使用的字体",
+  "settings.general.row.uiFont.title": "界面字体",
+  "settings.general.row.uiFont.description": "自定义整个界面使用的字体",
   "settings.general.row.followup.title": "跟进消息行为",
   "settings.general.row.followup.description": "选择跟进提示是立即引导还是在队列中等待",
   "settings.general.row.followup.option.queue": "排队",
@@ -657,20 +659,6 @@ export const dict = {
   "settings.updates.toast.latest.title": "已是最新版本",
   "settings.updates.toast.latest.description": "你正在使用最新版本的 OpenCode。",
 
-  "font.option.ibmPlexMono": "IBM Plex Mono",
-  "font.option.cascadiaCode": "Cascadia Code",
-  "font.option.firaCode": "Fira Code",
-  "font.option.hack": "Hack",
-  "font.option.inconsolata": "Inconsolata",
-  "font.option.intelOneMono": "Intel One Mono",
-  "font.option.iosevka": "Iosevka",
-  "font.option.jetbrainsMono": "JetBrains Mono",
-  "font.option.mesloLgs": "Meslo LGS",
-  "font.option.robotoMono": "Roboto Mono",
-  "font.option.sourceCodePro": "Source Code Pro",
-  "font.option.ubuntuMono": "Ubuntu Mono",
-  "font.option.geistMono": "Geist Mono",
-
   "sound.option.none": "无",
   "sound.option.alert01": "警报 01",
   "sound.option.alert02": "警报 02",
@@ -795,8 +783,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "按名称加载技能",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "运行语言服务器查询",
-  "settings.permissions.tool.todoread.title": "读取待办",
-  "settings.permissions.tool.todoread.description": "读取待办列表",
   "settings.permissions.tool.todowrite.title": "更新待办",
   "settings.permissions.tool.todowrite.description": "更新待办列表",
   "settings.permissions.tool.webfetch.title": "网页获取",

+ 4 - 17
packages/app/src/i18n/zht.ts

@@ -625,8 +625,10 @@ export const dict = {
   "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.font.title": "程式碼字型",
+  "settings.general.row.font.description": "自訂程式碼區塊和終端機使用的字型",
+  "settings.general.row.uiFont.title": "介面字型",
+  "settings.general.row.uiFont.description": "自訂整個介面使用的字型",
   "settings.general.row.followup.title": "後續追問行為",
   "settings.general.row.followup.description": "選擇後續追問提示是立即引導還是進入佇列等待",
   "settings.general.row.followup.option.queue": "佇列",
@@ -654,19 +656,6 @@ export const dict = {
   "settings.updates.toast.latest.title": "已是最新版本",
   "settings.updates.toast.latest.description": "你正在使用最新版本的 OpenCode。",
 
-  "font.option.ibmPlexMono": "IBM Plex Mono",
-  "font.option.cascadiaCode": "Cascadia Code",
-  "font.option.firaCode": "Fira Code",
-  "font.option.hack": "Hack",
-  "font.option.inconsolata": "Inconsolata",
-  "font.option.intelOneMono": "Intel One Mono",
-  "font.option.iosevka": "Iosevka",
-  "font.option.jetbrainsMono": "JetBrains Mono",
-  "font.option.mesloLgs": "Meslo LGS",
-  "font.option.robotoMono": "Roboto Mono",
-  "font.option.sourceCodePro": "Source Code Pro",
-  "font.option.ubuntuMono": "Ubuntu Mono",
-  "font.option.geistMono": "Geist Mono",
   "sound.option.none": "無",
   "sound.option.alert01": "警報 01",
   "sound.option.alert02": "警報 02",
@@ -790,8 +779,6 @@ export const dict = {
   "settings.permissions.tool.skill.description": "按名稱載入技能",
   "settings.permissions.tool.lsp.title": "LSP",
   "settings.permissions.tool.lsp.description": "執行語言伺服器查詢",
-  "settings.permissions.tool.todoread.title": "讀取待辦",
-  "settings.permissions.tool.todoread.description": "讀取待辦清單",
   "settings.permissions.tool.todowrite.title": "更新待辦",
   "settings.permissions.tool.todowrite.description": "更新待辦清單",
   "settings.permissions.tool.webfetch.title": "Web Fetch",

+ 1 - 0
packages/app/src/index.ts

@@ -1,6 +1,7 @@
 export { AppBaseProviders, AppInterface } from "./app"
 export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
 export { useCommand } from "./context/command"
+export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
 export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
 export { ServerConnection } from "./context/server"
 export { handleNotificationClick } from "./utils/notification-click"

+ 36 - 41
packages/app/src/pages/directory-layout.tsx

@@ -2,8 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context"
 import { showToast } from "@opencode-ai/ui/toast"
 import { base64Encode } from "@opencode-ai/util/encode"
 import { useLocation, useNavigate, useParams } from "@solidjs/router"
-import { createMemo, createResource, type ParentProps, Show } from "solid-js"
-import { useGlobalSDK } from "@/context/global-sdk"
+import { createEffect, createMemo, type ParentProps, Show } from "solid-js"
 import { useLanguage } from "@/context/language"
 import { LocalProvider } from "@/context/local"
 import { SDKProvider } from "@/context/sdk"
@@ -11,10 +10,25 @@ import { SyncProvider, useSync } from "@/context/sync"
 import { decode64 } from "@/utils/base64"
 
 function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
+  const location = useLocation()
   const navigate = useNavigate()
+  const params = useParams()
   const sync = useSync()
   const slug = createMemo(() => base64Encode(props.directory))
 
+  createEffect(() => {
+    const next = sync.data.path.directory
+    if (!next || next === props.directory) return
+    const path = location.pathname.slice(slug().length + 1)
+    navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
+  })
+
+  createEffect(() => {
+    const id = params.id
+    if (!id) return
+    void sync.session.sync(id)
+  })
+
   return (
     <DataProvider
       data={sync.data}
@@ -29,50 +43,31 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
 
 export default function Layout(props: ParentProps) {
   const params = useParams()
-  const location = useLocation()
   const language = useLanguage()
-  const globalSDK = useGlobalSDK()
   const navigate = useNavigate()
   let invalid = ""
 
-  const [resolved] = createResource(
-    () => {
-      if (params.dir) return [location.pathname, params.dir] as const
-    },
-    async ([pathname, b64Dir]) => {
-      const directory = decode64(b64Dir)
-
-      if (!directory) {
-        if (invalid === params.dir) return
-        invalid = b64Dir
-        showToast({
-          variant: "error",
-          title: language.t("common.requestFailed"),
-          description: language.t("directory.error.invalidUrl"),
-        })
-        navigate("/", { replace: true })
-        return
-      }
+  const resolved = createMemo(() => {
+    if (!params.dir) return ""
+    return decode64(params.dir) ?? ""
+  })
 
-      return await globalSDK
-        .createClient({
-          directory,
-          throwOnError: true,
-        })
-        .path.get()
-        .then((x) => {
-          const next = x.data?.directory ?? directory
-          invalid = ""
-          if (next === directory) return next
-          const path = pathname.slice(b64Dir.length + 1)
-          navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
-        })
-        .catch(() => {
-          invalid = ""
-          return directory
-        })
-    },
-  )
+  createEffect(() => {
+    const dir = params.dir
+    if (!dir) return
+    if (resolved()) {
+      invalid = ""
+      return
+    }
+    if (invalid === dir) return
+    invalid = dir
+    showToast({
+      variant: "error",
+      title: language.t("common.requestFailed"),
+      description: language.t("directory.error.invalidUrl"),
+    })
+    navigate("/", { replace: true })
+  })
 
   return (
     <Show when={resolved()} keyed>

+ 8 - 0
packages/app/src/pages/home.tsx

@@ -113,6 +113,14 @@ export default function Home() {
             </ul>
           </div>
         </Match>
+        <Match when={!sync.ready}>
+          <div class="mt-30 mx-auto flex flex-col items-center gap-3">
+            <div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
+            <Button class="px-3" onClick={chooseProject}>
+              {language.t("command.project.open")}
+            </Button>
+          </div>
+        </Match>
         <Match when={true}>
           <div class="mt-30 mx-auto flex flex-col items-center gap-3">
             <Icon name="folder-add-left" size="large" />

+ 129 - 47
packages/app/src/pages/layout.tsx

@@ -49,21 +49,16 @@ import { useNotification } from "@/context/notification"
 import { usePermission } from "@/context/permission"
 import { Binary } from "@opencode-ai/util/binary"
 import { retry } from "@opencode-ai/util/retry"
-import { playSound, soundSrc } from "@/utils/sound"
+import { playSoundById } from "@/utils/sound"
 import { createAim } from "@/utils/aim"
 import { setNavigate } from "@/utils/notification-click"
 import { Worktree as WorktreeState } from "@/utils/worktree"
 import { setSessionHandoff } from "@/pages/session/handoff"
 
 import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
-import { DialogSelectProvider } from "@/components/dialog-select-provider"
-import { DialogSelectServer } from "@/components/dialog-select-server"
-import { DialogSettings } from "@/components/dialog-settings"
+import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
 import { useCommand, type CommandOption } from "@/context/command"
 import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
-import { DialogSelectDirectory } from "@/components/dialog-select-directory"
-import { DialogEditProject } from "@/components/dialog-edit-project"
 import { DebugBar } from "@/components/debug-bar"
 import { Titlebar } from "@/components/titlebar"
 import { useServer } from "@/context/server"
@@ -110,6 +105,8 @@ export default function Layout(props: ParentProps) {
   const pageReady = createMemo(() => ready())
 
   let scrollContainerRef: HTMLDivElement | undefined
+  let dialogRun = 0
+  let dialogDead = false
 
   const params = useParams()
   const globalSDK = useGlobalSDK()
@@ -139,7 +136,7 @@ export default function Layout(props: ParentProps) {
       dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
     }
   })
-  const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
+  const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
   const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
   const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
     system: "theme.scheme.system",
@@ -201,6 +198,8 @@ export default function Layout(props: ParentProps) {
   })
 
   onCleanup(() => {
+    dialogDead = true
+    dialogRun += 1
     if (navLeave.current !== undefined) clearTimeout(navLeave.current)
     clearTimeout(sortNowTimeout)
     if (sortNowInterval) clearInterval(sortNowInterval)
@@ -211,13 +210,22 @@ export default function Layout(props: ParentProps) {
 
   onMount(() => {
     const stop = () => setState("sizing", false)
+    const blur = () => reset()
+    const hide = () => {
+      if (document.visibilityState !== "hidden") return
+      reset()
+    }
     window.addEventListener("pointerup", stop)
     window.addEventListener("pointercancel", stop)
     window.addEventListener("blur", stop)
+    window.addEventListener("blur", blur)
+    document.addEventListener("visibilitychange", hide)
     onCleanup(() => {
       window.removeEventListener("pointerup", stop)
       window.removeEventListener("pointercancel", stop)
       window.removeEventListener("blur", stop)
+      window.removeEventListener("blur", blur)
+      document.removeEventListener("visibilitychange", hide)
     })
   })
 
@@ -237,6 +245,12 @@ export default function Layout(props: ParentProps) {
     navLeave.current = undefined
   }
 
+  const reset = () => {
+    disarm()
+    setState("hoverSession", undefined)
+    setHoverProject(undefined)
+  }
+
   const arm = () => {
     if (layout.sidebar.opened()) return
     if (state.hoverProject === undefined) return
@@ -305,8 +319,7 @@ export default function Layout(props: ParentProps) {
 
   const clearSidebarHoverState = () => {
     if (layout.sidebar.opened()) return
-    setState("hoverSession", undefined)
-    setHoverProject(undefined)
+    reset()
   }
 
   const navigateWithSidebarReset = (href: string) => {
@@ -322,10 +335,9 @@ export default function Layout(props: ParentProps) {
     const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
     const nextThemeId = ids[nextIndex]
     theme.setTheme(nextThemeId)
-    const nextTheme = theme.themes()[nextThemeId]
     showToast({
       title: language.t("toast.theme.title"),
-      description: nextTheme?.name ?? nextThemeId,
+      description: theme.name(nextThemeId),
     })
   }
 
@@ -480,7 +492,7 @@ export default function Layout(props: ParentProps) {
 
         if (e.details.type === "permission.asked") {
           if (settings.sounds.permissionsEnabled()) {
-            playSound(soundSrc(settings.sounds.permissions()))
+            void playSoundById(settings.sounds.permissions())
           }
           if (settings.notifications.permissions()) {
             void platform.notify(title, description, href)
@@ -936,6 +948,28 @@ export default function Layout(props: ParentProps) {
     navigateToSession(session)
   }
 
+  function navigateProjectByOffset(offset: number) {
+    const projects = layout.projects.list()
+    if (projects.length === 0) return
+
+    const current = currentProject()?.worktree
+    const fallback = currentDir() ? projectRoot(currentDir()) : undefined
+    const active = current ?? fallback
+    const index = active ? projects.findIndex((project) => project.worktree === active) : -1
+
+    const target =
+      index === -1
+        ? offset > 0
+          ? projects[0]
+          : projects[projects.length - 1]
+        : projects[(index + offset + projects.length) % projects.length]
+    if (!target) return
+
+    // warm up child store to prevent flicker
+    globalSync.child(target.worktree)
+    openProject(target.worktree)
+  }
+
   function navigateSessionByUnseen(offset: number) {
     const sessions = currentSessions()
     if (sessions.length === 0) return
@@ -1002,6 +1036,20 @@ export default function Layout(props: ParentProps) {
         keybind: "mod+o",
         onSelect: () => chooseProject(),
       },
+      {
+        id: "project.previous",
+        title: language.t("command.project.previous"),
+        category: language.t("command.category.project"),
+        keybind: "mod+alt+arrowup",
+        onSelect: () => navigateProjectByOffset(-1),
+      },
+      {
+        id: "project.next",
+        title: language.t("command.project.next"),
+        category: language.t("command.category.project"),
+        keybind: "mod+alt+arrowdown",
+        onSelect: () => navigateProjectByOffset(1),
+      },
       {
         id: "provider.connect",
         title: language.t("command.provider.connect"),
@@ -1104,10 +1152,10 @@ export default function Layout(props: ParentProps) {
       },
     ]
 
-    for (const [id, definition] of availableThemeEntries()) {
+    for (const [id] of availableThemeEntries()) {
       commands.push({
         id: `theme.set.${id}`,
-        title: language.t("command.theme.set", { theme: definition.name ?? id }),
+        title: language.t("command.theme.set", { theme: theme.name(id) }),
         category: language.t("command.category.theme"),
         onSelect: () => theme.commitPreview(),
         onHighlight: () => {
@@ -1158,15 +1206,27 @@ export default function Layout(props: ParentProps) {
   })
 
   function connectProvider() {
-    dialog.show(() => <DialogSelectProvider />)
+    const run = ++dialogRun
+    void import("@/components/dialog-select-provider").then((x) => {
+      if (dialogDead || dialogRun !== run) return
+      dialog.show(() => <x.DialogSelectProvider />)
+    })
   }
 
   function openServer() {
-    dialog.show(() => <DialogSelectServer />)
+    const run = ++dialogRun
+    void import("@/components/dialog-select-server").then((x) => {
+      if (dialogDead || dialogRun !== run) return
+      dialog.show(() => <x.DialogSelectServer />)
+    })
   }
 
   function openSettings() {
-    dialog.show(() => <DialogSettings />)
+    const run = ++dialogRun
+    void import("@/components/dialog-settings").then((x) => {
+      if (dialogDead || dialogRun !== run) return
+      dialog.show(() => <x.DialogSettings />)
+    })
   }
 
   function projectRoot(directory: string) {
@@ -1393,7 +1453,13 @@ export default function Layout(props: ParentProps) {
     layout.sidebar.toggleWorkspaces(project.worktree)
   }
 
-  const showEditProjectDialog = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />)
+  const showEditProjectDialog = (project: LocalProject) => {
+    const run = ++dialogRun
+    void import("@/components/dialog-edit-project").then((x) => {
+      if (dialogDead || dialogRun !== run) return
+      dialog.show(() => <x.DialogEditProject project={project} />)
+    })
+  }
 
   async function chooseProject() {
     function resolve(result: string | string[] | null) {
@@ -1414,10 +1480,14 @@ export default function Layout(props: ParentProps) {
       })
       resolve(result)
     } else {
-      dialog.show(
-        () => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
-        () => resolve(null),
-      )
+      const run = ++dialogRun
+      void import("@/components/dialog-select-directory").then((x) => {
+        if (dialogDead || dialogRun !== run) return
+        dialog.show(
+          () => <x.DialogSelectDirectory multiple={true} onSelect={resolve} />,
+          () => resolve(null),
+        )
+      })
     }
   }
 
@@ -1750,6 +1820,9 @@ export default function Layout(props: ParentProps) {
     document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
   })
 
+  const side = createMemo(() => Math.max(layout.sidebar.width(), 244))
+  const panel = createMemo(() => Math.max(side() - 64, 0))
+
   const loadedSessionDirs = new Set<string>()
 
   createEffect(
@@ -1941,6 +2014,10 @@ export default function Layout(props: ParentProps) {
     onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
     onProjectMouseLeave: (worktree) => aim.leave(worktree),
     onProjectFocus: (worktree) => aim.activate(worktree),
+    onHoverOpenChanged: (worktree, hoverOpen) => {
+      if (!hoverOpen && state.hoverProject && state.hoverProject !== worktree) return
+      setState("hoverProject", hoverOpen ? worktree : undefined)
+    },
     navigateToProject,
     openSidebar: () => layout.sidebar.open(),
     closeProject,
@@ -2022,7 +2099,7 @@ export default function Layout(props: ParentProps) {
           "max-w-full overflow-hidden": panelProps.mobile,
         }}
         style={{
-          width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
+          width: panelProps.mobile ? undefined : `${panel()}px`,
         }}
       >
         <Show
@@ -2085,9 +2162,11 @@ export default function Layout(props: ParentProps) {
                     variant="ghost"
                     data-action="project-menu"
                     data-project={slug()}
-                    class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
+                    class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
                     classList={{
-                      "opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
+                      "opacity-100": panelProps.mobile || merged(),
+                      "opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
+                        !panelProps.mobile && !merged(),
                     }}
                     aria-label={language.t("common.moreOptions")}
                   />
@@ -2312,7 +2391,7 @@ export default function Layout(props: ParentProps) {
                 "absolute inset-y-0 left-0": true,
                 "z-10": true,
               }}
-              style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
+              style={{ width: `${side()}px` }}
               ref={(el) => {
                 setState("nav", el)
               }}
@@ -2327,26 +2406,29 @@ export default function Layout(props: ParentProps) {
               }}
             >
               <div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
-              <Show when={layout.sidebar.opened()}>
-                <div onPointerDown={() => setState("sizing", true)}>
-                  <ResizeHandle
-                    direction="horizontal"
-                    size={layout.sidebar.width()}
-                    min={244}
-                    max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
-                    collapseThreshold={244}
-                    onResize={(w) => {
-                      setState("sizing", true)
-                      if (sizet !== undefined) clearTimeout(sizet)
-                      sizet = window.setTimeout(() => setState("sizing", false), 120)
-                      layout.sidebar.resize(w)
-                    }}
-                    onCollapse={layout.sidebar.close}
-                  />
-                </div>
-              </Show>
             </nav>
 
+            <Show when={layout.sidebar.opened()}>
+              <div
+                class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
+                style={{ left: `${side()}px` }}
+                onPointerDown={() => setState("sizing", true)}
+              >
+                <ResizeHandle
+                  direction="horizontal"
+                  size={layout.sidebar.width()}
+                  min={244}
+                  max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
+                  onResize={(w) => {
+                    setState("sizing", true)
+                    if (sizet !== undefined) clearTimeout(sizet)
+                    sizet = window.setTimeout(() => setState("sizing", false), 120)
+                    layout.sidebar.resize(w)
+                  }}
+                />
+              </div>
+            </Show>
+
             <div
               class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
               style={{ left: "calc(4rem + 12px)" }}
@@ -2386,7 +2468,7 @@ export default function Layout(props: ParentProps) {
                   !state.sizing,
               }}
               style={{
-                "--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
+                "--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
               }}
             >
               <main
@@ -2433,7 +2515,7 @@ export default function Layout(props: ParentProps) {
                 "duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
                 "duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
               }}
-              style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
+              style={{ left: `calc(4rem + ${panel()}px)` }}
             >
               <div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
             </div>

+ 129 - 113
packages/app/src/pages/layout/sidebar-items.tsx

@@ -104,7 +104,7 @@ const SessionRow = (props: {
 }): JSX.Element => (
   <A
     href={`/${props.slug}/session/${props.session.id}`}
-    class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
+    class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
     onPointerDown={props.warmPress}
     onPointerEnter={props.warmHover}
     onPointerLeave={props.cancelHoverPrefetch}
@@ -115,30 +115,26 @@ const SessionRow = (props: {
       props.clearHoverProjectSoon()
     }}
   >
-    <div class="flex items-center gap-1 w-full">
-      <div
-        class="shrink-0 size-6 flex items-center justify-center"
-        style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
-      >
-        <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
-          <Match when={props.isWorking()}>
-            <Spinner class="size-[15px]" />
-          </Match>
-          <Match when={props.hasPermissions()}>
-            <div class="size-1.5 rounded-full bg-surface-warning-strong" />
-          </Match>
-          <Match when={props.hasError()}>
-            <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
-          </Match>
-          <Match when={props.unseenCount() > 0}>
-            <div class="size-1.5 rounded-full bg-text-interactive-base" />
-          </Match>
-        </Switch>
-      </div>
-      <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
-        {props.session.title}
-      </span>
+    <div
+      class="shrink-0 size-6 flex items-center justify-center"
+      style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
+    >
+      <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
+        <Match when={props.isWorking()}>
+          <Spinner class="size-[15px]" />
+        </Match>
+        <Match when={props.hasPermissions()}>
+          <div class="size-1.5 rounded-full bg-surface-warning-strong" />
+        </Match>
+        <Match when={props.hasError()}>
+          <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
+        </Match>
+        <Match when={props.unseenCount() > 0}>
+          <div class="size-1.5 rounded-full bg-text-interactive-base" />
+        </Match>
+      </Switch>
     </div>
+    <span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
   </A>
 )
 
@@ -157,34 +153,49 @@ const SessionHoverPreview = (props: {
   messageLabel: (message: Message) => string | undefined
   onMessageSelect: (message: Message) => void
   trigger: JSX.Element
-}): JSX.Element => (
-  <HoverCard
-    openDelay={1000}
-    closeDelay={props.sidebarHovering() ? 600 : 0}
-    placement="right-start"
-    gutter={16}
-    shift={-2}
-    trigger={props.trigger}
-    open={props.hoverSession() === props.session.id}
-    onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
-  >
-    <Show
-      when={props.hoverReady()}
-      fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
+}): JSX.Element => {
+  let ref: HTMLDivElement | undefined
+
+  return (
+    <HoverCard
+      openDelay={1000}
+      closeDelay={props.sidebarHovering() ? 600 : 0}
+      placement="right-start"
+      gutter={16}
+      shift={-2}
+      trigger={
+        <div ref={ref} class="min-w-0 w-full">
+          {props.trigger}
+        </div>
+      }
+      open={props.hoverSession() === props.session.id}
+      onOpenChange={(open) => {
+        if (!open) {
+          props.setHoverSession(undefined)
+          return
+        }
+        if (!ref?.matches(":hover")) return
+        props.setHoverSession(props.session.id)
+      }}
     >
-      <div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
-        <MessageNav
-          messages={props.hoverMessages() ?? []}
-          current={undefined}
-          getLabel={props.messageLabel}
-          onMessageSelect={props.onMessageSelect}
-          size="normal"
-          class="w-60"
-        />
-      </div>
-    </Show>
-  </HoverCard>
-)
+      <Show
+        when={props.hoverReady()}
+        fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
+      >
+        <div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
+          <MessageNav
+            messages={props.hoverMessages() ?? []}
+            current={undefined}
+            getLabel={props.messageLabel}
+            onMessageSelect={props.onMessageSelect}
+            size="normal"
+            class="w-60"
+          />
+        </div>
+      </Show>
+    </HoverCard>
+  )
+}
 
 export const SessionItem = (props: SessionItemProps): JSX.Element => {
   const params = useParams()
@@ -298,62 +309,71 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
   return (
     <div
       data-session-id={props.session.id}
-      class="group/session relative w-full rounded-md cursor-default pl-2 pr-3 transition-colors
+      class="group/session relative w-full min-w-0 rounded-md cursor-default pl-2 pr-3 transition-colors
              hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
     >
-      <Show
-        when={hoverEnabled()}
-        fallback={
-          <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
-            {item}
-          </Tooltip>
-        }
-      >
-        <SessionHoverPreview
-          mobile={props.mobile}
-          nav={props.nav}
-          hoverSession={props.hoverSession}
-          session={props.session}
-          sidebarHovering={props.sidebarHovering}
-          hoverReady={hoverReady}
-          hoverMessages={hoverMessages}
-          language={language}
-          isActive={isActive}
-          slug={props.slug}
-          setHoverSession={props.setHoverSession}
-          messageLabel={messageLabel}
-          onMessageSelect={(message) => {
-            if (!isActive())
-              layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
+      <div class="flex min-w-0 items-center gap-1">
+        <div class="min-w-0 flex-1">
+          <Show
+            when={hoverEnabled()}
+            fallback={
+              <Tooltip
+                placement={props.mobile ? "bottom" : "right"}
+                value={props.session.title}
+                gutter={10}
+                class="min-w-0 w-full"
+              >
+                {item}
+              </Tooltip>
+            }
+          >
+            <SessionHoverPreview
+              mobile={props.mobile}
+              nav={props.nav}
+              hoverSession={props.hoverSession}
+              session={props.session}
+              sidebarHovering={props.sidebarHovering}
+              hoverReady={hoverReady}
+              hoverMessages={hoverMessages}
+              language={language}
+              isActive={isActive}
+              slug={props.slug}
+              setHoverSession={props.setHoverSession}
+              messageLabel={messageLabel}
+              onMessageSelect={(message) => {
+                if (!isActive())
+                  layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
 
-            navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
-          }}
-          trigger={item}
-        />
-      </Show>
+                navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
+              }}
+              trigger={item}
+            />
+          </Show>
+        </div>
 
-      <div
-        class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
-        classList={{
-          "opacity-100 pointer-events-auto": !!props.mobile,
-          "opacity-0 pointer-events-none": !props.mobile,
-          "group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
-          "group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
-        }}
-      >
-        <Tooltip value={language.t("common.archive")} placement="top">
-          <IconButton
-            icon="archive"
-            variant="ghost"
-            class="size-6 rounded-md"
-            aria-label={language.t("common.archive")}
-            onClick={(event) => {
-              event.preventDefault()
-              event.stopPropagation()
-              void props.archiveSession(props.session)
-            }}
-          />
-        </Tooltip>
+        <div
+          class="shrink-0 overflow-hidden transition-[width,opacity]"
+          classList={{
+            "w-6 opacity-100 pointer-events-auto": !!props.mobile,
+            "w-0 opacity-0 pointer-events-none": !props.mobile,
+            "group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
+            "group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
+          }}
+        >
+          <Tooltip value={language.t("common.archive")} placement="top">
+            <IconButton
+              icon="archive"
+              variant="ghost"
+              class="size-6 rounded-md"
+              aria-label={language.t("common.archive")}
+              onClick={(event) => {
+                event.preventDefault()
+                event.stopPropagation()
+                void props.archiveSession(props.session)
+              }}
+            />
+          </Tooltip>
+        </div>
       </div>
     </div>
   )
@@ -375,30 +395,26 @@ export const NewSessionItem = (props: {
     <A
       href={`/${props.slug}/session`}
       end
-      class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
+      class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
       onClick={() => {
         props.setHoverSession(undefined)
         if (layout.sidebar.opened()) return
         props.clearHoverProjectSoon()
       }}
     >
-      <div class="flex items-center gap-1 w-full">
-        <div class="shrink-0 size-6 flex items-center justify-center">
-          <Icon name="new-session" size="small" class="text-icon-weak" />
-        </div>
-        <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
-          {label}
-        </span>
+      <div class="shrink-0 size-6 flex items-center justify-center">
+        <Icon name="new-session" size="small" class="text-icon-weak" />
       </div>
+      <span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{label}</span>
     </A>
   )
 
   return (
-    <div class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
+    <div class="group/session relative w-full min-w-0 rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
       <Show
         when={!tooltip()}
         fallback={
-          <Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10}>
+          <Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10} class="min-w-0 w-full">
             {item}
           </Tooltip>
         }

+ 15 - 23
packages/app/src/pages/layout/sidebar-project.tsx

@@ -23,6 +23,7 @@ export type ProjectSidebarContext = {
   onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
   onProjectMouseLeave: (worktree: string) => void
   onProjectFocus: (worktree: string) => void
+  onHoverOpenChanged: (worktree: string, hovered: boolean) => void
   navigateToProject: (directory: string) => void
   openSidebar: () => void
   closeProject: (directory: string) => void
@@ -109,8 +110,14 @@ const ProjectTile = (props: {
           "bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
         }}
         onPointerDown={(event) => {
+          if (event.button === 0 && !event.ctrlKey) {
+            props.setOpen(false)
+            props.setSuppressHover(true)
+            return
+          }
           if (!props.overlay()) return
           if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
+          props.setOpen(false)
           props.setSuppressHover(true)
           event.preventDefault()
         }}
@@ -130,12 +137,11 @@ const ProjectTile = (props: {
           props.onProjectFocus(props.project.worktree)
         }}
         onClick={() => {
+          props.setOpen(false)
           if (props.selected()) {
-            props.setSuppressHover(true)
             layout.sidebar.toggle()
             return
           }
-          props.setSuppressHover(false)
           props.navigateToProject(props.project.worktree)
         }}
         onBlur={() => props.setOpen(false)}
@@ -192,7 +198,6 @@ const ProjectPreviewPanel = (props: {
   projectChildren: Accessor<Map<string, string[]>>
   workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
   workspaceChildren: (directory: string) => Map<string, string[]>
-  setOpen: (value: boolean) => void
   ctx: ProjectSidebarContext
   language: ReturnType<typeof useLanguage>
 }): JSX.Element => (
@@ -259,7 +264,7 @@ const ProjectPreviewPanel = (props: {
         class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
         onClick={() => {
           props.ctx.openSidebar()
-          props.setOpen(false)
+          props.ctx.onHoverOpenChanged(props.project.worktree, false)
           if (props.selected()) return
           props.ctx.navigateToProject(props.project.worktree)
         }}
@@ -284,28 +289,16 @@ export const SortableProject = (props: {
   const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
   const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
   const [state, setState] = createStore({
-    open: false,
     menu: false,
     suppressHover: false,
   })
 
+  const isHoverProject = () => props.ctx.hoverProject() === props.project.worktree
   const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
   const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
-  const active = createMemo(
-    () => state.menu || (preview() ? state.open : overlay() && props.ctx.hoverProject() === props.project.worktree),
-  )
+  const active = createMemo(() => state.menu || (preview() ? isHoverProject() : overlay() && isHoverProject()))
 
-  createEffect(() => {
-    if (preview()) return
-    if (!state.open) return
-    setState("open", false)
-  })
-
-  createEffect(() => {
-    if (!selected()) return
-    if (!state.open) return
-    setState("open", false)
-  })
+  const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
 
   const label = (directory: string) => {
     const [data] = globalSync.child(directory, { bootstrap: false })
@@ -346,7 +339,7 @@ export const SortableProject = (props: {
       workspacesEnabled={props.ctx.workspacesEnabled}
       closeProject={props.ctx.closeProject}
       setMenu={(value) => setState("menu", value)}
-      setOpen={(value) => setState("open", value)}
+      setOpen={(value) => props.ctx.onHoverOpenChanged(props.project.worktree, value)}
       setSuppressHover={(value) => setState("suppressHover", value)}
       language={language}
     />
@@ -357,7 +350,7 @@ export const SortableProject = (props: {
     <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
       <Show when={preview() && !selected()} fallback={tile()}>
         <HoverCard
-          open={!state.suppressHover && state.open && !state.menu}
+          open={!state.suppressHover && hoverOpen() && !state.menu}
           openDelay={0}
           closeDelay={0}
           placement="right-start"
@@ -366,7 +359,7 @@ export const SortableProject = (props: {
           onOpenChange={(value) => {
             if (state.menu) return
             if (value && state.suppressHover) return
-            setState("open", value)
+            props.ctx.onHoverOpenChanged(props.project.worktree, value)
             if (value) props.ctx.setHoverSession(undefined)
           }}
         >
@@ -381,7 +374,6 @@ export const SortableProject = (props: {
             projectChildren={projectChildren}
             workspaceSessions={workspaceSessions}
             workspaceChildren={workspaceChildren}
-            setOpen={(value) => setState("open", value)}
             ctx={props.ctx}
             language={language}
           />

+ 149 - 151
packages/app/src/pages/session.tsx

@@ -1,5 +1,6 @@
 import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useMutation } from "@tanstack/solid-query"
 import {
   batch,
   onCleanup,
@@ -25,8 +26,8 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
 import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
 import { Button } from "@opencode-ai/ui/button"
 import { showToast } from "@opencode-ai/ui/toast"
-import { base64Encode, checksum } from "@opencode-ai/util/encode"
-import { useNavigate, useSearchParams } from "@solidjs/router"
+import { checksum } from "@opencode-ai/util/encode"
+import { useSearchParams } from "@solidjs/router"
 import { NewSessionView, SessionHeader } from "@/components/session"
 import { useComments } from "@/context/comments"
 import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
@@ -40,7 +41,13 @@ import { useSync } from "@/context/sync"
 import { useTerminal } from "@/context/terminal"
 import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
 import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
-import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers"
+import {
+  createOpenReviewFile,
+  createSessionTabs,
+  createSizing,
+  focusTerminalById,
+  shouldFocusTerminalOnKeyDown,
+} from "@/pages/session/helpers"
 import { MessageTimeline } from "@/pages/session/message-timeline"
 import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
 import { useSessionLayout } from "@/pages/session/session-layout"
@@ -239,14 +246,19 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
 
     if (added <= 0) return
     if (growth <= 0) return
+
+    if (opts?.prefetch) {
+      const current = turnStart()
+      preserveScroll(() => setTurnStart(current + growth))
+      return
+    }
+
     if (turnStart() !== start) return
 
-    const reveal = !opts?.prefetch
     const currentRendered = renderedUserMessages().length
     const base = Math.max(beforeRendered, currentRendered)
-    const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
-    const nextStart = Math.max(0, afterVisible - target)
-    preserveScroll(() => setTurnStart(nextStart))
+    const target = Math.min(afterVisible, base + turnBatch)
+    preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target)))
   }
 
   const onScrollerScroll = () => {
@@ -305,7 +317,6 @@ export default function Page() {
   const sync = useSync()
   const dialog = useDialog()
   const language = useLanguage()
-  const navigate = useNavigate()
   const sdk = useSDK()
   const settings = useSettings()
   const prompt = usePrompt()
@@ -327,10 +338,7 @@ export default function Page() {
   })
 
   const [ui, setUi] = createStore({
-    git: false,
     pendingMessage: undefined as string | undefined,
-    restoring: undefined as string | undefined,
-    reverting: false,
     reviewSnap: false,
     scrollGesture: 0,
     scroll: {
@@ -506,7 +514,6 @@ export default function Page() {
 
   const [followup, setFollowup] = createStore({
     items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
-    sending: {} as Record<string, string | undefined>,
     failed: {} as Record<string, string | undefined>,
     paused: {} as Record<string, boolean | undefined>,
     edit: {} as Record<
@@ -644,25 +651,24 @@ export default function Page() {
     globalSync.set("project", [...list, next])
   }
 
-  function initGit() {
-    if (ui.git) return
-    setUi("git", true)
-    void sdk.client.project
-      .initGit()
-      .then((x) => {
-        if (!x.data) return
-        upsert(x.data)
-      })
-      .catch((err) => {
-        showToast({
-          variant: "error",
-          title: language.t("common.requestFailed"),
-          description: formatServerError(err, language.t),
-        })
-      })
-      .finally(() => {
-        setUi("git", false)
+  const gitMutation = useMutation(() => ({
+    mutationFn: () => sdk.client.project.initGit(),
+    onSuccess: (x) => {
+      if (!x.data) return
+      upsert(x.data)
+    },
+    onError: (err) => {
+      showToast({
+        variant: "error",
+        title: language.t("common.requestFailed"),
+        description: formatServerError(err, language.t),
       })
+    },
+  }))
+
+  function initGit() {
+    if (gitMutation.isPending) return
+    gitMutation.mutate()
   }
 
   let inputRef!: HTMLDivElement
@@ -705,7 +711,6 @@ export default function Page() {
             return Date.now() - info.at > SESSION_PREFETCH_TTL
           })()
       const todos = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
-
       untrack(() => {
         void sync.session.sync(id)
       })
@@ -854,7 +859,7 @@ export default function Page() {
     // Prefer the open terminal over the composer when it can take focus
     if (view().terminal.opened()) {
       const id = terminal.active()
-      if (id && focusTerminalById(id)) return
+      if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return
     }
 
     // Only treat explicit scroll keys as potential "user scroll" gestures.
@@ -961,8 +966,8 @@ export default function Page() {
               {language.t("session.review.noVcs.createGit.description")}
             </div>
           </div>
-          <Button size="large" disabled={ui.git} onClick={initGit}>
-            {ui.git
+          <Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
+            {gitMutation.isPending
               ? language.t("session.review.noVcs.createGit.actionLoading")
               : language.t("session.review.noVcs.createGit.action")}
           </Button>
@@ -1177,8 +1182,6 @@ export default function Page() {
     on(
       () => sdk.directory,
       () => {
-        void file.tree.list("")
-
         const tab = activeFileTab()
         if (!tab) return
         const path = file.pathFromTab(tab)
@@ -1379,10 +1382,40 @@ export default function Page() {
     return followup.edit[id]
   })
 
+  const followupMutation = useMutation(() => ({
+    mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
+      const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
+      if (!item) return
+
+      if (input.manual) setFollowup("paused", input.sessionID, undefined)
+      setFollowup("failed", input.sessionID, undefined)
+
+      const ok = await sendFollowupDraft({
+        client: sdk.client,
+        sync,
+        globalSync,
+        draft: item,
+        optimisticBusy: item.sessionDirectory === sdk.directory,
+      }).catch((err) => {
+        setFollowup("failed", input.sessionID, input.id)
+        fail(err)
+        return false
+      })
+      if (!ok) return
+
+      setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
+      if (input.manual) resumeScroll()
+    },
+  }))
+
+  const followupBusy = (sessionID: string) =>
+    followupMutation.isPending && followupMutation.variables?.sessionID === sessionID
+
   const sendingFollowup = createMemo(() => {
     const id = params.id
     if (!id) return
-    return followup.sending[id]
+    if (!followupBusy(id)) return
+    return followupMutation.variables?.id
   })
 
   const queueEnabled = createMemo(() => {
@@ -1422,37 +1455,15 @@ export default function Page() {
   const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
     const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
     if (!item) return Promise.resolve()
-    if (followup.sending[sessionID]) return Promise.resolve()
-
-    if (opts?.manual) setFollowup("paused", sessionID, undefined)
-    setFollowup("sending", sessionID, id)
-    setFollowup("failed", sessionID, undefined)
-
-    return sendFollowupDraft({
-      client: sdk.client,
-      sync,
-      globalSync,
-      draft: item,
-      optimisticBusy: item.sessionDirectory === sdk.directory,
-    })
-      .then((ok) => {
-        if (ok === false) return
-        setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id))
-        if (opts?.manual) resumeScroll()
-      })
-      .catch((err) => {
-        setFollowup("failed", sessionID, id)
-        fail(err)
-      })
-      .finally(() => {
-        setFollowup("sending", sessionID, (value) => (value === id ? undefined : value))
-      })
+    if (followupBusy(sessionID)) return Promise.resolve()
+
+    return followupMutation.mutateAsync({ sessionID, id, manual: opts?.manual })
   }
 
   const editFollowup = (id: string) => {
     const sessionID = params.id
     if (!sessionID) return
-    if (followup.sending[sessionID]) return
+    if (followupBusy(sessionID)) return
 
     const item = queuedFollowups().find((entry) => entry.id === id)
     if (!item) return
@@ -1475,98 +1486,82 @@ export default function Page() {
   const halt = (sessionID: string) =>
     busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve()
 
-  const fork = (input: { sessionID: string; messageID: string }) => {
-    const value = draft(input.messageID)
-    const dir = base64Encode(sdk.directory)
-    return sdk.client.session
-      .fork(input)
-      .then((result) => {
-        const next = result.data
-        if (!next) {
-          showToast({
-            variant: "error",
-            title: language.t("common.requestFailed"),
+  const revertMutation = useMutation(() => ({
+    mutationFn: async (input: { sessionID: string; messageID: string }) => {
+      const prev = prompt.current().slice()
+      const last = info()?.revert
+      const value = draft(input.messageID)
+      batch(() => {
+        roll(input.sessionID, { messageID: input.messageID })
+        prompt.set(value)
+      })
+      await halt(input.sessionID)
+        .then(() => sdk.client.session.revert(input))
+        .then((result) => {
+          if (result.data) merge(result.data)
+        })
+        .catch((err) => {
+          batch(() => {
+            roll(input.sessionID, last)
+            prompt.set(prev)
           })
+          fail(err)
+        })
+    },
+  }))
+
+  const restoreMutation = useMutation(() => ({
+    mutationFn: async (id: string) => {
+      const sessionID = params.id
+      if (!sessionID) return
+
+      const next = userMessages().find((item) => item.id > id)
+      const prev = prompt.current().slice()
+      const last = info()?.revert
+
+      batch(() => {
+        roll(sessionID, next ? { messageID: next.id } : undefined)
+        if (next) {
+          prompt.set(draft(next.id))
           return
         }
-        prompt.set(value, undefined, { dir, id: next.id })
-        navigate(`/${dir}/session/${next.id}`)
+        prompt.reset()
       })
-      .catch(fail)
-  }
 
-  const revert = (input: { sessionID: string; messageID: string }) => {
-    if (ui.reverting || ui.restoring) return
-    const prev = prompt.current().slice()
-    const last = info()?.revert
-    const value = draft(input.messageID)
-    batch(() => {
-      setUi("reverting", true)
-      roll(input.sessionID, { messageID: input.messageID })
-      prompt.set(value)
-    })
-    return halt(input.sessionID)
-      .then(() => sdk.client.session.revert(input))
-      .then((result) => {
-        if (result.data) merge(result.data)
-      })
-      .catch((err) => {
-        batch(() => {
-          roll(input.sessionID, last)
-          prompt.set(prev)
+      const task = !next
+        ? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
+        : halt(sessionID).then(() =>
+            sdk.client.session.revert({
+              sessionID,
+              messageID: next.id,
+            }),
+          )
+
+      await task
+        .then((result) => {
+          if (result.data) merge(result.data)
         })
-        fail(err)
-      })
-      .finally(() => {
-        setUi("reverting", false)
-      })
+        .catch((err) => {
+          batch(() => {
+            roll(sessionID, last)
+            prompt.set(prev)
+          })
+          fail(err)
+        })
+    },
+  }))
+
+  const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
+  const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
+
+  const revert = (input: { sessionID: string; messageID: string }) => {
+    if (reverting()) return
+    return revertMutation.mutateAsync(input)
   }
 
   const restore = (id: string) => {
-    const sessionID = params.id
-    if (!sessionID || ui.restoring || ui.reverting) return
-
-    const next = userMessages().find((item) => item.id > id)
-    const prev = prompt.current().slice()
-    const last = info()?.revert
-
-    batch(() => {
-      setUi("restoring", id)
-      setUi("reverting", true)
-      roll(sessionID, next ? { messageID: next.id } : undefined)
-      if (next) {
-        prompt.set(draft(next.id))
-        return
-      }
-      prompt.reset()
-    })
-
-    const task = !next
-      ? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
-      : halt(sessionID).then(() =>
-          sdk.client.session.revert({
-            sessionID,
-            messageID: next.id,
-          }),
-        )
-
-    return task
-      .then((result) => {
-        if (result.data) merge(result.data)
-      })
-      .catch((err) => {
-        batch(() => {
-          roll(sessionID, last)
-          prompt.set(prev)
-        })
-        fail(err)
-      })
-      .finally(() => {
-        batch(() => {
-          setUi("restoring", (value) => (value === id ? undefined : value))
-          setUi("reverting", false)
-        })
-      })
+    if (!params.id || reverting()) return
+    return restoreMutation.mutateAsync(id)
   }
 
   const rolled = createMemo(() => {
@@ -1577,7 +1572,7 @@ export default function Page() {
       .map((item) => ({ id: item.id, text: line(item.id) }))
   })
 
-  const actions = { fork, revert }
+  const actions = { revert }
 
   createEffect(() => {
     const sessionID = params.id
@@ -1585,7 +1580,7 @@ export default function Page() {
 
     const item = queuedFollowups()[0]
     if (!item) return
-    if (followup.sending[sessionID]) return
+    if (followupBusy(sessionID)) return
     if (followup.failed[sessionID] === item.id) return
     if (followup.paused[sessionID]) return
     if (composer.blocked()) return
@@ -1621,6 +1616,9 @@ export default function Page() {
     sessionID: () => params.id,
     messagesReady,
     visibleUserMessages,
+    historyMore,
+    historyLoading,
+    loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
     turnStart: historyWindow.turnStart,
     currentMessageId: () => store.messageId,
     pendingMessage: () => ui.pendingMessage,
@@ -1692,7 +1690,7 @@ export default function Page() {
           <div class="flex-1 min-h-0 overflow-hidden">
             <Switch>
               <Match when={params.id}>
-                <Show when={lastUserMessage()}>
+                <Show when={messagesReady()}>
                   <MessageTimeline
                     mobileChanges={mobileChanges()}
                     mobileFallback={reviewContent({
@@ -1780,8 +1778,8 @@ export default function Page() {
               rolled().length > 0
                 ? {
                     items: rolled(),
-                    restoring: ui.restoring,
-                    disabled: ui.reverting,
+                    restoring: restoring(),
+                    disabled: reverting(),
                     onRestore: restore,
                   }
                 : undefined

+ 44 - 40
packages/app/src/pages/session/composer/session-question-dock.tsx

@@ -1,5 +1,6 @@
 import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
 import { createStore } from "solid-js/store"
+import { useMutation } from "@tanstack/solid-query"
 import { Button } from "@opencode-ai/ui/button"
 import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
 import { Icon } from "@opencode-ai/ui/icon"
@@ -24,7 +25,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     custom: cached?.custom ?? ([] as string[]),
     customOn: cached?.customOn ?? ([] as boolean[]),
     editing: false,
-    sending: false,
   })
 
   let root: HTMLDivElement | undefined
@@ -126,36 +126,40 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     showToast({ title: language.t("common.requestFailed"), description: message })
   }
 
-  const reply = async (answers: QuestionAnswer[]) => {
-    if (store.sending) return
-
-    props.onSubmit()
-    setStore("sending", true)
-    try {
-      await sdk.client.question.reply({ requestID: props.request.id, answers })
+  const replyMutation = useMutation(() => ({
+    mutationFn: (answers: QuestionAnswer[]) => sdk.client.question.reply({ requestID: props.request.id, answers }),
+    onMutate: () => {
+      props.onSubmit()
+    },
+    onSuccess: () => {
       replied = true
       cache.delete(props.request.id)
-    } catch (err) {
-      fail(err)
-    } finally {
-      setStore("sending", false)
-    }
+    },
+    onError: fail,
+  }))
+
+  const rejectMutation = useMutation(() => ({
+    mutationFn: () => sdk.client.question.reject({ requestID: props.request.id }),
+    onMutate: () => {
+      props.onSubmit()
+    },
+    onSuccess: () => {
+      replied = true
+      cache.delete(props.request.id)
+    },
+    onError: fail,
+  }))
+
+  const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending)
+
+  const reply = async (answers: QuestionAnswer[]) => {
+    if (sending()) return
+    await replyMutation.mutateAsync(answers)
   }
 
   const reject = async () => {
-    if (store.sending) return
-
-    props.onSubmit()
-    setStore("sending", true)
-    try {
-      await sdk.client.question.reject({ requestID: props.request.id })
-      replied = true
-      cache.delete(props.request.id)
-    } catch (err) {
-      fail(err)
-    } finally {
-      setStore("sending", false)
-    }
+    if (sending()) return
+    await rejectMutation.mutateAsync()
   }
 
   const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
@@ -175,7 +179,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
   }
 
   const customToggle = () => {
-    if (store.sending) return
+    if (sending()) return
 
     if (!multi()) {
       setStore("customOn", store.tab, true)
@@ -198,14 +202,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
   }
 
   const customOpen = () => {
-    if (store.sending) return
+    if (sending()) return
     if (!on()) setStore("customOn", store.tab, true)
     setStore("editing", true)
     customUpdate(input(), true)
   }
 
   const selectOption = (optIndex: number) => {
-    if (store.sending) return
+    if (sending()) return
 
     if (optIndex === options().length) {
       customOpen()
@@ -227,7 +231,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
   }
 
   const next = () => {
-    if (store.sending) return
+    if (sending()) return
     if (store.editing) commitCustom()
 
     if (store.tab >= total() - 1) {
@@ -240,14 +244,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
   }
 
   const back = () => {
-    if (store.sending) return
+    if (sending()) return
     if (store.tab <= 0) return
     setStore("tab", store.tab - 1)
     setStore("editing", false)
   }
 
   const jump = (tab: number) => {
-    if (store.sending) return
+    if (sending()) return
     setStore("tab", tab)
     setStore("editing", false)
   }
@@ -270,7 +274,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
                     (store.answers[i()]?.length ?? 0) > 0 ||
                     (store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
                   }
-                  disabled={store.sending}
+                  disabled={sending()}
                   onClick={() => jump(i())}
                   aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
                 />
@@ -281,16 +285,16 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
       }
       footer={
         <>
-          <Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
+          <Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
             {language.t("ui.common.dismiss")}
           </Button>
           <div data-slot="question-footer-actions">
             <Show when={store.tab > 0}>
-              <Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
+              <Button variant="secondary" size="large" disabled={sending()} onClick={back}>
                 {language.t("ui.common.back")}
               </Button>
             </Show>
-            <Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
+            <Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
               {last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
             </Button>
           </div>
@@ -311,7 +315,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
                 data-picked={picked()}
                 role={multi() ? "checkbox" : "radio"}
                 aria-checked={picked()}
-                disabled={store.sending}
+                disabled={sending()}
                 onClick={() => selectOption(i())}
               >
                 <span data-slot="question-option-check" aria-hidden="true">
@@ -345,7 +349,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
               data-picked={on()}
               role={multi() ? "checkbox" : "radio"}
               aria-checked={on()}
-              disabled={store.sending}
+              disabled={sending()}
               onClick={customOpen}
             >
               <span
@@ -377,7 +381,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
             role={multi() ? "checkbox" : "radio"}
             aria-checked={on()}
             onMouseDown={(e) => {
-              if (store.sending) {
+              if (sending()) {
                 e.preventDefault()
                 return
               }
@@ -419,7 +423,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
                 placeholder={language.t("ui.question.custom.placeholder")}
                 value={input()}
                 rows={1}
-                disabled={store.sending}
+                disabled={sending()}
                 onKeyDown={(e) => {
                   if (e.key === "Escape") {
                     e.preventDefault()

+ 21 - 0
packages/app/src/pages/session/helpers.test.ts

@@ -7,6 +7,7 @@ import {
   createSessionTabs,
   focusTerminalById,
   getTabReorderIndex,
+  shouldFocusTerminalOnKeyDown,
 } from "./helpers"
 
 describe("createOpenReviewFile", () => {
@@ -86,6 +87,26 @@ describe("focusTerminalById", () => {
   })
 })
 
+describe("shouldFocusTerminalOnKeyDown", () => {
+  test("skips pure modifier keys", () => {
+    expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Meta", metaKey: true }))).toBe(false)
+    expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Control", ctrlKey: true }))).toBe(false)
+    expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Alt", altKey: true }))).toBe(false)
+    expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Shift", shiftKey: true }))).toBe(false)
+  })
+
+  test("skips shortcut key combos", () => {
+    expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", metaKey: true }))).toBe(false)
+    expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", ctrlKey: true }))).toBe(false)
+    expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true }))).toBe(false)
+  })
+
+  test("keeps plain typing focused on terminal", () => {
+    expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "a" }))).toBe(true)
+    expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "A", shiftKey: true }))).toBe(true)
+  })
+})
+
 describe("getTabReorderIndex", () => {
   test("returns target index for valid drag reorder", () => {
     expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2)

+ 7 - 0
packages/app/src/pages/session/helpers.ts

@@ -93,6 +93,13 @@ export const focusTerminalById = (id: string) => {
   return true
 }
 
+const skip = new Set(["Alt", "Control", "Meta", "Shift"])
+
+export const shouldFocusTerminalOnKeyDown = (event: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "altKey">) => {
+  if (skip.has(event.key)) return false
+  return !(event.ctrlKey || event.metaKey || event.altKey)
+}
+
 export const createOpenReviewFile = (input: {
   showAllFiles: () => void
   tabForPath: (path: string) => string

+ 95 - 104
packages/app/src/pages/session/message-timeline.tsx

@@ -1,6 +1,7 @@
-import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
+import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { useNavigate } from "@solidjs/router"
+import { useMutation } from "@tanstack/solid-query"
 import { Button } from "@opencode-ai/ui/button"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { Icon } from "@opencode-ai/ui/icon"
@@ -29,6 +30,7 @@ import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
 import { messageAgentColor } from "@/utils/agent"
 import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
+import { makeTimer } from "@solid-primitives/timer"
 
 type MessageComment = {
   path: string
@@ -249,38 +251,21 @@ export function MessageTimeline(props: {
   const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
   const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
 
-  const [slot, setSlot] = createStore({
-    open: false,
-    show: false,
-    fade: false,
+  const [timeoutDone, setTimeoutDone] = createSignal(true)
+
+  const workingStatus = createMemo<"hidden" | "showing" | "hiding">((prev) => {
+    if (working()) return "showing"
+    if (prev === "showing" || !timeoutDone()) return "hiding"
+    return "hidden"
   })
 
-  let f: number | undefined
-  const clear = () => {
-    if (f !== undefined) window.clearTimeout(f)
-    f = undefined
-  }
+  createEffect(() => {
+    if (workingStatus() !== "hiding") return
+
+    setTimeoutDone(false)
+    makeTimer(() => setTimeoutDone(true), 260, setTimeout)
+  })
 
-  onCleanup(clear)
-  createEffect(
-    on(
-      working,
-      (on, prev) => {
-        clear()
-        if (on) {
-          setSlot({ open: true, show: true, fade: false })
-          return
-        }
-        if (prev) {
-          setSlot({ open: false, show: true, fade: true })
-          f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
-          return
-        }
-        setSlot({ open: false, show: false, fade: false })
-      },
-      { defer: true },
-    ),
-  )
   const activeMessageID = createMemo(() => {
     const parentID = pending()?.parentID
     if (parentID) {
@@ -321,7 +306,6 @@ export function MessageTimeline(props: {
   const [title, setTitle] = createStore({
     draft: "",
     editing: false,
-    saving: false,
     menuOpen: false,
     pendingRename: false,
     pendingShare: false,
@@ -335,38 +319,6 @@ export function MessageTimeline(props: {
 
   let more: HTMLButtonElement | undefined
 
-  const [req, setReq] = createStore({ share: false, unshare: false })
-
-  const shareSession = () => {
-    const id = sessionID()
-    if (!id || req.share) return
-    if (!shareEnabled()) return
-    setReq("share", true)
-    globalSDK.client.session
-      .share({ sessionID: id, directory: sdk.directory })
-      .catch((err: unknown) => {
-        console.error("Failed to share session", err)
-      })
-      .finally(() => {
-        setReq("share", false)
-      })
-  }
-
-  const unshareSession = () => {
-    const id = sessionID()
-    if (!id || req.unshare) return
-    if (!shareEnabled()) return
-    setReq("unshare", true)
-    globalSDK.client.session
-      .unshare({ sessionID: id, directory: sdk.directory })
-      .catch((err: unknown) => {
-        console.error("Failed to unshare session", err)
-      })
-      .finally(() => {
-        setReq("unshare", false)
-      })
-  }
-
   const viewShare = () => {
     const url = shareUrl()
     if (!url) return
@@ -382,6 +334,54 @@ export function MessageTimeline(props: {
     return language.t("common.requestFailed")
   }
 
+  const shareMutation = useMutation(() => ({
+    mutationFn: (id: string) => globalSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
+    onError: (err) => {
+      console.error("Failed to share session", err)
+    },
+  }))
+
+  const unshareMutation = useMutation(() => ({
+    mutationFn: (id: string) => globalSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
+    onError: (err) => {
+      console.error("Failed to unshare session", err)
+    },
+  }))
+
+  const titleMutation = useMutation(() => ({
+    mutationFn: (input: { id: string; title: string }) =>
+      sdk.client.session.update({ sessionID: input.id, title: input.title }),
+    onSuccess: (_, input) => {
+      sync.set(
+        produce((draft) => {
+          const index = draft.session.findIndex((s) => s.id === input.id)
+          if (index !== -1) draft.session[index].title = input.title
+        }),
+      )
+      setTitle("editing", false)
+    },
+    onError: (err) => {
+      showToast({
+        title: language.t("common.requestFailed"),
+        description: errorMessage(err),
+      })
+    },
+  }))
+
+  const shareSession = () => {
+    const id = sessionID()
+    if (!id || shareMutation.isPending) return
+    if (!shareEnabled()) return
+    shareMutation.mutate(id)
+  }
+
+  const unshareSession = () => {
+    const id = sessionID()
+    if (!id || unshareMutation.isPending) return
+    if (!shareEnabled()) return
+    unshareMutation.mutate(id)
+  }
+
   createEffect(
     on(
       sessionKey,
@@ -389,7 +389,6 @@ export function MessageTimeline(props: {
         setTitle({
           draft: "",
           editing: false,
-          saving: false,
           menuOpen: false,
           pendingRename: false,
           pendingShare: false,
@@ -408,40 +407,22 @@ export function MessageTimeline(props: {
   }
 
   const closeTitleEditor = () => {
-    if (title.saving) return
-    setTitle({ editing: false, saving: false })
+    if (titleMutation.isPending) return
+    setTitle("editing", false)
   }
 
-  const saveTitleEditor = async () => {
+  const saveTitleEditor = () => {
     const id = sessionID()
     if (!id) return
-    if (title.saving) return
+    if (titleMutation.isPending) return
 
     const next = title.draft.trim()
     if (!next || next === (titleValue() ?? "")) {
-      setTitle({ editing: false, saving: false })
+      setTitle("editing", false)
       return
     }
 
-    setTitle("saving", true)
-    await sdk.client.session
-      .update({ sessionID: id, title: next })
-      .then(() => {
-        sync.set(
-          produce((draft) => {
-            const index = draft.session.findIndex((s) => s.id === id)
-            if (index !== -1) draft.session[index].title = next
-          }),
-        )
-        setTitle({ editing: false, saving: false })
-      })
-      .catch((err) => {
-        setTitle("saving", false)
-        showToast({
-          title: language.t("common.requestFailed"),
-          description: errorMessage(err),
-        })
-      })
+    titleMutation.mutate({ id, title: next })
   }
 
   const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
@@ -679,17 +660,15 @@ export function MessageTimeline(props: {
                       <div
                         class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
                         style={{
-                          width: slot.open ? "16px" : "0px",
-                          "margin-right": slot.open ? "8px" : "0px",
+                          width: working() ? "16px" : "0px",
+                          "margin-right": working() ? "8px" : "0px",
                         }}
                         aria-hidden="true"
                       >
-                        <Show when={slot.show}>
+                        <Show when={workingStatus() !== "hidden"}>
                           <div
                             class="transition-opacity duration-200 ease-out"
-                            classList={{
-                              "opacity-0": slot.fade,
-                            }}
+                            classList={{ "opacity-0": workingStatus() === "hiding" }}
                           >
                             <Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
                           </div>
@@ -712,7 +691,7 @@ export function MessageTimeline(props: {
                               titleRef = el
                             }}
                             value={title.draft}
-                            disabled={title.saving}
+                            disabled={titleMutation.isPending}
                             class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
                             style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
                             onInput={(event) => setTitle("draft", event.currentTarget.value)}
@@ -863,9 +842,9 @@ export function MessageTimeline(props: {
                                         variant="primary"
                                         class="w-full"
                                         onClick={shareSession}
-                                        disabled={req.share}
+                                        disabled={shareMutation.isPending}
                                       >
-                                        {req.share
+                                        {shareMutation.isPending
                                           ? language.t("session.share.action.publishing")
                                           : language.t("session.share.action.publish")}
                                       </Button>
@@ -886,9 +865,9 @@ export function MessageTimeline(props: {
                                           variant="secondary"
                                           class="w-full shadow-none border border-border-weak-base"
                                           onClick={unshareSession}
-                                          disabled={req.unshare}
+                                          disabled={unshareMutation.isPending}
                                         >
-                                          {req.unshare
+                                          {unshareMutation.isPending
                                             ? language.t("session.share.action.unpublishing")
                                             : language.t("session.share.action.unpublish")}
                                         </Button>
@@ -897,7 +876,7 @@ export function MessageTimeline(props: {
                                           variant="primary"
                                           class="w-full"
                                           onClick={viewShare}
-                                          disabled={req.unshare}
+                                          disabled={unshareMutation.isPending}
                                         >
                                           {language.t("session.share.action.view")}
                                         </Button>
@@ -915,10 +894,10 @@ export function MessageTimeline(props: {
                 </div>
               </div>
             </Show>
-
             <div
               role="log"
-              class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
+              data-slot="session-turn-list"
+              class="flex flex-col items-start justify-start pb-16 transition-[margin]"
               classList={{
                 "w-full": true,
                 "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
@@ -945,7 +924,15 @@ export function MessageTimeline(props: {
                 {(messageID) => {
                   const active = createMemo(() => activeMessageID() === messageID)
                   const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
-                    equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
+                    equals: (a, b) =>
+                      a.length === b.length &&
+                      a.every(
+                        (c, i) =>
+                          c.path === b[i].path &&
+                          c.comment === b[i].comment &&
+                          c.selection?.startLine === b[i].selection?.startLine &&
+                          c.selection?.endLine === b[i].selection?.endLine,
+                      ),
                   })
                   const commentCount = createMemo(() => comments().length)
                   return (
@@ -956,7 +943,10 @@ export function MessageTimeline(props: {
                         "min-w-0 w-full max-w-full": true,
                         "md:max-w-200 2xl:max-w-[1000px]": props.centered,
                       }}
-                      style={{ "content-visibility": "auto", "contain-intrinsic-size": "auto 500px" }}
+                      style={{
+                        "content-visibility": active() ? undefined : "auto",
+                        "contain-intrinsic-size": active() ? undefined : "auto 500px",
+                      }}
                     >
                       <Show when={commentCount() > 0}>
                         <div class="w-full px-4 md:px-5 pb-2">
@@ -1001,6 +991,7 @@ export function MessageTimeline(props: {
                       <SessionTurn
                         sessionID={sessionID() ?? ""}
                         messageID={messageID}
+                        messages={sessionMessages()}
                         actions={props.actions}
                         active={active()}
                         status={active() ? sessionStatus() : undefined}

+ 5 - 6
packages/app/src/pages/session/session-side-panel.tsx

@@ -13,7 +13,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
 
 import FileTree from "@/components/file-tree"
 import { SessionContextUsage } from "@/components/session-context-usage"
-import { DialogSelectFile } from "@/components/dialog-select-file"
 import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
 import { useCommand } from "@/context/command"
 import { useFile, type SelectedLineRange } from "@/context/file"
@@ -293,9 +292,11 @@ export function SessionSidePanel(props: {
                             variant="ghost"
                             iconSize="large"
                             class="!rounded-md"
-                            onClick={() =>
-                              dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
-                            }
+                            onClick={() => {
+                              void import("@/components/dialog-select-file").then((x) => {
+                                dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
+                              })
+                            }}
                             aria-label={language.t("command.file.open")}
                           />
                         </TooltipKeybind>
@@ -438,12 +439,10 @@ export function SessionSidePanel(props: {
                   size={layout.fileTree.width()}
                   min={200}
                   max={480}
-                  collapseThreshold={160}
                   onResize={(width) => {
                     props.size.touch()
                     layout.fileTree.resize(width)
                   }}
-                  onCollapse={layout.fileTree.close}
                 />
               </div>
             </Show>

+ 23 - 11
packages/app/src/pages/session/use-session-commands.tsx

@@ -11,10 +11,6 @@ import { usePrompt } from "@/context/prompt"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
 import { useTerminal } from "@/context/terminal"
-import { DialogSelectFile } from "@/components/dialog-select-file"
-import { DialogSelectModel } from "@/components/dialog-select-model"
-import { DialogSelectMcp } from "@/components/dialog-select-mcp"
-import { DialogFork } from "@/components/dialog-fork"
 import { showToast } from "@opencode-ai/ui/toast"
 import { findLast } from "@opencode-ai/util/array"
 import { createSessionTabs } from "@/pages/session/helpers"
@@ -255,9 +251,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
         id: "file.open",
         title: language.t("command.file.open"),
         description: language.t("palette.search.placeholder"),
-        keybind: "mod+p",
+        keybind: "mod+k,mod+p",
         slash: "open",
-        onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
+        onSelect: () => {
+          void import("@/components/dialog-select-file").then((x) => {
+            dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
+          })
+        },
       }),
       fileCommand({
         id: "tab.close",
@@ -333,7 +333,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
         id: "message.previous",
         title: language.t("command.message.previous"),
         description: language.t("command.message.previous.description"),
-        keybind: "mod+arrowup",
+        keybind: "mod+alt+[",
         disabled: !params.id,
         onSelect: () => navigateMessageByOffset(-1),
       }),
@@ -341,7 +341,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
         id: "message.next",
         title: language.t("command.message.next"),
         description: language.t("command.message.next.description"),
-        keybind: "mod+arrowdown",
+        keybind: "mod+alt+]",
         disabled: !params.id,
         onSelect: () => navigateMessageByOffset(1),
       }),
@@ -351,7 +351,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
         description: language.t("command.model.choose.description"),
         keybind: "mod+'",
         slash: "model",
-        onSelect: () => dialog.show(() => <DialogSelectModel model={local.model} />),
+        onSelect: () => {
+          void import("@/components/dialog-select-model").then((x) => {
+            dialog.show(() => <x.DialogSelectModel model={local.model} />)
+          })
+        },
       }),
       mcpCommand({
         id: "mcp.toggle",
@@ -359,7 +363,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
         description: language.t("command.mcp.toggle.description"),
         keybind: "mod+;",
         slash: "mcp",
-        onSelect: () => dialog.show(() => <DialogSelectMcp />),
+        onSelect: () => {
+          void import("@/components/dialog-select-mcp").then((x) => {
+            dialog.show(() => <x.DialogSelectMcp />)
+          })
+        },
       }),
       agentCommand({
         id: "agent.cycle",
@@ -487,7 +495,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
         description: language.t("command.session.fork.description"),
         slash: "fork",
         disabled: !params.id || visibleUserMessages().length === 0,
-        onSelect: () => dialog.show(() => <DialogFork />),
+        onSelect: () => {
+          void import("@/components/dialog-fork").then((x) => {
+            dialog.show(() => <x.DialogFork />)
+          })
+        },
       }),
       ...share,
     ]

+ 18 - 0
packages/app/src/pages/session/use-session-hash-scroll.ts

@@ -8,6 +8,9 @@ export const useSessionHashScroll = (input: {
   sessionID: () => string | undefined
   messagesReady: () => boolean
   visibleUserMessages: () => UserMessage[]
+  historyMore: () => boolean
+  historyLoading: () => boolean
+  loadMore: (sessionID: string) => Promise<void>
   turnStart: () => number
   currentMessageId: () => string | undefined
   pendingMessage: () => string | undefined
@@ -181,6 +184,21 @@ export const useSessionHashScroll = (input: {
     queue(() => scrollToMessage(msg, "auto"))
   })
 
+  createEffect(() => {
+    const sessionID = input.sessionID()
+    if (!sessionID || !input.messagesReady()) return
+
+    visibleUserMessages()
+
+    let targetId = input.pendingMessage()
+    if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
+    if (!targetId) return
+    if (messageById().has(targetId)) return
+    if (!input.historyMore() || input.historyLoading()) return
+
+    void input.loadMore(sessionID)
+  })
+
   onMount(() => {
     if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
       window.history.scrollRestoration = "manual"

+ 32 - 3
packages/app/src/testing/model-selection.ts

@@ -3,6 +3,14 @@ type ModelKey = {
   modelID: string
 }
 
+type ModelItem = ModelKey & {
+  name: string
+}
+
+type AgentItem = {
+  name: string
+}
+
 type State = {
   agent?: string
   model?: ModelKey | null
@@ -26,6 +34,9 @@ export type ModelProbeState = {
   pick?: State
   base?: State
   current?: string
+  variants?: string[]
+  models?: ModelItem[]
+  agents?: AgentItem[]
 }
 
 export type ModelWindow = Window & {
@@ -33,6 +44,11 @@ export type ModelWindow = Window & {
     model?: {
       enabled?: boolean
       current?: ModelProbeState
+      controls?: {
+        setAgent?: (name: string | undefined) => void
+        setModel?: (value: ModelKey | undefined) => void
+        setVariant?: (value: string | undefined) => void
+      }
     }
   }
 }
@@ -45,6 +61,8 @@ const clone = (state?: State) => {
   }
 }
 
+let active: symbol | undefined
+
 export const modelEnabled = () => {
   if (typeof window === "undefined") return false
   return (window as ModelWindow).__opencode_e2e?.model?.enabled === true
@@ -56,9 +74,15 @@ const root = () => {
 }
 
 export const modelProbe = {
-  set(input: ModelProbeState) {
+  bind(id: symbol, input: NonNullable<NonNullable<ModelWindow["__opencode_e2e"]>["model"]>["controls"]) {
     const state = root()
     if (!state) return
+    active = id
+    state.controls = input
+  },
+  set(id: symbol, input: ModelProbeState) {
+    const state = root()
+    if (!state || active !== id) return
     state.current = {
       ...input,
       model: input.model ? { ...input.model } : undefined,
@@ -70,11 +94,16 @@ export const modelProbe = {
         : undefined,
       pick: clone(input.pick),
       base: clone(input.base),
+      variants: input.variants?.slice(),
+      models: input.models?.map((item) => ({ ...item })),
+      agents: input.agents?.map((item) => ({ ...item })),
     }
   },
-  clear() {
+  clear(id: symbol) {
     const state = root()
-    if (!state) return
+    if (!state || active !== id) return
+    active = undefined
     state.current = undefined
+    state.controls = undefined
   },
 }

+ 44 - 0
packages/app/src/utils/prompt.test.ts

@@ -0,0 +1,44 @@
+import { describe, expect, test } from "bun:test"
+import type { Part } from "@opencode-ai/sdk/v2"
+import { extractPromptFromParts } from "./prompt"
+
+describe("extractPromptFromParts", () => {
+  test("restores multiple uploaded attachments", () => {
+    const parts = [
+      {
+        id: "text_1",
+        type: "text",
+        text: "check these",
+        sessionID: "ses_1",
+        messageID: "msg_1",
+      },
+      {
+        id: "file_1",
+        type: "file",
+        mime: "image/png",
+        url: "data:image/png;base64,AAA",
+        filename: "a.png",
+        sessionID: "ses_1",
+        messageID: "msg_1",
+      },
+      {
+        id: "file_2",
+        type: "file",
+        mime: "application/pdf",
+        url: "data:application/pdf;base64,BBB",
+        filename: "b.pdf",
+        sessionID: "ses_1",
+        messageID: "msg_1",
+      },
+    ] satisfies Part[]
+
+    const result = extractPromptFromParts(parts)
+
+    expect(result).toHaveLength(3)
+    expect(result[0]).toMatchObject({ type: "text", content: "check these" })
+    expect(result.slice(1)).toMatchObject([
+      { type: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
+      { type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" },
+    ])
+  })
+})

+ 23 - 1
packages/app/src/utils/server-health.ts

@@ -14,6 +14,15 @@ interface CheckServerHealthOptions {
 const defaultTimeoutMs = 3000
 const defaultRetryCount = 2
 const defaultRetryDelayMs = 100
+const cacheMs = 750
+const healthCache = new Map<
+  string,
+  { at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise<ServerHealth> }
+>()
+
+function cacheKey(server: ServerConnection.HttpBase) {
+  return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}`
+}
 
 function timeoutSignal(timeoutMs: number) {
   const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
@@ -87,5 +96,18 @@ export function useCheckServerHealth() {
   const platform = usePlatform()
   const fetcher = platform.fetch ?? globalThis.fetch
 
-  return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher)
+  return (http: ServerConnection.HttpBase) => {
+    const key = cacheKey(http)
+    const hit = healthCache.get(key)
+    const now = Date.now()
+    if (hit && hit.fetch === fetcher && (!hit.done || now - hit.at < cacheMs)) return hit.promise
+    const promise = checkServerHealth(http, fetcher).finally(() => {
+      const next = healthCache.get(key)
+      if (!next || next.promise !== promise) return
+      next.done = true
+      next.at = Date.now()
+    })
+    healthCache.set(key, { at: now, done: false, fetch: fetcher, promise })
+    return promise
+  }
 }

+ 81 - 96
packages/app/src/utils/sound.ts

@@ -1,106 +1,89 @@
-import alert01 from "@opencode-ai/ui/audio/alert-01.aac"
-import alert02 from "@opencode-ai/ui/audio/alert-02.aac"
-import alert03 from "@opencode-ai/ui/audio/alert-03.aac"
-import alert04 from "@opencode-ai/ui/audio/alert-04.aac"
-import alert05 from "@opencode-ai/ui/audio/alert-05.aac"
-import alert06 from "@opencode-ai/ui/audio/alert-06.aac"
-import alert07 from "@opencode-ai/ui/audio/alert-07.aac"
-import alert08 from "@opencode-ai/ui/audio/alert-08.aac"
-import alert09 from "@opencode-ai/ui/audio/alert-09.aac"
-import alert10 from "@opencode-ai/ui/audio/alert-10.aac"
-import bipbop01 from "@opencode-ai/ui/audio/bip-bop-01.aac"
-import bipbop02 from "@opencode-ai/ui/audio/bip-bop-02.aac"
-import bipbop03 from "@opencode-ai/ui/audio/bip-bop-03.aac"
-import bipbop04 from "@opencode-ai/ui/audio/bip-bop-04.aac"
-import bipbop05 from "@opencode-ai/ui/audio/bip-bop-05.aac"
-import bipbop06 from "@opencode-ai/ui/audio/bip-bop-06.aac"
-import bipbop07 from "@opencode-ai/ui/audio/bip-bop-07.aac"
-import bipbop08 from "@opencode-ai/ui/audio/bip-bop-08.aac"
-import bipbop09 from "@opencode-ai/ui/audio/bip-bop-09.aac"
-import bipbop10 from "@opencode-ai/ui/audio/bip-bop-10.aac"
-import nope01 from "@opencode-ai/ui/audio/nope-01.aac"
-import nope02 from "@opencode-ai/ui/audio/nope-02.aac"
-import nope03 from "@opencode-ai/ui/audio/nope-03.aac"
-import nope04 from "@opencode-ai/ui/audio/nope-04.aac"
-import nope05 from "@opencode-ai/ui/audio/nope-05.aac"
-import nope06 from "@opencode-ai/ui/audio/nope-06.aac"
-import nope07 from "@opencode-ai/ui/audio/nope-07.aac"
-import nope08 from "@opencode-ai/ui/audio/nope-08.aac"
-import nope09 from "@opencode-ai/ui/audio/nope-09.aac"
-import nope10 from "@opencode-ai/ui/audio/nope-10.aac"
-import nope11 from "@opencode-ai/ui/audio/nope-11.aac"
-import nope12 from "@opencode-ai/ui/audio/nope-12.aac"
-import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac"
-import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac"
-import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac"
-import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac"
-import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac"
-import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac"
-import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac"
-import yup01 from "@opencode-ai/ui/audio/yup-01.aac"
-import yup02 from "@opencode-ai/ui/audio/yup-02.aac"
-import yup03 from "@opencode-ai/ui/audio/yup-03.aac"
-import yup04 from "@opencode-ai/ui/audio/yup-04.aac"
-import yup05 from "@opencode-ai/ui/audio/yup-05.aac"
-import yup06 from "@opencode-ai/ui/audio/yup-06.aac"
+let files: Record<string, () => Promise<string>> | undefined
+let loads: Record<SoundID, () => Promise<string>> | undefined
+
+function getFiles() {
+  if (files) return files
+  files = import.meta.glob("../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record<
+    string,
+    () => Promise<string>
+  >
+  return files
+}
 
 export const SOUND_OPTIONS = [
-  { id: "alert-01", label: "sound.option.alert01", src: alert01 },
-  { id: "alert-02", label: "sound.option.alert02", src: alert02 },
-  { id: "alert-03", label: "sound.option.alert03", src: alert03 },
-  { id: "alert-04", label: "sound.option.alert04", src: alert04 },
-  { id: "alert-05", label: "sound.option.alert05", src: alert05 },
-  { id: "alert-06", label: "sound.option.alert06", src: alert06 },
-  { id: "alert-07", label: "sound.option.alert07", src: alert07 },
-  { id: "alert-08", label: "sound.option.alert08", src: alert08 },
-  { id: "alert-09", label: "sound.option.alert09", src: alert09 },
-  { id: "alert-10", label: "sound.option.alert10", src: alert10 },
-  { id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 },
-  { id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 },
-  { id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 },
-  { id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 },
-  { id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 },
-  { id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 },
-  { id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 },
-  { id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 },
-  { id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 },
-  { id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 },
-  { id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 },
-  { id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 },
-  { id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 },
-  { id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 },
-  { id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 },
-  { id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 },
-  { id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 },
-  { id: "nope-01", label: "sound.option.nope01", src: nope01 },
-  { id: "nope-02", label: "sound.option.nope02", src: nope02 },
-  { id: "nope-03", label: "sound.option.nope03", src: nope03 },
-  { id: "nope-04", label: "sound.option.nope04", src: nope04 },
-  { id: "nope-05", label: "sound.option.nope05", src: nope05 },
-  { id: "nope-06", label: "sound.option.nope06", src: nope06 },
-  { id: "nope-07", label: "sound.option.nope07", src: nope07 },
-  { id: "nope-08", label: "sound.option.nope08", src: nope08 },
-  { id: "nope-09", label: "sound.option.nope09", src: nope09 },
-  { id: "nope-10", label: "sound.option.nope10", src: nope10 },
-  { id: "nope-11", label: "sound.option.nope11", src: nope11 },
-  { id: "nope-12", label: "sound.option.nope12", src: nope12 },
-  { id: "yup-01", label: "sound.option.yup01", src: yup01 },
-  { id: "yup-02", label: "sound.option.yup02", src: yup02 },
-  { id: "yup-03", label: "sound.option.yup03", src: yup03 },
-  { id: "yup-04", label: "sound.option.yup04", src: yup04 },
-  { id: "yup-05", label: "sound.option.yup05", src: yup05 },
-  { id: "yup-06", label: "sound.option.yup06", src: yup06 },
+  { id: "alert-01", label: "sound.option.alert01" },
+  { id: "alert-02", label: "sound.option.alert02" },
+  { id: "alert-03", label: "sound.option.alert03" },
+  { id: "alert-04", label: "sound.option.alert04" },
+  { id: "alert-05", label: "sound.option.alert05" },
+  { id: "alert-06", label: "sound.option.alert06" },
+  { id: "alert-07", label: "sound.option.alert07" },
+  { id: "alert-08", label: "sound.option.alert08" },
+  { id: "alert-09", label: "sound.option.alert09" },
+  { id: "alert-10", label: "sound.option.alert10" },
+  { id: "bip-bop-01", label: "sound.option.bipbop01" },
+  { id: "bip-bop-02", label: "sound.option.bipbop02" },
+  { id: "bip-bop-03", label: "sound.option.bipbop03" },
+  { id: "bip-bop-04", label: "sound.option.bipbop04" },
+  { id: "bip-bop-05", label: "sound.option.bipbop05" },
+  { id: "bip-bop-06", label: "sound.option.bipbop06" },
+  { id: "bip-bop-07", label: "sound.option.bipbop07" },
+  { id: "bip-bop-08", label: "sound.option.bipbop08" },
+  { id: "bip-bop-09", label: "sound.option.bipbop09" },
+  { id: "bip-bop-10", label: "sound.option.bipbop10" },
+  { id: "staplebops-01", label: "sound.option.staplebops01" },
+  { id: "staplebops-02", label: "sound.option.staplebops02" },
+  { id: "staplebops-03", label: "sound.option.staplebops03" },
+  { id: "staplebops-04", label: "sound.option.staplebops04" },
+  { id: "staplebops-05", label: "sound.option.staplebops05" },
+  { id: "staplebops-06", label: "sound.option.staplebops06" },
+  { id: "staplebops-07", label: "sound.option.staplebops07" },
+  { id: "nope-01", label: "sound.option.nope01" },
+  { id: "nope-02", label: "sound.option.nope02" },
+  { id: "nope-03", label: "sound.option.nope03" },
+  { id: "nope-04", label: "sound.option.nope04" },
+  { id: "nope-05", label: "sound.option.nope05" },
+  { id: "nope-06", label: "sound.option.nope06" },
+  { id: "nope-07", label: "sound.option.nope07" },
+  { id: "nope-08", label: "sound.option.nope08" },
+  { id: "nope-09", label: "sound.option.nope09" },
+  { id: "nope-10", label: "sound.option.nope10" },
+  { id: "nope-11", label: "sound.option.nope11" },
+  { id: "nope-12", label: "sound.option.nope12" },
+  { id: "yup-01", label: "sound.option.yup01" },
+  { id: "yup-02", label: "sound.option.yup02" },
+  { id: "yup-03", label: "sound.option.yup03" },
+  { id: "yup-04", label: "sound.option.yup04" },
+  { id: "yup-05", label: "sound.option.yup05" },
+  { id: "yup-06", label: "sound.option.yup06" },
 ] as const
 
 export type SoundOption = (typeof SOUND_OPTIONS)[number]
 export type SoundID = SoundOption["id"]
 
-const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record<SoundID, string>
+function getLoads() {
+  if (loads) return loads
+  loads = Object.fromEntries(
+    Object.entries(getFiles()).flatMap(([path, load]) => {
+      const file = path.split("/").at(-1)
+      if (!file) return []
+      return [[file.replace(/\.aac$/, ""), load] as const]
+    }),
+  ) as Record<SoundID, () => Promise<string>>
+  return loads
+}
+
+const cache = new Map<SoundID, Promise<string | undefined>>()
 
 export function soundSrc(id: string | undefined) {
-  if (!id) return
-  if (!(id in soundById)) return
-  return soundById[id as SoundID]
+  const loads = getLoads()
+  if (!id || !(id in loads)) return Promise.resolve(undefined)
+  const key = id as SoundID
+  const hit = cache.get(key)
+  if (hit) return hit
+  const next = loads[key]().catch(() => undefined)
+  cache.set(key, next)
+  return next
 }
 
 export function playSound(src: string | undefined) {
@@ -108,10 +91,12 @@ export function playSound(src: string | undefined) {
   if (!src) return
   const audio = new Audio(src)
   audio.play().catch(() => undefined)
-
-  // Return a cleanup function to pause the sound.
   return () => {
     audio.pause()
     audio.currentTime = 0
   }
 }
+
+export function playSoundById(id: string | undefined) {
+  return soundSrc(id).then((src) => playSound(src))
+}

+ 12 - 0
packages/app/vite.js

@@ -1,7 +1,10 @@
+import { readFileSync } from "node:fs"
 import solidPlugin from "vite-plugin-solid"
 import tailwindcss from "@tailwindcss/vite"
 import { fileURLToPath } from "url"
 
+const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
+
 /**
  * @type {import("vite").PluginOption}
  */
@@ -21,6 +24,15 @@ export default [
       }
     },
   },
+  {
+    name: "opencode-desktop:theme-preload",
+    transformIndexHtml(html) {
+      return html.replace(
+        '<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>',
+        `<script id="oc-theme-preload-script">${readFileSync(theme, "utf8")}</script>`,
+      )
+    },
+  },
   tailwindcss(),
   solidPlugin(),
 ]

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů