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

chore: refactor packages/app files (#13236)

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Frank <[email protected]>
Adam 2 месяцев назад
Родитель
Сommit
ff4414bb15
93 измененных файлов с 5326 добавлено и 4386 удалено
  1. 16 3
      packages/app/e2e/files/file-open.spec.ts
  2. 31 8
      packages/app/e2e/files/file-viewer.spec.ts
  3. 7 3
      packages/app/e2e/projects/workspace-new-session.spec.ts
  4. 60 26
      packages/app/e2e/projects/workspaces.spec.ts
  5. 78 23
      packages/app/e2e/prompt/context.spec.ts
  6. 0 3
      packages/app/e2e/prompt/prompt.spec.ts
  7. 15 17
      packages/app/e2e/session/session-undo-redo.spec.ts
  8. 31 14
      packages/app/e2e/session/session.spec.ts
  9. 99 74
      packages/app/src/app.tsx
  10. 311 283
      packages/app/src/components/dialog-connect-provider.tsx
  11. 168 160
      packages/app/src/components/dialog-custom-provider.tsx
  12. 41 26
      packages/app/src/components/dialog-edit-project.tsx
  13. 17 8
      packages/app/src/components/dialog-fork.tsx
  14. 11 16
      packages/app/src/components/dialog-manage-models.tsx
  15. 2 18
      packages/app/src/components/dialog-release-notes.tsx
  16. 145 132
      packages/app/src/components/dialog-select-directory.tsx
  17. 194 136
      packages/app/src/components/dialog-select-file.tsx
  18. 26 19
      packages/app/src/components/dialog-select-mcp.tsx
  19. 3 10
      packages/app/src/components/dialog-select-model-unpaid.tsx
  20. 7 63
      packages/app/src/components/dialog-select-model.tsx
  21. 8 10
      packages/app/src/components/dialog-select-provider.tsx
  22. 181 180
      packages/app/src/components/dialog-select-server.tsx
  23. 0 9
      packages/app/src/components/dialog-settings.tsx
  24. 212 174
      packages/app/src/components/file-tree.tsx
  25. 13 4
      packages/app/src/components/link.tsx
  26. 58 53
      packages/app/src/components/prompt-input.tsx
  27. 58 51
      packages/app/src/components/prompt-input/context-items.tsx
  28. 6 1
      packages/app/src/components/prompt-input/drag-overlay.tsx
  29. 11 4
      packages/app/src/components/prompt-input/image-attachments.tsx
  30. 39 40
      packages/app/src/components/prompt-input/slash-popover.tsx
  31. 49 37
      packages/app/src/components/question-dock.tsx
  32. 11 11
      packages/app/src/components/server/server-row.tsx
  33. 17 5
      packages/app/src/components/session-context-usage.tsx
  34. 61 0
      packages/app/src/components/session/session-context-breakdown.test.ts
  35. 132 0
      packages/app/src/components/session/session-context-breakdown.ts
  36. 20 0
      packages/app/src/components/session/session-context-format.ts
  37. 103 185
      packages/app/src/components/session/session-context-tab.tsx
  38. 185 163
      packages/app/src/components/session/session-header.tsx
  39. 3 1
      packages/app/src/components/session/session-new-view.tsx
  40. 6 2
      packages/app/src/components/session/session-sortable-tab.tsx
  41. 21 10
      packages/app/src/components/session/session-sortable-terminal-tab.tsx
  42. 1 0
      packages/app/src/components/settings-agents.tsx
  43. 1 0
      packages/app/src/components/settings-commands.tsx
  44. 260 270
      packages/app/src/components/settings-general.tsx
  45. 160 143
      packages/app/src/components/settings-keybinds.tsx
  46. 1 0
      packages/app/src/components/settings-mcp.tsx
  47. 21 14
      packages/app/src/components/settings-models.tsx
  48. 5 3
      packages/app/src/components/settings-permissions.tsx
  49. 24 40
      packages/app/src/components/settings-providers.tsx
  50. 151 116
      packages/app/src/components/status-popover.tsx
  51. 104 88
      packages/app/src/components/terminal.tsx
  52. 25 23
      packages/app/src/components/titlebar.tsx
  53. 33 9
      packages/app/src/context/command.tsx
  54. 41 0
      packages/app/src/context/comments.test.ts
  55. 30 28
      packages/app/src/context/comments.tsx
  56. 57 44
      packages/app/src/context/file.tsx
  57. 15 7
      packages/app/src/context/global-sdk.tsx
  58. 42 28
      packages/app/src/context/global-sync.tsx
  59. 43 36
      packages/app/src/context/highlights.tsx
  60. 68 79
      packages/app/src/context/language.tsx
  61. 42 63
      packages/app/src/context/layout.tsx
  62. 36 36
      packages/app/src/context/local.tsx
  63. 31 8
      packages/app/src/context/models.tsx
  64. 73 69
      packages/app/src/context/notification.tsx
  65. 14 2
      packages/app/src/context/permission.tsx
  66. 10 4
      packages/app/src/context/platform.tsx
  67. 61 48
      packages/app/src/context/prompt.tsx
  68. 5 3
      packages/app/src/context/sdk.tsx
  69. 69 62
      packages/app/src/context/server.tsx
  70. 22 13
      packages/app/src/context/settings.tsx
  71. 88 81
      packages/app/src/context/sync.tsx
  72. 37 30
      packages/app/src/context/terminal.tsx
  73. 105 85
      packages/app/src/entry.tsx
  74. 10 0
      packages/app/src/env.d.ts
  75. 31 44
      packages/app/src/pages/directory-layout.tsx
  76. 39 12
      packages/app/src/pages/error.tsx
  77. 9 5
      packages/app/src/pages/home.tsx
  78. 184 204
      packages/app/src/pages/layout.tsx
  79. 15 2
      packages/app/src/pages/layout/inline-editor.tsx
  80. 152 86
      packages/app/src/pages/layout/sidebar-items.tsx
  81. 227 154
      packages/app/src/pages/layout/sidebar-project.tsx
  82. 4 7
      packages/app/src/pages/layout/sidebar-shell.tsx
  83. 278 167
      packages/app/src/pages/layout/sidebar-workspace.tsx
  84. 34 35
      packages/app/src/pages/session.tsx
  85. 36 58
      packages/app/src/pages/session/file-tabs.tsx
  86. 33 45
      packages/app/src/pages/session/message-timeline.tsx
  87. 9 5
      packages/app/src/pages/session/review-tab.tsx
  88. 6 8
      packages/app/src/pages/session/session-mobile-tabs.tsx
  89. 4 5
      packages/app/src/pages/session/session-prompt-dock.tsx
  90. 14 10
      packages/app/src/pages/session/session-side-panel.tsx
  91. 2 3
      packages/app/src/pages/session/terminal-panel.tsx
  92. 72 80
      packages/app/src/pages/session/use-session-commands.tsx
  93. 36 42
      packages/app/src/utils/solid-dnd.tsx

+ 16 - 3
packages/app/e2e/files/file-open.spec.ts

@@ -1,15 +1,28 @@
 import { test, expect } from "../fixtures"
-import { openPalette, clickListItem } from "../actions"
+import { promptSelector } from "../selectors"
 
 test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
   await gotoSession()
 
-  const dialog = await openPalette(page)
+  await page.locator(promptSelector).click()
+  await page.keyboard.type("/open")
+
+  const command = page.locator('[data-slash-id="file.open"]').first()
+  await expect(command).toBeVisible()
+  await page.keyboard.press("Enter")
+
+  const dialog = page
+    .getByRole("dialog")
+    .filter({ has: page.getByPlaceholder(/search files/i) })
+    .first()
+  await expect(dialog).toBeVisible()
 
   const input = dialog.getByRole("textbox").first()
   await input.fill("package.json")
 
-  await clickListItem(dialog, { keyStartsWith: "file:" })
+  const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
+  await expect(item).toBeVisible({ timeout: 30_000 })
+  await item.click()
 
   await expect(dialog).toHaveCount(0)
 

+ 31 - 8
packages/app/e2e/files/file-viewer.spec.ts

@@ -1,18 +1,41 @@
 import { test, expect } from "../fixtures"
-import { openPalette, clickListItem } from "../actions"
+import { promptSelector } from "../selectors"
 
 test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
   await gotoSession()
 
-  const sep = process.platform === "win32" ? "\\" : "/"
-  const file = ["packages", "app", "package.json"].join(sep)
+  await page.locator(promptSelector).click()
+  await page.keyboard.type("/open")
 
-  const dialog = await openPalette(page)
+  const command = page.locator('[data-slash-id="file.open"]').first()
+  await expect(command).toBeVisible()
+  await page.keyboard.press("Enter")
 
-  const input = dialog.getByRole("textbox").first()
-  await input.fill(file)
+  const dialog = page
+    .getByRole("dialog")
+    .filter({ has: page.getByPlaceholder(/search files/i) })
+    .first()
+  await expect(dialog).toBeVisible()
 
-  await clickListItem(dialog, { text: /packages.*app.*package.json/ })
+  const input = dialog.getByRole("textbox").first()
+  await input.fill("package.json")
+
+  const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
+  let index = -1
+  await expect
+    .poll(
+      async () => {
+        const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
+        index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
+        return index >= 0
+      },
+      { timeout: 30_000 },
+    )
+    .toBe(true)
+
+  const item = items.nth(index)
+  await expect(item).toBeVisible()
+  await item.click()
 
   await expect(dialog).toHaveCount(0)
 
@@ -22,5 +45,5 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
 
   const code = page.locator('[data-component="code"]').first()
   await expect(code).toBeVisible()
-  await expect(code.getByText("@opencode-ai/app")).toBeVisible()
+  await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
 })

+ 7 - 3
packages/app/e2e/projects/workspace-new-session.spec.ts

@@ -69,15 +69,19 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
 
   const prompt = page.locator(promptSelector)
   await expect(prompt).toBeVisible()
+  await expect(prompt).toBeEditable()
   await prompt.click()
-  await page.keyboard.type(text)
-  await page.keyboard.press("Enter")
+  await expect(prompt).toBeFocused()
+  await prompt.fill(text)
+  await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
+  await prompt.press("Enter")
 
   await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
-  await expect(page).toHaveURL(new RegExp(`/${slug}/session/[^/?#]+`), { timeout: 30_000 })
+  await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
 
   const sessionID = sessionIDFromUrl(page.url())
   if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
+  await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
   return sessionID
 }
 

+ 60 - 26
packages/app/e2e/projects/workspaces.spec.ts

@@ -11,18 +11,12 @@ import {
   cleanupTestProject,
   clickMenuItem,
   confirmDialog,
-  openProjectMenu,
   openSidebar,
   openWorkspaceMenu,
   setWorkspacesEnabled,
 } from "../actions"
-import {
-  inlineInputSelector,
-  projectSwitchSelector,
-  projectWorkspacesToggleSelector,
-  workspaceItemSelector,
-} from "../selectors"
-import { dirSlug } from "../utils"
+import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
+import { createSdk, dirSlug } from "../utils"
 
 function slugFromUrl(url: string) {
   return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
@@ -143,26 +137,35 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
   await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
 
   try {
-    await withProject(
-      async () => {
-        await openSidebar(page)
+    await withProject(async () => {
+      await page.goto(`/${nonGitSlug}/session`)
+
+      await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
 
-        const nonGitButton = page.locator(projectSwitchSelector(nonGitSlug)).first()
-        await expect(nonGitButton).toBeVisible()
-        await nonGitButton.click()
-        await expect(page).toHaveURL(new RegExp(`/${nonGitSlug}/session`))
+      const activeDir = base64Decode(slugFromUrl(page.url()))
+      expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
 
-        const menu = await openProjectMenu(page, nonGitSlug)
-        const toggle = menu.locator(projectWorkspacesToggleSelector(nonGitSlug)).first()
+      await openSidebar(page)
+      await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
 
-        await expect(toggle).toBeVisible()
-        await expect(toggle).toBeDisabled()
+      const trigger = page.locator('[data-action="project-menu"]').first()
+      const hasMenu = await trigger
+        .isVisible()
+        .then((x) => x)
+        .catch(() => false)
+      if (!hasMenu) return
 
-        await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
-        await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
-      },
-      { extra: [nonGit] },
-    )
+      await trigger.click({ force: true })
+
+      const menu = page.locator(dropdownMenuContentSelector).first()
+      await expect(menu).toBeVisible()
+
+      const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
+
+      await expect(toggle).toBeVisible()
+      await expect(toggle).toBeDisabled()
+      await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
+    })
   } finally {
     await cleanupTestProject(nonGit)
   }
@@ -256,14 +259,45 @@ test("can delete a workspace", async ({ page, withProject }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
 
   await withProject(async (project) => {
-    const { rootSlug, slug } = await setupWorkspaceTest(page, project)
+    const sdk = createSdk(project.directory)
+    const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
+
+    await expect
+      .poll(
+        async () => {
+          const worktrees = await sdk.worktree
+            .list()
+            .then((r) => r.data ?? [])
+            .catch(() => [] as string[])
+          return worktrees.includes(directory)
+        },
+        { timeout: 30_000 },
+      )
+      .toBe(true)
 
     const menu = await openWorkspaceMenu(page, slug)
     await clickMenuItem(menu, /^Delete$/i, { force: true })
     await confirmDialog(page, /^Delete workspace$/i)
 
     await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
-    await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
+
+    await expect
+      .poll(
+        async () => {
+          const worktrees = await sdk.worktree
+            .list()
+            .then((r) => r.data ?? [])
+            .catch(() => [] as string[])
+          return worktrees.includes(directory)
+        },
+        { timeout: 60_000 },
+      )
+      .toBe(false)
+
+    await project.gotoSession()
+
+    await openSidebar(page)
+    await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
     await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
   })
 })

+ 78 - 23
packages/app/e2e/prompt/context.spec.ts

@@ -1,40 +1,95 @@
 import { test, expect } from "../fixtures"
+import type { Page } from "@playwright/test"
 import { promptSelector } from "../selectors"
 import { withSession } from "../actions"
 
+function contextButton(page: Page) {
+  return page
+    .locator('[data-component="button"]')
+    .filter({ has: page.locator('[data-component="progress-circle"]').first() })
+    .first()
+}
+
+async function seedContextSession(input: { sessionID: string; sdk: Parameters<typeof withSession>[0] }) {
+  await input.sdk.session.promptAsync({
+    sessionID: input.sessionID,
+    noReply: true,
+    parts: [
+      {
+        type: "text",
+        text: "seed context",
+      },
+    ],
+  })
+
+  await expect
+    .poll(async () => {
+      const messages = await input.sdk.session
+        .messages({ sessionID: input.sessionID, limit: 1 })
+        .then((r) => r.data ?? [])
+      return messages.length
+    })
+    .toBeGreaterThan(0)
+}
+
 test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
   const title = `e2e smoke context ${Date.now()}`
 
   await withSession(sdk, title, async (session) => {
-    await sdk.session.promptAsync({
-      sessionID: session.id,
-      noReply: true,
-      parts: [
-        {
-          type: "text",
-          text: "seed context",
-        },
-      ],
-    })
+    await seedContextSession({ sessionID: session.id, sdk })
 
-    await expect
-      .poll(async () => {
-        const messages = await sdk.session.messages({ sessionID: session.id, limit: 1 }).then((r) => r.data ?? [])
-        return messages.length
-      })
-      .toBeGreaterThan(0)
+    await gotoSession(session.id)
+
+    const trigger = contextButton(page)
+    await expect(trigger).toBeVisible()
+    await trigger.click()
+
+    const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
+    await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
+  })
+})
 
+test("context panel can be closed from the context tab close action", async ({ page, sdk, gotoSession }) => {
+  await withSession(sdk, `e2e context toggle ${Date.now()}`, async (session) => {
+    await seedContextSession({ sessionID: session.id, sdk })
     await gotoSession(session.id)
 
-    const contextButton = page
-      .locator('[data-component="button"]')
-      .filter({ has: page.locator('[data-component="progress-circle"]').first() })
-      .first()
+    await page.locator(promptSelector).click()
 
-    await expect(contextButton).toBeVisible()
-    await contextButton.click()
+    const trigger = contextButton(page)
+    await expect(trigger).toBeVisible()
+    await trigger.click()
 
     const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
-    await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
+    const context = tabs.getByRole("tab", { name: "Context" })
+    await expect(context).toBeVisible()
+
+    await page.getByRole("button", { name: "Close tab" }).first().click()
+    await expect(context).toHaveCount(0)
+  })
+})
+
+test("context panel can open file picker from context actions", async ({ page, sdk, gotoSession }) => {
+  await withSession(sdk, `e2e context tabs ${Date.now()}`, async (session) => {
+    await seedContextSession({ sessionID: session.id, sdk })
+    await gotoSession(session.id)
+
+    await page.locator(promptSelector).click()
+
+    const trigger = contextButton(page)
+    await expect(trigger).toBeVisible()
+    await trigger.click()
+
+    await expect(page.getByRole("tab", { name: "Context" })).toBeVisible()
+    await page.getByRole("button", { name: "Open file" }).first().click()
+
+    const dialog = page
+      .getByRole("dialog")
+      .filter({ has: page.getByPlaceholder(/search files/i) })
+      .first()
+    await expect(dialog).toBeVisible()
+
+    await page.keyboard.press("Escape")
+    await expect(dialog).toHaveCount(0)
   })
 })

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

@@ -44,9 +44,6 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
       )
 
       .toContain(token)
-
-    const reply = page.locator('[data-slot="session-turn-summary-section"]').filter({ hasText: token }).first()
-    await expect(reply).toBeVisible({ timeout: 90_000 })
   } finally {
     page.off("pageerror", onPageError)
     await sdk.session.delete({ sessionID }).catch(() => undefined)

+ 15 - 17
packages/app/e2e/session/session-undo-redo.spec.ts

@@ -10,21 +10,26 @@ async function seedConversation(input: {
   sessionID: string
   token: string
 }) {
+  const messages = async () =>
+    await input.sdk.session.messages({ sessionID: input.sessionID, limit: 100 }).then((r) => r.data ?? [])
+  const seeded = await messages()
+  const userIDs = new Set(seeded.filter((m) => m.info.role === "user").map((m) => m.info.id))
+
   const prompt = input.page.locator(promptSelector)
   await expect(prompt).toBeVisible()
-  await prompt.click()
-  await input.page.keyboard.type(`Reply with exactly: ${input.token}`)
-  await input.page.keyboard.press("Enter")
+  await input.sdk.session.promptAsync({
+    sessionID: input.sessionID,
+    noReply: true,
+    parts: [{ type: "text", text: input.token }],
+  })
 
   let userMessageID: string | undefined
   await expect
     .poll(
       async () => {
-        const messages = await input.sdk.session
-          .messages({ sessionID: input.sessionID, limit: 50 })
-          .then((r) => r.data ?? [])
-        const users = messages.filter(
+        const users = (await messages()).filter(
           (m) =>
+            !userIDs.has(m.info.id) &&
             m.info.role === "user" &&
             m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)),
         )
@@ -33,21 +38,14 @@ async function seedConversation(input: {
         const user = users[users.length - 1]
         if (!user) return false
         userMessageID = user.info.id
-
-        const assistantText = messages
-          .filter((m) => m.info.role === "assistant")
-          .flatMap((m) => m.parts)
-          .filter((p) => p.type === "text")
-          .map((p) => p.text)
-          .join("\n")
-
-        return assistantText.includes(input.token)
+        return true
       },
-      { timeout: 90_000 },
+      { timeout: 90_000, intervals: [250, 500, 1_000] },
     )
     .toBe(true)
 
   if (!userMessageID) throw new Error("Expected a user message id")
+  await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 })
   return { prompt, userMessageID }
 }
 

+ 31 - 14
packages/app/e2e/session/session.spec.ts

@@ -34,21 +34,34 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
 test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
   const stamp = Date.now()
   const originalTitle = `e2e rename test ${stamp}`
-  const newTitle = `e2e renamed ${stamp}`
+  const renamedTitle = `e2e renamed ${stamp}`
 
   await withSession(sdk, originalTitle, async (session) => {
     await seedMessage(sdk, session.id)
     await gotoSession(session.id)
+    await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
 
     const menu = await openSessionMoreMenu(page, session.id)
     await clickMenuItem(menu, /rename/i)
 
     const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
     await expect(input).toBeVisible()
-    await input.fill(newTitle)
+    await expect(input).toBeFocused()
+    await input.fill(renamedTitle)
+    await expect(input).toHaveValue(renamedTitle)
     await input.press("Enter")
 
-    await expect(page.getByRole("heading", { level: 1 }).first()).toContainText(newTitle)
+    await expect
+      .poll(
+        async () => {
+          const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+          return data?.title
+        },
+        { timeout: 30_000 },
+      )
+      .toBe(renamedTitle)
+
+    await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
   })
 })
 
@@ -116,8 +129,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
     await seedMessage(sdk, session.id)
     await gotoSession(session.id)
 
-    const { rightSection, popoverBody } = await openSharePopover(page)
-    await popoverBody.getByRole("button", { name: "Publish" }).first().click()
+    const shared = await openSharePopover(page)
+    const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
+    await expect(publish).toBeVisible({ timeout: 30_000 })
+    await publish.click()
+
+    await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
+      timeout: 30_000,
+    })
 
     await expect
       .poll(
@@ -129,14 +148,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
       )
       .not.toBeUndefined()
 
-    const copyButton = rightSection.locator('button[aria-label="Copy link"]').first()
-    await expect(copyButton).toBeVisible({ timeout: 30_000 })
-
-    const sharedPopover = await openSharePopover(page)
-    const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first()
+    const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
     await expect(unpublish).toBeVisible({ timeout: 30_000 })
     await unpublish.click()
 
+    await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
+      timeout: 30_000,
+    })
+
     await expect
       .poll(
         async () => {
@@ -147,10 +166,8 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
       )
       .toBeUndefined()
 
-    await expect(copyButton).not.toBeVisible({ timeout: 30_000 })
-
-    const unsharedPopover = await openSharePopover(page)
-    await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
+    const unshared = await openSharePopover(page)
+    await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
       timeout: 30_000,
     })
   })

+ 99 - 74
packages/app/src/app.tsx

@@ -1,5 +1,5 @@
 import "@/index.css"
-import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js"
+import { ErrorBoundary, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
 import { Router, Route, Navigate } from "@solidjs/router"
 import { MetaProvider } from "@solidjs/meta"
 import { Font } from "@opencode-ai/ui/font"
@@ -30,12 +30,26 @@ import { HighlightsProvider } from "@/context/highlights"
 import Layout from "@/pages/layout"
 import DirectoryLayout from "@/pages/directory-layout"
 import { ErrorPage } from "./pages/error"
-import { Suspense, JSX } from "solid-js"
-
 const Home = lazy(() => import("@/pages/home"))
 const Session = lazy(() => import("@/pages/session"))
 const Loading = () => <div class="size-full" />
 
+const HomeRoute = () => (
+  <Suspense fallback={<Loading />}>
+    <Home />
+  </Suspense>
+)
+
+const SessionRoute = () => (
+  <SessionProviders>
+    <Suspense fallback={<Loading />}>
+      <Session />
+    </Suspense>
+  </SessionProviders>
+)
+
+const SessionIndexRoute = () => <Navigate href="session" />
+
 function UiI18nBridge(props: ParentProps) {
   const language = useLanguage()
   return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
@@ -52,6 +66,71 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
   return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
 }
 
+function AppShellProviders(props: ParentProps) {
+  return (
+    <SettingsProvider>
+      <PermissionProvider>
+        <LayoutProvider>
+          <NotificationProvider>
+            <ModelsProvider>
+              <CommandProvider>
+                <HighlightsProvider>
+                  <Layout>{props.children}</Layout>
+                </HighlightsProvider>
+              </CommandProvider>
+            </ModelsProvider>
+          </NotificationProvider>
+        </LayoutProvider>
+      </PermissionProvider>
+    </SettingsProvider>
+  )
+}
+
+function SessionProviders(props: ParentProps) {
+  return (
+    <TerminalProvider>
+      <FileProvider>
+        <PromptProvider>
+          <CommentsProvider>{props.children}</CommentsProvider>
+        </PromptProvider>
+      </FileProvider>
+    </TerminalProvider>
+  )
+}
+
+function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
+  return (
+    <AppShellProviders>
+      {props.appChildren}
+      {props.children}
+    </AppShellProviders>
+  )
+}
+
+const getStoredDefaultServerUrl = (platform: ReturnType<typeof usePlatform>) => {
+  if (platform.platform !== "web") return
+  const result = platform.getDefaultServerUrl?.()
+  if (result instanceof Promise) return
+  if (!result) return
+  return normalizeServerUrl(result)
+}
+
+const resolveDefaultServerUrl = (props: {
+  defaultUrl?: string
+  storedDefaultServerUrl?: string
+  hostname: string
+  origin: string
+  isDev: boolean
+  devHost?: string
+  devPort?: string
+}) => {
+  if (props.defaultUrl) return props.defaultUrl
+  if (props.storedDefaultServerUrl) return props.storedDefaultServerUrl
+  if (props.hostname.includes("opencode.ai")) return "http://localhost:4096"
+  if (props.isDev) return `http://${props.devHost ?? "localhost"}:${props.devPort ?? "4096"}`
+  return props.origin
+}
+
 export function AppBaseProviders(props: ParentProps) {
   return (
     <MetaProvider>
@@ -77,89 +156,35 @@ export function AppBaseProviders(props: ParentProps) {
 
 function ServerKey(props: ParentProps) {
   const server = useServer()
-  return (
-    <Show when={server.url} keyed>
-      {props.children}
-    </Show>
-  )
+  if (!server.url) return null
+  return props.children
 }
 
 export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
   const platform = usePlatform()
-
-  const stored = (() => {
-    if (platform.platform !== "web") return
-    const result = platform.getDefaultServerUrl?.()
-    if (result instanceof Promise) return
-    if (!result) return
-    return normalizeServerUrl(result)
-  })()
-
-  const defaultServerUrl = () => {
-    if (props.defaultUrl) return props.defaultUrl
-    if (stored) return stored
-    if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
-    if (import.meta.env.DEV)
-      return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
-
-    return window.location.origin
-  }
+  const storedDefaultServerUrl = getStoredDefaultServerUrl(platform)
+  const defaultServerUrl = resolveDefaultServerUrl({
+    defaultUrl: props.defaultUrl,
+    storedDefaultServerUrl,
+    hostname: location.hostname,
+    origin: window.location.origin,
+    isDev: import.meta.env.DEV,
+    devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST,
+    devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT,
+  })
 
   return (
-    <ServerProvider defaultUrl={defaultServerUrl()} isSidecar={props.isSidecar}>
+    <ServerProvider defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}>
       <ServerKey>
         <GlobalSDKProvider>
           <GlobalSyncProvider>
             <Router
-              root={(routerProps) => (
-                <SettingsProvider>
-                  <PermissionProvider>
-                    <LayoutProvider>
-                      <NotificationProvider>
-                        <ModelsProvider>
-                          <CommandProvider>
-                            <HighlightsProvider>
-                              <Layout>
-                                {props.children}
-                                {routerProps.children}
-                              </Layout>
-                            </HighlightsProvider>
-                          </CommandProvider>
-                        </ModelsProvider>
-                      </NotificationProvider>
-                    </LayoutProvider>
-                  </PermissionProvider>
-                </SettingsProvider>
-              )}
+              root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
             >
-              <Route
-                path="/"
-                component={() => (
-                  <Suspense fallback={<Loading />}>
-                    <Home />
-                  </Suspense>
-                )}
-              />
+              <Route path="/" component={HomeRoute} />
               <Route path="/:dir" component={DirectoryLayout}>
-                <Route path="/" component={() => <Navigate href="session" />} />
-                <Route
-                  path="/session/:id?"
-                  component={(p) => (
-                    <Show when={p.params.id ?? "new"}>
-                      <TerminalProvider>
-                        <FileProvider>
-                          <PromptProvider>
-                            <CommentsProvider>
-                              <Suspense fallback={<Loading />}>
-                                <Session />
-                              </Suspense>
-                            </CommentsProvider>
-                          </PromptProvider>
-                        </FileProvider>
-                      </TerminalProvider>
-                    </Show>
-                  )}
-                />
+                <Route path="/" component={SessionIndexRoute} />
+                <Route path="/session/:id?" component={SessionRoute} />
               </Route>
             </Router>
           </GlobalSyncProvider>

+ 311 - 283
packages/app/src/components/dialog-connect-provider.tsx

@@ -10,7 +10,6 @@ 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 { iife } from "@opencode-ai/util/iife"
 import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { Link } from "@/components/link"
@@ -55,6 +54,47 @@ export function DialogConnectProvider(props: { provider: string }) {
     error: undefined as string | undefined,
   })
 
+  type Action =
+    | { type: "method.select"; index: number }
+    | { type: "method.reset" }
+    | { type: "auth.pending" }
+    | { type: "auth.complete"; authorization: ProviderAuthAuthorization }
+    | { type: "auth.error"; error: string }
+
+  function dispatch(action: Action) {
+    setStore(
+      produce((draft) => {
+        if (action.type === "method.select") {
+          draft.methodIndex = action.index
+          draft.authorization = undefined
+          draft.state = undefined
+          draft.error = undefined
+          return
+        }
+        if (action.type === "method.reset") {
+          draft.methodIndex = undefined
+          draft.authorization = undefined
+          draft.state = undefined
+          draft.error = undefined
+          return
+        }
+        if (action.type === "auth.pending") {
+          draft.state = "pending"
+          draft.error = undefined
+          return
+        }
+        if (action.type === "auth.complete") {
+          draft.state = "complete"
+          draft.authorization = action.authorization
+          draft.error = undefined
+          return
+        }
+        draft.state = "error"
+        draft.error = action.error
+      }),
+    )
+  }
+
   const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
 
   const methodLabel = (value?: { type?: string; label?: string }) => {
@@ -70,17 +110,10 @@ export function DialogConnectProvider(props: { provider: string }) {
     }
 
     const method = methods()[index]
-    setStore(
-      produce((draft) => {
-        draft.methodIndex = index
-        draft.authorization = undefined
-        draft.state = undefined
-        draft.error = undefined
-      }),
-    )
+    dispatch({ type: "method.select", index })
 
     if (method.type === "oauth") {
-      setStore("state", "pending")
+      dispatch({ type: "auth.pending" })
       const start = Date.now()
       await globalSDK.client.provider.oauth
         .authorize(
@@ -100,18 +133,15 @@ export function DialogConnectProvider(props: { provider: string }) {
             timer.current = setTimeout(() => {
               timer.current = undefined
               if (!alive.value) return
-              setStore("state", "complete")
-              setStore("authorization", x.data!)
+              dispatch({ type: "auth.complete", authorization: x.data! })
             }, delay)
             return
           }
-          setStore("state", "complete")
-          setStore("authorization", x.data!)
+          dispatch({ type: "auth.complete", authorization: x.data! })
         })
         .catch((e) => {
           if (!alive.value) return
-          setStore("state", "error")
-          setStore("error", String(e))
+          dispatch({ type: "auth.error", error: String(e) })
         })
     }
   }
@@ -129,10 +159,6 @@ export function DialogConnectProvider(props: { provider: string }) {
     if (methods().length === 1) {
       selectMethod(0)
     }
-    document.addEventListener("keydown", handleKey)
-    onCleanup(() => {
-      document.removeEventListener("keydown", handleKey)
-    })
   })
 
   async function complete() {
@@ -152,17 +178,244 @@ export function DialogConnectProvider(props: { provider: string }) {
       return
     }
     if (store.authorization) {
-      setStore("authorization", undefined)
-      setStore("methodIndex", undefined)
+      dispatch({ type: "method.reset" })
       return
     }
-    if (store.methodIndex) {
-      setStore("methodIndex", undefined)
+    if (store.methodIndex !== undefined) {
+      dispatch({ type: "method.reset" })
       return
     }
     dialog.show(() => <DialogSelectProvider />)
   }
 
+  function MethodSelection() {
+    return (
+      <>
+        <div class="text-14-regular text-text-base">
+          {language.t("provider.connect.selectMethod", { provider: provider().name })}
+        </div>
+        <div>
+          <List
+            ref={(ref) => {
+              listRef = ref
+            }}
+            items={methods}
+            key={(m) => m?.label}
+            onSelect={async (selected, index) => {
+              if (!selected) return
+              selectMethod(index)
+            }}
+          >
+            {(i) => (
+              <div class="w-full flex items-center gap-x-2">
+                <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
+                  <div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
+                </div>
+                <span>{methodLabel(i)}</span>
+              </div>
+            )}
+          </List>
+        </div>
+      </>
+    )
+  }
+
+  function ApiAuthView() {
+    const [formStore, setFormStore] = createStore({
+      value: "",
+      error: undefined as string | undefined,
+    })
+
+    async function handleSubmit(e: SubmitEvent) {
+      e.preventDefault()
+
+      const form = e.currentTarget as HTMLFormElement
+      const formData = new FormData(form)
+      const apiKey = formData.get("apiKey") as string
+
+      if (!apiKey?.trim()) {
+        setFormStore("error", language.t("provider.connect.apiKey.required"))
+        return
+      }
+
+      setFormStore("error", undefined)
+      await globalSDK.client.auth.set({
+        providerID: props.provider,
+        auth: {
+          type: "api",
+          key: apiKey,
+        },
+      })
+      await complete()
+    }
+
+    return (
+      <div class="flex flex-col gap-6">
+        <Switch>
+          <Match when={provider().id === "opencode"}>
+            <div class="flex flex-col gap-4">
+              <div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line1")}</div>
+              <div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
+              <div class="text-14-regular text-text-base">
+                {language.t("provider.connect.opencodeZen.visit.prefix")}
+                <Link href="https://opencode.ai/zen" tabIndex={-1}>
+                  {language.t("provider.connect.opencodeZen.visit.link")}
+                </Link>
+                {language.t("provider.connect.opencodeZen.visit.suffix")}
+              </div>
+            </div>
+          </Match>
+          <Match when={true}>
+            <div class="text-14-regular text-text-base">
+              {language.t("provider.connect.apiKey.description", { provider: provider().name })}
+            </div>
+          </Match>
+        </Switch>
+        <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
+          <TextField
+            autofocus
+            type="text"
+            label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
+            placeholder={language.t("provider.connect.apiKey.placeholder")}
+            name="apiKey"
+            value={formStore.value}
+            onChange={(v) => setFormStore("value", v)}
+            validationState={formStore.error ? "invalid" : undefined}
+            error={formStore.error}
+          />
+          <Button class="w-auto" type="submit" size="large" variant="primary">
+            {language.t("common.submit")}
+          </Button>
+        </form>
+      </div>
+    )
+  }
+
+  function OAuthCodeView() {
+    const [formStore, setFormStore] = createStore({
+      value: "",
+      error: undefined as string | undefined,
+    })
+
+    onMount(() => {
+      if (store.authorization?.method === "code" && store.authorization?.url) {
+        platform.openLink(store.authorization.url)
+      }
+    })
+
+    async function handleSubmit(e: SubmitEvent) {
+      e.preventDefault()
+
+      const form = e.currentTarget as HTMLFormElement
+      const formData = new FormData(form)
+      const code = formData.get("code") as string
+
+      if (!code?.trim()) {
+        setFormStore("error", language.t("provider.connect.oauth.code.required"))
+        return
+      }
+
+      setFormStore("error", undefined)
+      const result = await globalSDK.client.provider.oauth
+        .callback({
+          providerID: props.provider,
+          method: store.methodIndex,
+          code,
+        })
+        .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
+        .catch((error) => ({ ok: false as const, error }))
+      if (result.ok) {
+        await complete()
+        return
+      }
+      const message = result.error instanceof Error ? result.error.message : String(result.error)
+      setFormStore("error", message || language.t("provider.connect.oauth.code.invalid"))
+    }
+
+    return (
+      <div class="flex flex-col gap-6">
+        <div class="text-14-regular text-text-base">
+          {language.t("provider.connect.oauth.code.visit.prefix")}
+          <Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
+          {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
+        </div>
+        <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
+          <TextField
+            autofocus
+            type="text"
+            label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
+            placeholder={language.t("provider.connect.oauth.code.placeholder")}
+            name="code"
+            value={formStore.value}
+            onChange={(v) => setFormStore("value", v)}
+            validationState={formStore.error ? "invalid" : undefined}
+            error={formStore.error}
+          />
+          <Button class="w-auto" type="submit" size="large" variant="primary">
+            {language.t("common.submit")}
+          </Button>
+        </form>
+      </div>
+    )
+  }
+
+  function OAuthAutoView() {
+    const code = createMemo(() => {
+      const instructions = store.authorization?.instructions
+      if (instructions?.includes(":")) {
+        return instructions.split(":")[1]?.trim()
+      }
+      return instructions
+    })
+
+    onMount(() => {
+      void (async () => {
+        if (store.authorization?.url) {
+          platform.openLink(store.authorization.url)
+        }
+
+        const result = await globalSDK.client.provider.oauth
+          .callback({
+            providerID: props.provider,
+            method: store.methodIndex,
+          })
+          .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
+          .catch((error) => ({ ok: false as const, error }))
+
+        if (!alive.value) return
+
+        if (!result.ok) {
+          const message = result.error instanceof Error ? result.error.message : String(result.error)
+          dispatch({ type: "auth.error", error: message })
+          return
+        }
+
+        await complete()
+      })()
+    })
+
+    return (
+      <div class="flex flex-col gap-6">
+        <div class="text-14-regular text-text-base">
+          {language.t("provider.connect.oauth.auto.visit.prefix")}
+          <Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
+          {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
+        </div>
+        <TextField
+          label={language.t("provider.connect.oauth.auto.confirmationCode")}
+          class="font-mono"
+          value={code()}
+          readOnly
+          copyable
+        />
+        <div class="text-14-regular text-text-base flex items-center gap-4">
+          <Spinner />
+          <span>{language.t("provider.connect.status.waiting")}</span>
+        </div>
+      </div>
+    )
+  }
+
   return (
     <Dialog
       title={
@@ -188,267 +441,42 @@ export function DialogConnectProvider(props: { provider: string }) {
           </div>
         </div>
         <div class="px-2.5 pb-10 flex flex-col gap-6">
-          <Switch>
-            <Match when={store.methodIndex === undefined}>
-              <div class="text-14-regular text-text-base">
-                {language.t("provider.connect.selectMethod", { provider: provider().name })}
-              </div>
-              <div class="">
-                <List
-                  ref={(ref) => {
-                    listRef = ref
-                  }}
-                  items={methods}
-                  key={(m) => m?.label}
-                  onSelect={async (method, index) => {
-                    if (!method) return
-                    selectMethod(index)
-                  }}
-                >
-                  {(i) => (
-                    <div class="w-full flex items-center gap-x-2">
-                      <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
-                        <div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
-                      </div>
-                      <span>{methodLabel(i)}</span>
-                    </div>
-                  )}
-                </List>
-              </div>
-            </Match>
-            <Match when={store.state === "pending"}>
-              <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.state === "error"}>
-              <div class="text-14-regular text-text-base">
-                <div class="flex items-center gap-x-2">
-                  <Icon name="circle-ban-sign" class="text-icon-critical-base" />
-                  <span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
+          <div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
+            <Switch>
+              <Match when={store.methodIndex === undefined}>
+                <MethodSelection />
+              </Match>
+              <Match when={store.state === "pending"}>
+                <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>
-              </div>
-            </Match>
-            <Match when={method()?.type === "api"}>
-              {iife(() => {
-                const [formStore, setFormStore] = createStore({
-                  value: "",
-                  error: undefined as string | undefined,
-                })
-
-                async function handleSubmit(e: SubmitEvent) {
-                  e.preventDefault()
-
-                  const form = e.currentTarget as HTMLFormElement
-                  const formData = new FormData(form)
-                  const apiKey = formData.get("apiKey") as string
-
-                  if (!apiKey?.trim()) {
-                    setFormStore("error", language.t("provider.connect.apiKey.required"))
-                    return
-                  }
-
-                  setFormStore("error", undefined)
-                  await globalSDK.client.auth.set({
-                    providerID: props.provider,
-                    auth: {
-                      type: "api",
-                      key: apiKey,
-                    },
-                  })
-                  await complete()
-                }
-
-                return (
-                  <div class="flex flex-col gap-6">
-                    <Switch>
-                      <Match when={provider().id === "opencode"}>
-                        <div class="flex flex-col gap-4">
-                          <div class="text-14-regular text-text-base">
-                            {language.t("provider.connect.opencodeZen.line1")}
-                          </div>
-                          <div class="text-14-regular text-text-base">
-                            {language.t("provider.connect.opencodeZen.line2")}
-                          </div>
-                          <div class="text-14-regular text-text-base">
-                            {language.t("provider.connect.opencodeZen.visit.prefix")}
-                            <Link href="https://opencode.ai/zen" tabIndex={-1}>
-                              {language.t("provider.connect.opencodeZen.visit.link")}
-                            </Link>
-                            {language.t("provider.connect.opencodeZen.visit.suffix")}
-                          </div>
-                        </div>
-                      </Match>
-                      <Match when={true}>
-                        <div class="text-14-regular text-text-base">
-                          {language.t("provider.connect.apiKey.description", { provider: provider().name })}
-                        </div>
-                      </Match>
-                    </Switch>
-                    <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
-                      <TextField
-                        autofocus
-                        type="text"
-                        label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
-                        placeholder={language.t("provider.connect.apiKey.placeholder")}
-                        name="apiKey"
-                        value={formStore.value}
-                        onChange={setFormStore.bind(null, "value")}
-                        validationState={formStore.error ? "invalid" : undefined}
-                        error={formStore.error}
-                      />
-                      <Button class="w-auto" type="submit" size="large" variant="primary">
-                        {language.t("common.submit")}
-                      </Button>
-                    </form>
+              </Match>
+              <Match when={store.state === "error"}>
+                <div class="text-14-regular text-text-base">
+                  <div class="flex items-center gap-x-2">
+                    <Icon name="circle-ban-sign" class="text-icon-critical-base" />
+                    <span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
                   </div>
-                )
-              })}
-            </Match>
-            <Match when={method()?.type === "oauth"}>
-              <Switch>
-                <Match when={store.authorization?.method === "code"}>
-                  {iife(() => {
-                    const [formStore, setFormStore] = createStore({
-                      value: "",
-                      error: undefined as string | undefined,
-                    })
-
-                    onMount(() => {
-                      if (store.authorization?.method === "code" && store.authorization?.url) {
-                        platform.openLink(store.authorization.url)
-                      }
-                    })
-
-                    async function handleSubmit(e: SubmitEvent) {
-                      e.preventDefault()
-
-                      const form = e.currentTarget as HTMLFormElement
-                      const formData = new FormData(form)
-                      const code = formData.get("code") as string
-
-                      if (!code?.trim()) {
-                        setFormStore("error", language.t("provider.connect.oauth.code.required"))
-                        return
-                      }
-
-                      setFormStore("error", undefined)
-                      const result = await globalSDK.client.provider.oauth
-                        .callback({
-                          providerID: props.provider,
-                          method: store.methodIndex,
-                          code,
-                        })
-                        .then((value) =>
-                          value.error ? { ok: false as const, error: value.error } : { ok: true as const },
-                        )
-                        .catch((error) => ({ ok: false as const, error }))
-                      if (result.ok) {
-                        await complete()
-                        return
-                      }
-                      const message = result.error instanceof Error ? result.error.message : String(result.error)
-                      setFormStore("error", message || language.t("provider.connect.oauth.code.invalid"))
-                    }
-
-                    return (
-                      <div class="flex flex-col gap-6">
-                        <div class="text-14-regular text-text-base">
-                          {language.t("provider.connect.oauth.code.visit.prefix")}
-                          <Link href={store.authorization!.url}>
-                            {language.t("provider.connect.oauth.code.visit.link")}
-                          </Link>
-                          {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
-                        </div>
-                        <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
-                          <TextField
-                            autofocus
-                            type="text"
-                            label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
-                            placeholder={language.t("provider.connect.oauth.code.placeholder")}
-                            name="code"
-                            value={formStore.value}
-                            onChange={setFormStore.bind(null, "value")}
-                            validationState={formStore.error ? "invalid" : undefined}
-                            error={formStore.error}
-                          />
-                          <Button class="w-auto" type="submit" size="large" variant="primary">
-                            {language.t("common.submit")}
-                          </Button>
-                        </form>
-                      </div>
-                    )
-                  })}
-                </Match>
-                <Match when={store.authorization?.method === "auto"}>
-                  {iife(() => {
-                    const code = createMemo(() => {
-                      const instructions = store.authorization?.instructions
-                      if (instructions?.includes(":")) {
-                        return instructions?.split(":")[1]?.trim()
-                      }
-                      return instructions
-                    })
-
-                    onMount(() => {
-                      void (async () => {
-                        if (store.authorization?.url) {
-                          platform.openLink(store.authorization.url)
-                        }
-
-                        const result = await globalSDK.client.provider.oauth
-                          .callback({
-                            providerID: props.provider,
-                            method: store.methodIndex,
-                          })
-                          .then((value) =>
-                            value.error ? { ok: false as const, error: value.error } : { ok: true as const },
-                          )
-                          .catch((error) => ({ ok: false as const, error }))
-
-                        if (!alive.value) return
-
-                        if (!result.ok) {
-                          const message = result.error instanceof Error ? result.error.message : String(result.error)
-                          setStore("state", "error")
-                          setStore("error", message)
-                          return
-                        }
-
-                        await complete()
-                      })()
-                    })
-
-                    return (
-                      <div class="flex flex-col gap-6">
-                        <div class="text-14-regular text-text-base">
-                          {language.t("provider.connect.oauth.auto.visit.prefix")}
-                          <Link href={store.authorization!.url}>
-                            {language.t("provider.connect.oauth.auto.visit.link")}
-                          </Link>
-                          {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
-                        </div>
-                        <TextField
-                          label={language.t("provider.connect.oauth.auto.confirmationCode")}
-                          class="font-mono"
-                          value={code()}
-                          readOnly
-                          copyable
-                        />
-                        <div class="text-14-regular text-text-base flex items-center gap-4">
-                          <Spinner />
-                          <span>{language.t("provider.connect.status.waiting")}</span>
-                        </div>
-                      </div>
-                    )
-                  })}
-                </Match>
-              </Switch>
-            </Match>
-          </Switch>
+                </div>
+              </Match>
+              <Match when={method()?.type === "api"}>
+                <ApiAuthView />
+              </Match>
+              <Match when={method()?.type === "oauth"}>
+                <Switch>
+                  <Match when={store.authorization?.method === "code"}>
+                    <OAuthCodeView />
+                  </Match>
+                  <Match when={store.authorization?.method === "auto"}>
+                    <OAuthAutoView />
+                  </Match>
+                </Switch>
+              </Match>
+            </Switch>
+          </div>
         </div>
       </div>
     </Dialog>

+ 168 - 160
packages/app/src/components/dialog-custom-provider.tsx

@@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast } from "@opencode-ai/ui/toast"
 import { For } from "solid-js"
-import { createStore, produce } from "solid-js/store"
+import { createStore } from "solid-js/store"
 import { Link } from "@/components/link"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { useGlobalSync } from "@/context/global-sync"
@@ -16,6 +16,147 @@ import { DialogSelectProvider } from "./dialog-select-provider"
 const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
 const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
 
+type Translator = ReturnType<typeof useLanguage>["t"]
+
+type ModelRow = {
+  id: string
+  name: string
+}
+
+type HeaderRow = {
+  key: string
+  value: string
+}
+
+type FormState = {
+  providerID: string
+  name: string
+  baseURL: string
+  apiKey: string
+  models: ModelRow[]
+  headers: HeaderRow[]
+  saving: boolean
+}
+
+type FormErrors = {
+  providerID: string | undefined
+  name: string | undefined
+  baseURL: string | undefined
+  models: Array<{ id?: string; name?: string }>
+  headers: Array<{ key?: string; value?: string }>
+}
+
+type ValidateArgs = {
+  form: FormState
+  t: Translator
+  disabledProviders: string[]
+  existingProviderIDs: Set<string>
+}
+
+function validateCustomProvider(input: ValidateArgs) {
+  const providerID = input.form.providerID.trim()
+  const name = input.form.name.trim()
+  const baseURL = input.form.baseURL.trim()
+  const apiKey = input.form.apiKey.trim()
+
+  const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
+  const key = apiKey && !env ? apiKey : undefined
+
+  const idError = !providerID
+    ? input.t("provider.custom.error.providerID.required")
+    : !PROVIDER_ID.test(providerID)
+      ? input.t("provider.custom.error.providerID.format")
+      : undefined
+
+  const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
+  const urlError = !baseURL
+    ? input.t("provider.custom.error.baseURL.required")
+    : !/^https?:\/\//.test(baseURL)
+      ? input.t("provider.custom.error.baseURL.format")
+      : undefined
+
+  const disabled = input.disabledProviders.includes(providerID)
+  const existsError = idError
+    ? undefined
+    : input.existingProviderIDs.has(providerID) && !disabled
+      ? input.t("provider.custom.error.providerID.exists")
+      : undefined
+
+  const seenModels = new Set<string>()
+  const modelErrors = input.form.models.map((m) => {
+    const id = m.id.trim()
+    const modelIdError = !id
+      ? input.t("provider.custom.error.required")
+      : seenModels.has(id)
+        ? input.t("provider.custom.error.duplicate")
+        : (() => {
+            seenModels.add(id)
+            return undefined
+          })()
+    const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
+    return { id: modelIdError, name: modelNameError }
+  })
+  const modelsValid = modelErrors.every((m) => !m.id && !m.name)
+  const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
+
+  const seenHeaders = new Set<string>()
+  const headerErrors = input.form.headers.map((h) => {
+    const key = h.key.trim()
+    const value = h.value.trim()
+
+    if (!key && !value) return {}
+    const keyError = !key
+      ? input.t("provider.custom.error.required")
+      : seenHeaders.has(key.toLowerCase())
+        ? input.t("provider.custom.error.duplicate")
+        : (() => {
+            seenHeaders.add(key.toLowerCase())
+            return undefined
+          })()
+    const valueError = !value ? input.t("provider.custom.error.required") : undefined
+    return { key: keyError, value: valueError }
+  })
+  const headersValid = headerErrors.every((h) => !h.key && !h.value)
+  const headers = Object.fromEntries(
+    input.form.headers
+      .map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
+      .filter((h) => !!h.key && !!h.value)
+      .map((h) => [h.key, h.value]),
+  )
+
+  const errors: FormErrors = {
+    providerID: idError ?? existsError,
+    name: nameError,
+    baseURL: urlError,
+    models: modelErrors,
+    headers: headerErrors,
+  }
+
+  const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
+  if (!ok) return { errors }
+
+  const options = {
+    baseURL,
+    ...(Object.keys(headers).length ? { headers } : {}),
+  }
+
+  return {
+    errors,
+    result: {
+      providerID,
+      name,
+      key,
+      config: {
+        npm: OPENAI_COMPATIBLE,
+        name,
+        ...(env ? { env: [env] } : {}),
+        options,
+        models,
+      },
+    },
+  }
+}
+
 type Props = {
   back?: "providers" | "close"
 }
@@ -26,7 +167,7 @@ export function DialogCustomProvider(props: Props) {
   const globalSDK = useGlobalSDK()
   const language = useLanguage()
 
-  const [form, setForm] = createStore({
+  const [form, setForm] = createStore<FormState>({
     providerID: "",
     name: "",
     baseURL: "",
@@ -36,12 +177,12 @@ export function DialogCustomProvider(props: Props) {
     saving: false,
   })
 
-  const [errors, setErrors] = createStore({
-    providerID: undefined as string | undefined,
-    name: undefined as string | undefined,
-    baseURL: undefined as string | undefined,
-    models: [{} as { id?: string; name?: string }],
-    headers: [{} as { key?: string; value?: string }],
+  const [errors, setErrors] = createStore<FormErrors>({
+    providerID: undefined,
+    name: undefined,
+    baseURL: undefined,
+    models: [{}],
+    headers: [{}],
   })
 
   const goBack = () => {
@@ -53,169 +194,36 @@ export function DialogCustomProvider(props: Props) {
   }
 
   const addModel = () => {
-    setForm(
-      "models",
-      produce((draft) => {
-        draft.push({ id: "", name: "" })
-      }),
-    )
-    setErrors(
-      "models",
-      produce((draft) => {
-        draft.push({})
-      }),
-    )
+    setForm("models", (v) => [...v, { id: "", name: "" }])
+    setErrors("models", (v) => [...v, {}])
   }
 
   const removeModel = (index: number) => {
     if (form.models.length <= 1) return
-    setForm(
-      "models",
-      produce((draft) => {
-        draft.splice(index, 1)
-      }),
-    )
-    setErrors(
-      "models",
-      produce((draft) => {
-        draft.splice(index, 1)
-      }),
-    )
+    setForm("models", (v) => v.filter((_, i) => i !== index))
+    setErrors("models", (v) => v.filter((_, i) => i !== index))
   }
 
   const addHeader = () => {
-    setForm(
-      "headers",
-      produce((draft) => {
-        draft.push({ key: "", value: "" })
-      }),
-    )
-    setErrors(
-      "headers",
-      produce((draft) => {
-        draft.push({})
-      }),
-    )
+    setForm("headers", (v) => [...v, { key: "", value: "" }])
+    setErrors("headers", (v) => [...v, {}])
   }
 
   const removeHeader = (index: number) => {
     if (form.headers.length <= 1) return
-    setForm(
-      "headers",
-      produce((draft) => {
-        draft.splice(index, 1)
-      }),
-    )
-    setErrors(
-      "headers",
-      produce((draft) => {
-        draft.splice(index, 1)
-      }),
-    )
+    setForm("headers", (v) => v.filter((_, i) => i !== index))
+    setErrors("headers", (v) => v.filter((_, i) => i !== index))
   }
 
   const validate = () => {
-    const providerID = form.providerID.trim()
-    const name = form.name.trim()
-    const baseURL = form.baseURL.trim()
-    const apiKey = form.apiKey.trim()
-
-    const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
-    const key = apiKey && !env ? apiKey : undefined
-
-    const idError = !providerID
-      ? language.t("provider.custom.error.providerID.required")
-      : !PROVIDER_ID.test(providerID)
-        ? language.t("provider.custom.error.providerID.format")
-        : undefined
-
-    const nameError = !name ? language.t("provider.custom.error.name.required") : undefined
-    const urlError = !baseURL
-      ? language.t("provider.custom.error.baseURL.required")
-      : !/^https?:\/\//.test(baseURL)
-        ? language.t("provider.custom.error.baseURL.format")
-        : undefined
-
-    const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID)
-    const existingProvider = globalSync.data.provider.all.find((p) => p.id === providerID)
-    const existsError = idError
-      ? undefined
-      : existingProvider && !disabled
-        ? language.t("provider.custom.error.providerID.exists")
-        : undefined
-
-    const seenModels = new Set<string>()
-    const modelErrors = form.models.map((m) => {
-      const id = m.id.trim()
-      const modelIdError = !id
-        ? language.t("provider.custom.error.required")
-        : seenModels.has(id)
-          ? language.t("provider.custom.error.duplicate")
-          : (() => {
-              seenModels.add(id)
-              return undefined
-            })()
-      const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined
-      return { id: modelIdError, name: modelNameError }
+    const output = validateCustomProvider({
+      form,
+      t: language.t,
+      disabledProviders: globalSync.data.config.disabled_providers ?? [],
+      existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
     })
-    const modelsValid = modelErrors.every((m) => !m.id && !m.name)
-    const models = Object.fromEntries(form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
-
-    const seenHeaders = new Set<string>()
-    const headerErrors = form.headers.map((h) => {
-      const key = h.key.trim()
-      const value = h.value.trim()
-
-      if (!key && !value) return {}
-      const keyError = !key
-        ? language.t("provider.custom.error.required")
-        : seenHeaders.has(key.toLowerCase())
-          ? language.t("provider.custom.error.duplicate")
-          : (() => {
-              seenHeaders.add(key.toLowerCase())
-              return undefined
-            })()
-      const valueError = !value ? language.t("provider.custom.error.required") : undefined
-      return { key: keyError, value: valueError }
-    })
-    const headersValid = headerErrors.every((h) => !h.key && !h.value)
-    const headers = Object.fromEntries(
-      form.headers
-        .map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
-        .filter((h) => !!h.key && !!h.value)
-        .map((h) => [h.key, h.value]),
-    )
-
-    setErrors(
-      produce((draft) => {
-        draft.providerID = idError ?? existsError
-        draft.name = nameError
-        draft.baseURL = urlError
-        draft.models = modelErrors
-        draft.headers = headerErrors
-      }),
-    )
-
-    const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
-    if (!ok) return
-
-    const options = {
-      baseURL,
-      ...(Object.keys(headers).length ? { headers } : {}),
-    }
-
-    return {
-      providerID,
-      name,
-      key,
-      config: {
-        npm: OPENAI_COMPATIBLE,
-        name,
-        ...(env ? { env: [env] } : {}),
-        options,
-        models,
-      },
-    }
+    setErrors(output.errors)
+    return output.result
   }
 
   const save = async (e: SubmitEvent) => {
@@ -297,7 +305,7 @@ export function DialogCustomProvider(props: Props) {
               placeholder={language.t("provider.custom.field.providerID.placeholder")}
               description={language.t("provider.custom.field.providerID.description")}
               value={form.providerID}
-              onChange={setForm.bind(null, "providerID")}
+              onChange={(v) => setForm("providerID", v)}
               validationState={errors.providerID ? "invalid" : undefined}
               error={errors.providerID}
             />
@@ -305,7 +313,7 @@ export function DialogCustomProvider(props: Props) {
               label={language.t("provider.custom.field.name.label")}
               placeholder={language.t("provider.custom.field.name.placeholder")}
               value={form.name}
-              onChange={setForm.bind(null, "name")}
+              onChange={(v) => setForm("name", v)}
               validationState={errors.name ? "invalid" : undefined}
               error={errors.name}
             />
@@ -313,7 +321,7 @@ export function DialogCustomProvider(props: Props) {
               label={language.t("provider.custom.field.baseURL.label")}
               placeholder={language.t("provider.custom.field.baseURL.placeholder")}
               value={form.baseURL}
-              onChange={setForm.bind(null, "baseURL")}
+              onChange={(v) => setForm("baseURL", v)}
               validationState={errors.baseURL ? "invalid" : undefined}
               error={errors.baseURL}
             />
@@ -322,7 +330,7 @@ export function DialogCustomProvider(props: Props) {
               placeholder={language.t("provider.custom.field.apiKey.placeholder")}
               description={language.t("provider.custom.field.apiKey.description")}
               value={form.apiKey}
-              onChange={setForm.bind(null, "apiKey")}
+              onChange={(v) => setForm("apiKey", v)}
             />
           </div>
 

+ 41 - 26
packages/app/src/components/dialog-edit-project.tsx

@@ -33,6 +33,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
     iconHover: false,
   })
 
+  let iconInput: HTMLInputElement | undefined
+
   function handleFileSelect(file: File) {
     if (!file.type.startsWith("image/")) return
     const reader = new FileReader()
@@ -72,31 +74,35 @@ export function DialogEditProject(props: { project: LocalProject }) {
   async function handleSubmit(e: SubmitEvent) {
     e.preventDefault()
 
-    setStore("saving", true)
-    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)
-      setStore("saving", false)
-      dialog.close()
-      return
-    }
+    await Promise.resolve()
+      .then(async () => {
+        setStore("saving", true)
+        const name = store.name.trim() === folderName() ? "" : store.name.trim()
+        const start = store.startup.trim()
 
-    globalSync.project.meta(props.project.worktree, {
-      name,
-      icon: { color: store.color, override: store.iconUrl || undefined },
-      commands: { start: start || undefined },
-    })
-    setStore("saving", false)
-    dialog.close()
+        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, {
+          name,
+          icon: { color: store.color, override: store.iconUrl || undefined },
+          commands: { start: start || undefined },
+        })
+        dialog.close()
+      })
+      .finally(() => {
+        setStore("saving", false)
+      })
   }
 
   return (
@@ -134,7 +140,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
                     if (store.iconUrl && store.iconHover) {
                       clearIcon()
                     } else {
-                      document.getElementById("icon-upload")?.click()
+                      iconInput?.click()
                     }
                   }}
                 >
@@ -176,7 +182,16 @@ export function DialogEditProject(props: { project: LocalProject }) {
                   <Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
                 </div>
               </div>
-              <input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
+              <input
+                id="icon-upload"
+                ref={(el) => {
+                  iconInput = el
+                }}
+                type="file"
+                accept="image/*"
+                class="hidden"
+                onChange={handleInputChange}
+              />
               <div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
                 <span>{language.t("dialog.project.edit.icon.hint")}</span>
                 <span>{language.t("dialog.project.edit.icon.recommended")}</span>

+ 17 - 8
packages/app/src/components/dialog-fork.tsx

@@ -6,6 +6,7 @@ import { usePrompt } from "@/context/prompt"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { List } from "@opencode-ai/ui/list"
+import { showToast } from "@opencode-ai/ui/toast"
 import { extractPromptFromParts } from "@/utils/prompt"
 import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
 import { base64Encode } from "@opencode-ai/util/encode"
@@ -66,15 +67,23 @@ export const DialogFork: Component = () => {
       attachmentName: language.t("common.attachment"),
     })
 
-    dialog.close()
-
-    sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => {
-      if (!forked.data) return
-      navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
-      requestAnimationFrame(() => {
-        prompt.set(restored)
+    sdk.client.session
+      .fork({ sessionID, messageID: item.id })
+      .then((forked) => {
+        if (!forked.data) {
+          showToast({ title: language.t("common.requestFailed") })
+          return
+        }
+        dialog.close()
+        navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
+        requestAnimationFrame(() => {
+          prompt.set(restored)
+        })
+      })
+      .catch((err: unknown) => {
+        const message = err instanceof Error ? err.message : String(err)
+        showToast({ title: language.t("common.requestFailed"), description: message })
       })
-    })
   }
 
   return (

+ 11 - 16
packages/app/src/components/dialog-manage-models.tsx

@@ -17,6 +17,7 @@ export const DialogManageModels: Component = () => {
   const handleConnectProvider = () => {
     dialog.show(() => <DialogSelectProvider />)
   }
+  const providerRank = (id: string) => popularProviders.indexOf(id)
 
   return (
     <Dialog
@@ -37,19 +38,18 @@ export const DialogManageModels: Component = () => {
         sortBy={(a, b) => a.name.localeCompare(b.name)}
         groupBy={(x) => x.provider.name}
         sortGroupsBy={(a, b) => {
-          const aProvider = a.items[0].provider.id
-          const bProvider = b.items[0].provider.id
-          if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
-          if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
-          return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
+          const aRank = providerRank(a.items[0].provider.id)
+          const bRank = providerRank(b.items[0].provider.id)
+          const aPopular = aRank >= 0
+          const bPopular = bRank >= 0
+          if (aPopular && !bPopular) return -1
+          if (!aPopular && bPopular) return 1
+          return aRank - bRank
         }}
         onSelect={(x) => {
           if (!x) return
-          const visible = local.model.visible({
-            modelID: x.id,
-            providerID: x.provider.id,
-          })
-          local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
+          const key = { modelID: x.id, providerID: x.provider.id }
+          local.model.setVisibility(key, !local.model.visible(key))
         }}
       >
         {(i) => (
@@ -57,12 +57,7 @@ export const DialogManageModels: Component = () => {
             <span>{i.name}</span>
             <div onClick={(e) => e.stopPropagation()}>
               <Switch
-                checked={
-                  !!local.model.visible({
-                    modelID: i.id,
-                    providerID: i.provider.id,
-                  })
-                }
+                checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
                 onChange={(checked) => {
                   local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
                 }}

+ 2 - 18
packages/app/src/components/dialog-release-notes.tsx

@@ -1,4 +1,4 @@
-import { createSignal, createEffect, onMount, onCleanup } from "solid-js"
+import { createSignal } from "solid-js"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { Button } from "@opencode-ai/ui/button"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -40,8 +40,6 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
     handleClose()
   }
 
-  let focusTrap: HTMLDivElement | undefined
-
   function handleKeyDown(e: KeyboardEvent) {
     if (e.key === "Escape") {
       e.preventDefault()
@@ -60,27 +58,13 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
     }
   }
 
-  onMount(() => {
-    focusTrap?.focus()
-    document.addEventListener("keydown", handleKeyDown)
-    onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
-  })
-
-  // Refocus the trap when index changes to ensure escape always works
-  createEffect(() => {
-    index() // track index
-    focusTrap?.focus()
-  })
-
   return (
     <Dialog
       size="large"
       fit
       class="w-[min(calc(100vw-40px),720px)] h-[min(calc(100vh-40px),400px)] -mt-20 min-h-0 overflow-hidden"
     >
-      {/* Hidden element to capture initial focus and handle escape */}
-      <div ref={focusTrap} tabindex="0" class="absolute opacity-0 pointer-events-none" />
-      <div class="flex flex-1 min-w-0 min-h-0">
+      <div class="flex flex-1 min-w-0 min-h-0" tabIndex={0} autofocus onKeyDown={handleKeyDown}>
         {/* Left side - Text content */}
         <div class="flex flex-col flex-1 min-w-0 p-8">
           {/* Top section - feature content (fixed position from top) */}

+ 145 - 132
packages/app/src/components/dialog-select-directory.tsx

@@ -2,13 +2,13 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { List } from "@opencode-ai/ui/list"
+import type { ListRef } from "@opencode-ai/ui/list"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import fuzzysort from "fuzzysort"
 import { createMemo, createResource, createSignal } from "solid-js"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { useGlobalSync } from "@/context/global-sync"
 import { useLanguage } from "@/context/language"
-import type { ListRef } from "@opencode-ai/ui/list"
 
 interface DialogSelectDirectoryProps {
   title?: string
@@ -21,157 +21,131 @@ type Row = {
   search: string
 }
 
-export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
-  const sync = useGlobalSync()
-  const sdk = useGlobalSDK()
-  const dialog = useDialog()
-  const language = useLanguage()
-
-  const [filter, setFilter] = createSignal("")
-
-  let list: ListRef | undefined
-
-  const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
-
-  const [fallbackPath] = createResource(
-    () => (missingBase() ? true : undefined),
-    async () => {
-      return sdk.client.path
-        .get()
-        .then((x) => x.data)
-        .catch(() => undefined)
-    },
-    { initialValue: undefined },
-  )
-
-  const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
-
-  const start = createMemo(
-    () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
-  )
-
-  const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
+function cleanInput(value: string) {
+  const first = (value ?? "").split(/\r?\n/)[0] ?? ""
+  return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
+}
 
-  const clean = (value: string) => {
-    const first = (value ?? "").split(/\r?\n/)[0] ?? ""
-    return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
-  }
+function normalizePath(input: string) {
+  const v = input.replaceAll("\\", "/")
+  if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
+  return v.replace(/\/+/g, "/")
+}
 
-  function normalize(input: string) {
-    const v = input.replaceAll("\\", "/")
-    if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
-    return v.replace(/\/+/g, "/")
-  }
+function normalizeDriveRoot(input: string) {
+  const v = normalizePath(input)
+  if (/^[A-Za-z]:$/.test(v)) return v + "/"
+  return v
+}
 
-  function normalizeDriveRoot(input: string) {
-    const v = normalize(input)
-    if (/^[A-Za-z]:$/.test(v)) return v + "/"
-    return v
-  }
+function trimTrailing(input: string) {
+  const v = normalizeDriveRoot(input)
+  if (v === "/") return v
+  if (v === "//") return v
+  if (/^[A-Za-z]:\/$/.test(v)) return v
+  return v.replace(/\/+$/, "")
+}
 
-  function trimTrailing(input: string) {
-    const v = normalizeDriveRoot(input)
-    if (v === "/") return v
-    if (v === "//") return v
-    if (/^[A-Za-z]:\/$/.test(v)) return v
-    return v.replace(/\/+$/, "")
-  }
+function joinPath(base: string | undefined, rel: string) {
+  const b = trimTrailing(base ?? "")
+  const r = trimTrailing(rel).replace(/^\/+/, "")
+  if (!b) return r
+  if (!r) return b
+  if (b.endsWith("/")) return b + r
+  return b + "/" + r
+}
 
-  function join(base: string | undefined, rel: string) {
-    const b = trimTrailing(base ?? "")
-    const r = trimTrailing(rel).replace(/^\/+/, "")
-    if (!b) return r
-    if (!r) return b
-    if (b.endsWith("/")) return b + r
-    return b + "/" + r
-  }
+function rootOf(input: string) {
+  const v = normalizeDriveRoot(input)
+  if (v.startsWith("//")) return "//"
+  if (v.startsWith("/")) return "/"
+  if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
+  return ""
+}
 
-  function rootOf(input: string) {
-    const v = normalizeDriveRoot(input)
-    if (v.startsWith("//")) return "//"
-    if (v.startsWith("/")) return "/"
-    if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
-    return ""
-  }
+function parentOf(input: string) {
+  const v = trimTrailing(input)
+  if (v === "/") return v
+  if (v === "//") return v
+  if (/^[A-Za-z]:\/$/.test(v)) return v
 
-  function parentOf(input: string) {
-    const v = trimTrailing(input)
-    if (v === "/") return v
-    if (v === "//") return v
-    if (/^[A-Za-z]:\/$/.test(v)) return v
+  const i = v.lastIndexOf("/")
+  if (i <= 0) return "/"
+  if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
+  return v.slice(0, i)
+}
 
-    const i = v.lastIndexOf("/")
-    if (i <= 0) return "/"
-    if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
-    return v.slice(0, i)
-  }
+function modeOf(input: string) {
+  const raw = normalizeDriveRoot(input.trim())
+  if (!raw) return "relative" as const
+  if (raw.startsWith("~")) return "tilde" as const
+  if (rootOf(raw)) return "absolute" as const
+  return "relative" as const
+}
 
-  function modeOf(input: string) {
-    const raw = normalizeDriveRoot(input.trim())
-    if (!raw) return "relative" as const
-    if (raw.startsWith("~")) return "tilde" as const
-    if (rootOf(raw)) return "absolute" as const
-    return "relative" as const
-  }
+function tildeOf(absolute: string, home: string) {
+  const full = trimTrailing(absolute)
+  if (!home) return ""
 
-  function display(path: string, input: string) {
-    const full = trimTrailing(path)
-    if (modeOf(input) === "absolute") return full
+  const hn = trimTrailing(home)
+  const lc = full.toLowerCase()
+  const hc = hn.toLowerCase()
+  if (lc === hc) return "~"
+  if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
+  return ""
+}
 
-    return tildeOf(full) || full
-  }
+function displayPath(path: string, input: string, home: string) {
+  const full = trimTrailing(path)
+  if (modeOf(input) === "absolute") return full
+  return tildeOf(full, home) || full
+}
 
-  function tildeOf(absolute: string) {
-    const full = trimTrailing(absolute)
-    const h = home()
-    if (!h) return ""
-
-    const hn = trimTrailing(h)
-    const lc = full.toLowerCase()
-    const hc = hn.toLowerCase()
-    if (lc === hc) return "~"
-    if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
-    return ""
+function toRow(absolute: string, home: string): Row {
+  const full = trimTrailing(absolute)
+  const tilde = tildeOf(full, home)
+  const withSlash = (value: string) => {
+    if (!value) return ""
+    if (value.endsWith("/")) return value
+    return value + "/"
   }
 
-  function row(absolute: string): Row {
-    const full = trimTrailing(absolute)
-    const tilde = tildeOf(full)
-
-    const withSlash = (value: string) => {
-      if (!value) return ""
-      if (value.endsWith("/")) return value
-      return value + "/"
-    }
+  const search = Array.from(
+    new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
+  ).join("\n")
+  return { absolute: full, search }
+}
 
-    const search = Array.from(
-      new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
-    ).join("\n")
-    return { absolute: full, search }
-  }
+function useDirectorySearch(args: {
+  sdk: ReturnType<typeof useGlobalSDK>
+  start: () => string | undefined
+  home: () => string
+}) {
+  const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
+  let current = 0
 
-  function scoped(value: string) {
-    const base = start()
+  const scoped = (value: string) => {
+    const base = args.start()
     if (!base) return
 
     const raw = normalizeDriveRoot(value)
     if (!raw) return { directory: trimTrailing(base), path: "" }
 
-    const h = home()
-    if (raw === "~") return { directory: trimTrailing(h ?? base), path: "" }
-    if (raw.startsWith("~/")) return { directory: trimTrailing(h ?? base), path: raw.slice(2) }
+    const h = args.home()
+    if (raw === "~") return { directory: trimTrailing(h || base), path: "" }
+    if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) }
 
     const root = rootOf(raw)
     if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
     return { directory: trimTrailing(base), path: raw }
   }
 
-  async function dirs(dir: string) {
+  const dirs = async (dir: string) => {
     const key = trimTrailing(dir)
     const existing = cache.get(key)
     if (existing) return existing
 
-    const request = sdk.client.file
+    const request = args.sdk.client.file
       .list({ directory: key, path: "" })
       .then((x) => x.data ?? [])
       .catch(() => [])
@@ -188,32 +162,34 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
     return request
   }
 
-  async function match(dir: string, query: string, limit: number) {
+  const match = async (dir: string, query: string, limit: number) => {
     const items = await dirs(dir)
     if (!query) return items.slice(0, limit).map((x) => x.absolute)
     return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
   }
 
-  const directories = async (filter: string) => {
-    const value = clean(filter)
+  return async (filter: string) => {
+    const token = ++current
+    const active = () => token === current
+
+    const value = cleanInput(filter)
     const scopedInput = scoped(value)
     if (!scopedInput) return [] as string[]
 
     const raw = normalizeDriveRoot(value)
     const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
-
     const query = normalizeDriveRoot(scopedInput.path)
 
     const find = () =>
-      sdk.client.find
+      args.sdk.client.find
         .files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
         .then((x) => x.data ?? [])
         .catch(() => [])
 
     if (!isPath) {
       const results = await find()
-
-      return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50)
+      if (!active()) return []
+      return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50)
     }
 
     const segments = query.replace(/^\/+/, "").split("/")
@@ -224,17 +200,20 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
     const branch = 4
     let paths = [scopedInput.directory]
     for (const part of head) {
+      if (!active()) return []
       if (part === "..") {
         paths = paths.map(parentOf)
         continue
       }
 
       const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
+      if (!active()) return []
       paths = Array.from(new Set(next)).slice(0, cap)
       if (paths.length === 0) return [] as string[]
     }
 
     const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
+    if (!active()) return []
     const deduped = Array.from(new Set(out))
     const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
     const expand = !raw.endsWith("/")
@@ -249,13 +228,47 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
     if (!target) return deduped.slice(0, 50)
 
     const children = await match(target, "", 30)
+    if (!active()) return []
     const items = Array.from(new Set([...deduped, ...children]))
     return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
   }
+}
+
+export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
+  const sync = useGlobalSync()
+  const sdk = useGlobalSDK()
+  const dialog = useDialog()
+  const language = useLanguage()
+
+  const [filter, setFilter] = createSignal("")
+  let list: ListRef | undefined
+
+  const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
+  const [fallbackPath] = createResource(
+    () => (missingBase() ? true : undefined),
+    async () => {
+      return sdk.client.path
+        .get()
+        .then((x) => x.data)
+        .catch(() => undefined)
+    },
+    { initialValue: undefined },
+  )
+
+  const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
+  const start = createMemo(
+    () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
+  )
+
+  const directories = useDirectorySearch({
+    sdk,
+    home,
+    start,
+  })
 
   const items = async (value: string) => {
     const results = await directories(value)
-    return results.map(row)
+    return results.map((absolute) => toRow(absolute, home()))
   }
 
   function resolve(absolute: string) {
@@ -273,7 +286,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
         key={(x) => x.absolute}
         filterKeys={["search"]}
         ref={(r) => (list = r)}
-        onFilter={(value) => setFilter(clean(value))}
+        onFilter={(value) => setFilter(cleanInput(value))}
         onKeyEvent={(e, item) => {
           if (e.key !== "Tab") return
           if (e.shiftKey) return
@@ -282,7 +295,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
           e.preventDefault()
           e.stopPropagation()
 
-          const value = display(item.absolute, filter())
+          const value = displayPath(item.absolute, filter(), home())
           list?.setFilter(value.endsWith("/") ? value : value + "/")
         }}
         onSelect={(path) => {
@@ -291,7 +304,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
         }}
       >
         {(item) => {
-          const path = display(item.absolute, filter())
+          const path = displayPath(item.absolute, filter(), home())
           if (path === "~") {
             return (
               <div class="w-full flex items-center justify-between rounded-md">

+ 194 - 136
packages/app/src/components/dialog-select-file.tsx

@@ -36,197 +36,200 @@ type Entry = {
 
 type DialogSelectFileMode = "all" | "files"
 
-export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
-  const command = useCommand()
-  const language = useLanguage()
-  const layout = useLayout()
-  const file = useFile()
-  const dialog = useDialog()
-  const params = useParams()
-  const navigate = useNavigate()
-  const globalSDK = useGlobalSDK()
-  const globalSync = useGlobalSync()
-  const filesOnly = () => props.mode === "files"
-  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
-  const tabs = createMemo(() => layout.tabs(sessionKey))
-  const view = createMemo(() => layout.view(sessionKey))
-  const state = { cleanup: undefined as (() => void) | void, committed: false }
-  const [grouped, setGrouped] = createSignal(false)
-  const common = [
-    "session.new",
-    "workspace.new",
-    "session.previous",
-    "session.next",
-    "terminal.toggle",
-    "review.toggle",
-  ]
-  const limit = 5
-
-  const allowed = createMemo(() => {
-    if (filesOnly()) return []
-    return command.options.filter(
-      (option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
-    )
-  })
-
-  const commandItem = (option: CommandOption): Entry => ({
-    id: "command:" + option.id,
-    type: "command",
-    title: option.title,
-    description: option.description,
-    keybind: option.keybind,
-    category: language.t("palette.group.commands"),
-    option,
-  })
-
-  const fileItem = (path: string): Entry => ({
-    id: "file:" + path,
-    type: "file",
-    title: path,
-    category: language.t("palette.group.files"),
-    path,
-  })
-
-  const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
-  const project = createMemo(() => {
-    const directory = projectDirectory()
-    if (!directory) return
-    return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
-  })
-  const workspaces = createMemo(() => {
-    const directory = projectDirectory()
-    const current = project()
-    if (!current) return directory ? [directory] : []
-
-    const dirs = [current.worktree, ...(current.sandboxes ?? [])]
-    if (directory && !dirs.includes(directory)) return [...dirs, directory]
-    return dirs
-  })
-  const homedir = createMemo(() => globalSync.data.path.home)
-  const label = (directory: string) => {
-    const current = project()
-    const kind =
-      current && directory === current.worktree
-        ? language.t("workspace.type.local")
-        : language.t("workspace.type.sandbox")
-    const [store] = globalSync.child(directory, { bootstrap: false })
-    const home = homedir()
-    const path = home ? directory.replace(home, "~") : directory
-    const name = store.vcs?.branch ?? getFilename(directory)
-    return `${kind} : ${name || path}`
+const ENTRY_LIMIT = 5
+const COMMON_COMMAND_IDS = [
+  "session.new",
+  "workspace.new",
+  "session.previous",
+  "session.next",
+  "terminal.toggle",
+  "review.toggle",
+] as const
+
+const uniqueEntries = (items: Entry[]) => {
+  const seen = new Set<string>()
+  const out: Entry[] = []
+  for (const item of items) {
+    if (seen.has(item.id)) continue
+    seen.add(item.id)
+    out.push(item)
   }
+  return out
+}
 
-  const sessionItem = (input: {
+const createCommandEntry = (option: CommandOption, category: string): Entry => ({
+  id: "command:" + option.id,
+  type: "command",
+  title: option.title,
+  description: option.description,
+  keybind: option.keybind,
+  category,
+  option,
+})
+
+const createFileEntry = (path: string, category: string): Entry => ({
+  id: "file:" + path,
+  type: "file",
+  title: path,
+  category,
+  path,
+})
+
+const createSessionEntry = (
+  input: {
     directory: string
     id: string
     title: string
     description: string
     archived?: number
     updated?: number
-  }): Entry => ({
-    id: `session:${input.directory}:${input.id}`,
-    type: "session",
-    title: input.title,
-    description: input.description,
-    category: language.t("command.category.session"),
-    directory: input.directory,
-    sessionID: input.id,
-    archived: input.archived,
-    updated: input.updated,
+  },
+  category: string,
+): Entry => ({
+  id: `session:${input.directory}:${input.id}`,
+  type: "session",
+  title: input.title,
+  description: input.description,
+  category,
+  directory: input.directory,
+  sessionID: input.id,
+  archived: input.archived,
+  updated: input.updated,
+})
+
+function createCommandEntries(props: {
+  filesOnly: () => boolean
+  command: ReturnType<typeof useCommand>
+  language: ReturnType<typeof useLanguage>
+}) {
+  const allowed = createMemo(() => {
+    if (props.filesOnly()) return []
+    return props.command.options.filter(
+      (option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
+    )
   })
 
-  const list = createMemo(() => allowed().map(commandItem))
+  const list = createMemo(() => {
+    const category = props.language.t("palette.group.commands")
+    return allowed().map((option) => createCommandEntry(option, category))
+  })
 
   const picks = createMemo(() => {
     const all = allowed()
-    const order = new Map(common.map((id, index) => [id, index]))
+    const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
     const picked = all.filter((option) => order.has(option.id))
-    const base = picked.length ? picked : all.slice(0, limit)
+    const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
     const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
-    return sorted.map(commandItem)
+    const category = props.language.t("palette.group.commands")
+    return sorted.map((option) => createCommandEntry(option, category))
   })
 
+  return { allowed, list, picks }
+}
+
+function createFileEntries(props: {
+  file: ReturnType<typeof useFile>
+  tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
+  language: ReturnType<typeof useLanguage>
+}) {
   const recent = createMemo(() => {
-    const all = tabs().all()
-    const active = tabs().active()
+    const all = props.tabs().all()
+    const active = props.tabs().active()
     const order = active ? [active, ...all.filter((item) => item !== active)] : all
     const seen = new Set<string>()
+    const category = props.language.t("palette.group.files")
     const items: Entry[] = []
 
     for (const item of order) {
-      const path = file.pathFromTab(item)
+      const path = props.file.pathFromTab(item)
       if (!path) continue
       if (seen.has(path)) continue
       seen.add(path)
-      items.push(fileItem(path))
+      items.push(createFileEntry(path, category))
     }
 
-    return items.slice(0, limit)
+    return items.slice(0, ENTRY_LIMIT)
   })
 
   const root = createMemo(() => {
-    const nodes = file.tree.children("")
+    const category = props.language.t("palette.group.files")
+    const nodes = props.file.tree.children("")
     const paths = nodes
       .filter((node) => node.type === "file")
       .map((node) => node.path)
       .sort((a, b) => a.localeCompare(b))
-    return paths.slice(0, limit).map(fileItem)
+    return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
   })
 
-  const unique = (items: Entry[]) => {
-    const seen = new Set<string>()
-    const out: Entry[] = []
-    for (const item of items) {
-      if (seen.has(item.id)) continue
-      seen.add(item.id)
-      out.push(item)
-    }
-    return out
-  }
+  return { recent, root }
+}
 
-  const sessionToken = { value: 0 }
-  let sessionInflight: Promise<Entry[]> | undefined
-  let sessionAll: Entry[] | undefined
+function createSessionEntries(props: {
+  workspaces: () => string[]
+  label: (directory: string) => string
+  globalSDK: ReturnType<typeof useGlobalSDK>
+  language: ReturnType<typeof useLanguage>
+}) {
+  const state: {
+    token: number
+    inflight: Promise<Entry[]> | undefined
+    cached: Entry[] | undefined
+  } = {
+    token: 0,
+    inflight: undefined,
+    cached: undefined,
+  }
 
   const sessions = (text: string) => {
     const query = text.trim()
     if (!query) {
-      sessionToken.value += 1
-      sessionInflight = undefined
-      sessionAll = undefined
+      state.token += 1
+      state.inflight = undefined
+      state.cached = undefined
       return [] as Entry[]
     }
 
-    if (sessionAll) return sessionAll
-    if (sessionInflight) return sessionInflight
+    if (state.cached) return state.cached
+    if (state.inflight) return state.inflight
 
-    const current = sessionToken.value
-    const dirs = workspaces()
+    const current = state.token
+    const dirs = props.workspaces()
     if (dirs.length === 0) return [] as Entry[]
 
-    sessionInflight = Promise.all(
+    state.inflight = Promise.all(
       dirs.map((directory) => {
-        const description = label(directory)
-        return globalSDK.client.session
+        const description = props.label(directory)
+        return props.globalSDK.client.session
           .list({ directory, roots: true })
           .then((x) =>
             (x.data ?? [])
               .filter((s) => !!s?.id)
               .map((s) => ({
                 id: s.id,
-                title: s.title ?? language.t("command.session.new"),
+                title: s.title ?? props.language.t("command.session.new"),
                 description,
                 directory,
                 archived: s.time?.archived,
                 updated: s.time?.updated,
               })),
           )
-          .catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
+          .catch(
+            () =>
+              [] as {
+                id: string
+                title: string
+                description: string
+                directory: string
+                archived?: number
+                updated?: number
+              }[],
+          )
       }),
     )
       .then((results) => {
-        if (sessionToken.value !== current) return [] as Entry[]
+        if (state.token !== current) return [] as Entry[]
         const seen = new Set<string>()
+        const category = props.language.t("command.category.session")
         const next = results
           .flat()
           .filter((item) => {
@@ -235,18 +238,71 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
             seen.add(key)
             return true
           })
-          .map(sessionItem)
-        sessionAll = next
+          .map((item) => createSessionEntry(item, category))
+        state.cached = next
         return next
       })
       .catch(() => [] as Entry[])
       .finally(() => {
-        sessionInflight = undefined
+        state.inflight = undefined
       })
 
-    return sessionInflight
+    return state.inflight
   }
 
+  return { sessions }
+}
+
+export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
+  const command = useCommand()
+  const language = useLanguage()
+  const layout = useLayout()
+  const file = useFile()
+  const dialog = useDialog()
+  const params = useParams()
+  const navigate = useNavigate()
+  const globalSDK = useGlobalSDK()
+  const globalSync = useGlobalSync()
+  const filesOnly = () => props.mode === "files"
+  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+  const tabs = createMemo(() => layout.tabs(sessionKey))
+  const view = createMemo(() => layout.view(sessionKey))
+  const state = { cleanup: undefined as (() => void) | void, committed: false }
+  const [grouped, setGrouped] = createSignal(false)
+  const commandEntries = createCommandEntries({ filesOnly, command, language })
+  const fileEntries = createFileEntries({ file, tabs, language })
+
+  const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
+  const project = createMemo(() => {
+    const directory = projectDirectory()
+    if (!directory) return
+    return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
+  })
+  const workspaces = createMemo(() => {
+    const directory = projectDirectory()
+    const current = project()
+    if (!current) return directory ? [directory] : []
+
+    const dirs = [current.worktree, ...(current.sandboxes ?? [])]
+    if (directory && !dirs.includes(directory)) return [...dirs, directory]
+    return dirs
+  })
+  const homedir = createMemo(() => globalSync.data.path.home)
+  const label = (directory: string) => {
+    const current = project()
+    const kind =
+      current && directory === current.worktree
+        ? language.t("workspace.type.local")
+        : language.t("workspace.type.sandbox")
+    const [store] = globalSync.child(directory, { bootstrap: false })
+    const home = homedir()
+    const path = home ? directory.replace(home, "~") : directory
+    const name = store.vcs?.branch ?? getFilename(directory)
+    return `${kind} : ${name || path}`
+  }
+
+  const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language })
+
   const items = async (text: string) => {
     const query = text.trim()
     setGrouped(query.length > 0)
@@ -254,7 +310,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
     if (!query && filesOnly()) {
       const loaded = file.tree.state("")?.loaded
       const pending = loaded ? Promise.resolve() : file.tree.list("")
-      const next = unique([...recent(), ...root()])
+      const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
 
       if (loaded || next.length > 0) {
         void pending
@@ -262,19 +318,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
       }
 
       await pending
-      return unique([...recent(), ...root()])
+      return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
     }
 
-    if (!query) return [...picks(), ...recent()]
+    if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
 
     if (filesOnly()) {
       const files = await file.searchFiles(query)
-      return files.map(fileItem)
+      const category = language.t("palette.group.files")
+      return files.map((path) => createFileEntry(path, category))
     }
 
     const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
-    const entries = files.map(fileItem)
-    return [...list(), ...nextSessions, ...entries]
+    const category = language.t("palette.group.files")
+    const entries = files.map((path) => createFileEntry(path, category))
+    return [...commandEntries.list(), ...nextSessions, ...entries]
   }
 
   const handleMove = (item: Entry | undefined) => {

+ 26 - 19
packages/app/src/components/dialog-select-mcp.tsx

@@ -6,6 +6,13 @@ import { List } from "@opencode-ai/ui/list"
 import { Switch } from "@opencode-ai/ui/switch"
 import { useLanguage } from "@/context/language"
 
+const statusLabels = {
+  connected: "mcp.status.connected",
+  failed: "mcp.status.failed",
+  needs_auth: "mcp.status.needs_auth",
+  disabled: "mcp.status.disabled",
+} as const
+
 export const DialogSelectMcp: Component = () => {
   const sync = useSync()
   const sdk = useSDK()
@@ -21,15 +28,19 @@ export const DialogSelectMcp: Component = () => {
   const toggle = async (name: string) => {
     if (loading()) return
     setLoading(name)
-    const status = sync.data.mcp[name]
-    if (status?.status === "connected") {
-      await sdk.client.mcp.disconnect({ name })
-    } else {
-      await sdk.client.mcp.connect({ name })
+    try {
+      const status = sync.data.mcp[name]
+      if (status?.status === "connected") {
+        await sdk.client.mcp.disconnect({ name })
+      } else {
+        await sdk.client.mcp.connect({ name })
+      }
+
+      const result = await sdk.client.mcp.status()
+      if (result.data) sync.set("mcp", result.data)
+    } finally {
+      setLoading(null)
     }
-    const result = await sdk.client.mcp.status()
-    if (result.data) sync.set("mcp", result.data)
-    setLoading(null)
   }
 
   const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
@@ -54,6 +65,11 @@ export const DialogSelectMcp: Component = () => {
         {(i) => {
           const mcpStatus = () => sync.data.mcp[i.name]
           const status = () => mcpStatus()?.status
+          const statusLabel = () => {
+            const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
+            if (!key) return
+            return language.t(key)
+          }
           const error = () => {
             const s = mcpStatus()
             return s?.status === "failed" ? s.error : undefined
@@ -64,17 +80,8 @@ export const DialogSelectMcp: Component = () => {
               <div class="flex flex-col gap-0.5 min-w-0">
                 <div class="flex items-center gap-2">
                   <span class="truncate">{i.name}</span>
-                  <Show when={status() === "connected"}>
-                    <span class="text-11-regular text-text-weaker">{language.t("mcp.status.connected")}</span>
-                  </Show>
-                  <Show when={status() === "failed"}>
-                    <span class="text-11-regular text-text-weaker">{language.t("mcp.status.failed")}</span>
-                  </Show>
-                  <Show when={status() === "needs_auth"}>
-                    <span class="text-11-regular text-text-weaker">{language.t("mcp.status.needs_auth")}</span>
-                  </Show>
-                  <Show when={status() === "disabled"}>
-                    <span class="text-11-regular text-text-weaker">{language.t("mcp.status.disabled")}</span>
+                  <Show when={statusLabel()}>
+                    <span class="text-11-regular text-text-weaker">{statusLabel()}</span>
                   </Show>
                   <Show when={loading() === i.name}>
                     <span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>

+ 3 - 10
packages/app/src/components/dialog-select-model-unpaid.tsx

@@ -6,7 +6,7 @@ import { List, type ListRef } from "@opencode-ai/ui/list"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Tag } from "@opencode-ai/ui/tag"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { type Component, onCleanup, onMount, Show } from "solid-js"
+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"
@@ -21,24 +21,17 @@ export const DialogSelectModelUnpaid: Component = () => {
   const language = useLanguage()
 
   let listRef: ListRef | undefined
-  const handleKey = (e: KeyboardEvent) => {
+  const handleKeyDown = (e: KeyboardEvent) => {
     if (e.key === "Escape") return
     listRef?.onKeyDown(e)
   }
 
-  onMount(() => {
-    document.addEventListener("keydown", handleKey)
-    onCleanup(() => {
-      document.removeEventListener("keydown", handleKey)
-    })
-  })
-
   return (
     <Dialog
       title={language.t("dialog.model.select.title")}
       class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none"
     >
-      <div class="flex flex-col gap-3 px-2.5">
+      <div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
         <div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
         <List
           class="[&_[data-slot=list-scroll]]:overflow-visible"

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

@@ -1,5 +1,5 @@
 import { Popover as Kobalte } from "@kobalte/core/popover"
-import { Component, ComponentProps, createEffect, createMemo, JSX, onCleanup, Show, ValidComponent } from "solid-js"
+import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js"
 import { createStore } from "solid-js/store"
 import { useLocal } from "@/context/local"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -15,6 +15,9 @@ import { DialogManageModels } from "./dialog-manage-models"
 import { ModelTooltip } from "./model-tooltip"
 import { useLanguage } from "@/context/language"
 
+const isFree = (provider: string, cost: { input: number } | undefined) =>
+  provider === "opencode" && (!cost || cost.input === 0)
+
 const ModelList: Component<{
   provider?: string
   class?: string
@@ -54,13 +57,7 @@ const ModelList: Component<{
           class="w-full"
           placement="right-start"
           gutter={12}
-          value={
-            <ModelTooltip
-              model={item}
-              latest={item.latest}
-              free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
-            />
-          }
+          value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
         >
           {node}
         </Tooltip>
@@ -75,7 +72,7 @@ const ModelList: Component<{
       {(i) => (
         <div class="w-full flex items-center gap-x-2 text-13-regular">
           <span class="truncate">{i.name}</span>
-          <Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
+          <Show when={isFree(i.provider.id, i.cost)}>
             <Tag>{language.t("model.tag.free")}</Tag>
           </Show>
           <Show when={i.latest}>
@@ -98,13 +95,9 @@ export function ModelSelectorPopover(props: {
   const [store, setStore] = createStore<{
     open: boolean
     dismiss: "escape" | "outside" | null
-    trigger?: HTMLElement
-    content?: HTMLElement
   }>({
     open: false,
     dismiss: null,
-    trigger: undefined,
-    content: undefined,
   })
   const dialog = useDialog()
 
@@ -119,54 +112,6 @@ export function ModelSelectorPopover(props: {
   }
   const language = useLanguage()
 
-  createEffect(() => {
-    if (!store.open) return
-
-    const inside = (node: Node | null | undefined) => {
-      if (!node) return false
-      const el = store.content
-      if (el && el.contains(node)) return true
-      const anchor = store.trigger
-      if (anchor && anchor.contains(node)) return true
-      return false
-    }
-
-    const onKeyDown = (event: KeyboardEvent) => {
-      if (event.key !== "Escape") return
-      setStore("dismiss", "escape")
-      setStore("open", false)
-      event.preventDefault()
-      event.stopPropagation()
-    }
-
-    const onPointerDown = (event: PointerEvent) => {
-      const target = event.target
-      if (!(target instanceof Node)) return
-      if (inside(target)) return
-      setStore("dismiss", "outside")
-      setStore("open", false)
-    }
-
-    const onFocusIn = (event: FocusEvent) => {
-      if (!store.content) return
-      const target = event.target
-      if (!(target instanceof Node)) return
-      if (inside(target)) return
-      setStore("dismiss", "outside")
-      setStore("open", false)
-    }
-
-    window.addEventListener("keydown", onKeyDown, true)
-    window.addEventListener("pointerdown", onPointerDown, true)
-    window.addEventListener("focusin", onFocusIn, true)
-
-    onCleanup(() => {
-      window.removeEventListener("keydown", onKeyDown, true)
-      window.removeEventListener("pointerdown", onPointerDown, true)
-      window.removeEventListener("focusin", onFocusIn, true)
-    })
-  })
-
   return (
     <Kobalte
       open={store.open}
@@ -178,12 +123,11 @@ export function ModelSelectorPopover(props: {
       placement="top-start"
       gutter={8}
     >
-      <Kobalte.Trigger ref={(el) => setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}>
+      <Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
         {props.children}
       </Kobalte.Trigger>
       <Kobalte.Portal>
         <Kobalte.Content
-          ref={(el) => setStore("content", el)}
           class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
           onEscapeKeyDown={(event) => {
             setStore("dismiss", "escape")

+ 8 - 10
packages/app/src/components/dialog-select-provider.tsx

@@ -24,6 +24,12 @@ export const DialogSelectProvider: Component = () => {
 
   const popularGroup = () => language.t("dialog.provider.group.popular")
   const otherGroup = () => language.t("dialog.provider.group.other")
+  const customLabel = () => language.t("settings.providers.tag.custom")
+  const note = (id: string) => {
+    if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
+    if (id === "openai") return language.t("dialog.provider.openai.note")
+    if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
+  }
 
   return (
     <Dialog title={language.t("command.provider.connect")} transition>
@@ -34,7 +40,7 @@ export const DialogSelectProvider: Component = () => {
         key={(x) => x?.id}
         items={() => {
           language.locale()
-          return [{ id: CUSTOM_ID, name: "Custom provider" }, ...providers.all()]
+          return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()]
         }}
         filterKeys={["id", "name"]}
         groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
@@ -70,15 +76,7 @@ export const DialogSelectProvider: Component = () => {
             <Show when={i.id === "opencode"}>
               <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
             </Show>
-            <Show when={i.id === "anthropic"}>
-              <div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
-            </Show>
-            <Show when={i.id === "openai"}>
-              <div class="text-14-regular text-text-weak">{language.t("dialog.provider.openai.note")}</div>
-            </Show>
-            <Show when={i.id.startsWith("github-copilot")}>
-              <div class="text-14-regular text-text-weak">{language.t("dialog.provider.copilot.note")}</div>
-            </Show>
+            <Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
           </div>
         )}
       </List>

+ 181 - 180
packages/app/src/components/dialog-select-server.tsx

@@ -38,6 +38,64 @@ interface EditRowProps {
   onBlur: () => void
 }
 
+function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
+  showToast({
+    variant: "error",
+    title: language.t("common.requestFailed"),
+    description: err instanceof Error ? err.message : String(err),
+  })
+}
+
+function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
+  const [defaultUrl, defaultUrlActions] = createResource(
+    async () => {
+      try {
+        const url = await platform.getDefaultServerUrl?.()
+        if (!url) return null
+        return normalizeServerUrl(url) ?? null
+      } catch (err) {
+        showRequestError(language, err)
+        return null
+      }
+    },
+    { initialValue: null },
+  )
+
+  const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
+  const setDefault = async (url: string | null) => {
+    try {
+      await platform.setDefaultServerUrl?.(url)
+      defaultUrlActions.mutate(url)
+    } catch (err) {
+      showRequestError(language, err)
+    }
+  }
+
+  return { defaultUrl, canDefault, setDefault }
+}
+
+function useServerPreview(fetcher: typeof fetch) {
+  const looksComplete = (value: string) => {
+    const normalized = normalizeServerUrl(value)
+    if (!normalized) return false
+    const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
+    if (!host) return false
+    if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
+    return host.includes(".") || host.includes(":")
+  }
+
+  const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
+    setStatus(undefined)
+    if (!looksComplete(value)) return
+    const normalized = normalizeServerUrl(value)
+    if (!normalized) return
+    const result = await checkServerHealth(normalized, fetcher)
+    setStatus(result.healthy)
+  }
+
+  return { previewStatus }
+}
+
 function AddRow(props: AddRowProps) {
   return (
     <div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
@@ -115,6 +173,10 @@ export function DialogSelectServer() {
   const platform = usePlatform()
   const globalSDK = useGlobalSDK()
   const language = useLanguage()
+  const fetcher = platform.fetch ?? globalThis.fetch
+  const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
+  const { previewStatus } = useServerPreview(fetcher)
+  let listRoot: HTMLDivElement | undefined
   const [store, setStore] = createStore({
     status: {} as Record<string, ServerHealth | undefined>,
     addServer: {
@@ -132,43 +194,6 @@ export function DialogSelectServer() {
       status: undefined as boolean | undefined,
     },
   })
-  const [defaultUrl, defaultUrlActions] = createResource(
-    async () => {
-      try {
-        const url = await platform.getDefaultServerUrl?.()
-        if (!url) return null
-        return normalizeServerUrl(url) ?? null
-      } catch (err) {
-        showToast({
-          variant: "error",
-          title: language.t("common.requestFailed"),
-          description: err instanceof Error ? err.message : String(err),
-        })
-        return null
-      }
-    },
-    { initialValue: null },
-  )
-  const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
-  const fetcher = platform.fetch ?? globalThis.fetch
-
-  const looksComplete = (value: string) => {
-    const normalized = normalizeServerUrl(value)
-    if (!normalized) return false
-    const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
-    if (!host) return false
-    if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
-    return host.includes(".") || host.includes(":")
-  }
-
-  const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
-    setStatus(undefined)
-    if (!looksComplete(value)) return
-    const normalized = normalizeServerUrl(value)
-    if (!normalized) return
-    const result = await checkServerHealth(normalized, fetcher)
-    setStatus(result.healthy)
-  }
 
   const resetAdd = () => {
     setStore("addServer", {
@@ -263,7 +288,7 @@ export function DialogSelectServer() {
   }
 
   const scrollListToBottom = () => {
-    const scroll = document.querySelector<HTMLDivElement>('[data-component="list"] [data-slot="list-scroll"]')
+    const scroll = listRoot?.querySelector<HTMLDivElement>('[data-slot="list-scroll"]')
     if (!scroll) return
     requestAnimationFrame(() => {
       scroll.scrollTop = scroll.scrollHeight
@@ -363,158 +388,134 @@ export function DialogSelectServer() {
   return (
     <Dialog title={language.t("dialog.server.title")}>
       <div class="flex flex-col gap-2">
-        <List
-          search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
-          noInitialSelection
-          emptyMessage={language.t("dialog.server.empty")}
-          items={sortedItems}
-          key={(x) => x}
-          onSelect={(x) => {
-            if (x) select(x)
-          }}
-          onFilter={(value) => {
-            if (value && store.addServer.showForm && !store.addServer.adding) {
-              resetAdd()
+        <div ref={(el) => (listRoot = el)}>
+          <List
+            search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
+            noInitialSelection
+            emptyMessage={language.t("dialog.server.empty")}
+            items={sortedItems}
+            key={(x) => x}
+            onSelect={(x) => {
+              if (x) select(x)
+            }}
+            onFilter={(value) => {
+              if (value && store.addServer.showForm && !store.addServer.adding) {
+                resetAdd()
+              }
+            }}
+            divider={true}
+            class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
+            add={
+              store.addServer.showForm
+                ? {
+                    render: () => (
+                      <AddRow
+                        value={store.addServer.url}
+                        placeholder={language.t("dialog.server.add.placeholder")}
+                        adding={store.addServer.adding}
+                        error={store.addServer.error}
+                        status={store.addServer.status}
+                        onChange={handleAddChange}
+                        onKeyDown={handleAddKey}
+                        onBlur={blurAdd}
+                      />
+                    ),
+                  }
+                : undefined
             }
-          }}
-          divider={true}
-          class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
-          add={
-            store.addServer.showForm
-              ? {
-                  render: () => (
-                    <AddRow
-                      value={store.addServer.url}
-                      placeholder={language.t("dialog.server.add.placeholder")}
-                      adding={store.addServer.adding}
-                      error={store.addServer.error}
-                      status={store.addServer.status}
-                      onChange={handleAddChange}
-                      onKeyDown={handleAddKey}
-                      onBlur={blurAdd}
-                    />
-                  ),
-                }
-              : undefined
-          }
-        >
-          {(i) => {
-            return (
-              <div class="flex items-center gap-3 min-w-0 flex-1 group/item">
-                <Show
-                  when={store.editServer.id !== i}
-                  fallback={
-                    <EditRow
-                      value={store.editServer.value}
-                      placeholder={language.t("dialog.server.add.placeholder")}
-                      busy={store.editServer.busy}
-                      error={store.editServer.error}
-                      status={store.editServer.status}
-                      onChange={handleEditChange}
-                      onKeyDown={(event) => handleEditKey(event, i)}
-                      onBlur={() => handleEdit(i, store.editServer.value)}
+          >
+            {(i) => {
+              return (
+                <div class="flex items-center gap-3 min-w-0 flex-1 group/item">
+                  <Show
+                    when={store.editServer.id !== i}
+                    fallback={
+                      <EditRow
+                        value={store.editServer.value}
+                        placeholder={language.t("dialog.server.add.placeholder")}
+                        busy={store.editServer.busy}
+                        error={store.editServer.error}
+                        status={store.editServer.status}
+                        onChange={handleEditChange}
+                        onKeyDown={(event) => handleEditKey(event, i)}
+                        onBlur={() => handleEdit(i, store.editServer.value)}
+                      />
+                    }
+                  >
+                    <ServerRow
+                      url={i}
+                      status={store.status[i]}
+                      dimmed={store.status[i]?.healthy === false}
+                      class="flex items-center gap-3 px-4 min-w-0 flex-1"
+                      badge={
+                        <Show when={defaultUrl() === i}>
+                          <span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
+                            {language.t("dialog.server.status.default")}
+                          </span>
+                        </Show>
+                      }
                     />
-                  }
-                >
-                  <ServerRow
-                    url={i}
-                    status={store.status[i]}
-                    dimmed={store.status[i]?.healthy === false}
-                    class="flex items-center gap-3 px-4 min-w-0 flex-1"
-                    badge={
-                      <Show when={defaultUrl() === i}>
-                        <span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
-                          {language.t("dialog.server.status.default")}
-                        </span>
+                  </Show>
+                  <Show when={store.editServer.id !== i}>
+                    <div class="flex items-center justify-center gap-5 pl-4">
+                      <Show when={current() === i}>
+                        <p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
                       </Show>
-                    }
-                  />
-                </Show>
-                <Show when={store.editServer.id !== i}>
-                  <div class="flex items-center justify-center gap-5 pl-4">
-                    <Show when={current() === i}>
-                      <p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
-                    </Show>
-
-                    <DropdownMenu>
-                      <DropdownMenu.Trigger
-                        as={IconButton}
-                        icon="dot-grid"
-                        variant="ghost"
-                        class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
-                        onClick={(e: MouseEvent) => e.stopPropagation()}
-                        onPointerDown={(e: PointerEvent) => e.stopPropagation()}
-                      />
-                      <DropdownMenu.Portal>
-                        <DropdownMenu.Content class="mt-1">
-                          <DropdownMenu.Item
-                            onSelect={() => {
-                              setStore("editServer", {
-                                id: i,
-                                value: i,
-                                error: "",
-                                status: store.status[i]?.healthy,
-                              })
-                            }}
-                          >
-                            <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
-                          </DropdownMenu.Item>
-                          <Show when={canDefault() && defaultUrl() !== i}>
+
+                      <DropdownMenu>
+                        <DropdownMenu.Trigger
+                          as={IconButton}
+                          icon="dot-grid"
+                          variant="ghost"
+                          class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
+                          onClick={(e: MouseEvent) => e.stopPropagation()}
+                          onPointerDown={(e: PointerEvent) => e.stopPropagation()}
+                        />
+                        <DropdownMenu.Portal>
+                          <DropdownMenu.Content class="mt-1">
                             <DropdownMenu.Item
-                              onSelect={async () => {
-                                try {
-                                  await platform.setDefaultServerUrl?.(i)
-                                  defaultUrlActions.mutate(i)
-                                } catch (err) {
-                                  showToast({
-                                    variant: "error",
-                                    title: language.t("common.requestFailed"),
-                                    description: err instanceof Error ? err.message : String(err),
-                                  })
-                                }
+                              onSelect={() => {
+                                setStore("editServer", {
+                                  id: i,
+                                  value: i,
+                                  error: "",
+                                  status: store.status[i]?.healthy,
+                                })
                               }}
                             >
-                              <DropdownMenu.ItemLabel>
-                                {language.t("dialog.server.menu.default")}
-                              </DropdownMenu.ItemLabel>
+                              <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
                             </DropdownMenu.Item>
-                          </Show>
-                          <Show when={canDefault() && defaultUrl() === i}>
+                            <Show when={canDefault() && defaultUrl() !== i}>
+                              <DropdownMenu.Item onSelect={() => setDefault(i)}>
+                                <DropdownMenu.ItemLabel>
+                                  {language.t("dialog.server.menu.default")}
+                                </DropdownMenu.ItemLabel>
+                              </DropdownMenu.Item>
+                            </Show>
+                            <Show when={canDefault() && defaultUrl() === i}>
+                              <DropdownMenu.Item onSelect={() => setDefault(null)}>
+                                <DropdownMenu.ItemLabel>
+                                  {language.t("dialog.server.menu.defaultRemove")}
+                                </DropdownMenu.ItemLabel>
+                              </DropdownMenu.Item>
+                            </Show>
+                            <DropdownMenu.Separator />
                             <DropdownMenu.Item
-                              onSelect={async () => {
-                                try {
-                                  await platform.setDefaultServerUrl?.(null)
-                                  defaultUrlActions.mutate(null)
-                                } catch (err) {
-                                  showToast({
-                                    variant: "error",
-                                    title: language.t("common.requestFailed"),
-                                    description: err instanceof Error ? err.message : String(err),
-                                  })
-                                }
-                              }}
+                              onSelect={() => handleRemove(i)}
+                              class="text-text-on-critical-base hover:bg-surface-critical-weak"
                             >
-                              <DropdownMenu.ItemLabel>
-                                {language.t("dialog.server.menu.defaultRemove")}
-                              </DropdownMenu.ItemLabel>
+                              <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
                             </DropdownMenu.Item>
-                          </Show>
-                          <DropdownMenu.Separator />
-                          <DropdownMenu.Item
-                            onSelect={() => handleRemove(i)}
-                            class="text-text-on-critical-base hover:bg-surface-critical-weak"
-                          >
-                            <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
-                          </DropdownMenu.Item>
-                        </DropdownMenu.Content>
-                      </DropdownMenu.Portal>
-                    </DropdownMenu>
-                  </div>
-                </Show>
-              </div>
-            )
-          }}
-        </List>
+                          </DropdownMenu.Content>
+                        </DropdownMenu.Portal>
+                      </DropdownMenu>
+                    </div>
+                  </Show>
+                </div>
+              )
+            }}
+          </List>
+        </div>
 
         <div class="px-5 pb-5">
           <Button

+ 0 - 9
packages/app/src/components/dialog-settings.tsx

@@ -67,15 +67,6 @@ export const DialogSettings: Component = () => {
         <Tabs.Content value="models" class="no-scrollbar">
           <SettingsModels />
         </Tabs.Content>
-        {/* <Tabs.Content value="agents" class="no-scrollbar"> */}
-        {/*   <SettingsAgents /> */}
-        {/* </Tabs.Content> */}
-        {/* <Tabs.Content value="commands" class="no-scrollbar"> */}
-        {/*   <SettingsCommands /> */}
-        {/* </Tabs.Content> */}
-        {/* <Tabs.Content value="mcp" class="no-scrollbar"> */}
-        {/*   <SettingsMcp /> */}
-        {/* </Tabs.Content> */}
       </Tabs>
     </Dialog>
   )

+ 212 - 174
packages/app/src/components/file-tree.tsx

@@ -15,6 +15,7 @@ import {
   Switch,
   untrack,
   type ComponentProps,
+  type JSXElement,
   type ParentProps,
 } from "solid-js"
 import { Dynamic } from "solid-js/web"
@@ -59,6 +60,189 @@ export function dirsToExpand(input: {
   return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
 }
 
+const kindLabel = (kind: Kind) => {
+  if (kind === "add") return "A"
+  if (kind === "del") return "D"
+  return "M"
+}
+
+const kindTextColor = (kind: Kind) => {
+  if (kind === "add") return "color: var(--icon-diff-add-base)"
+  if (kind === "del") return "color: var(--icon-diff-delete-base)"
+  return "color: var(--icon-warning-active)"
+}
+
+const kindDotColor = (kind: Kind) => {
+  if (kind === "add") return "background-color: var(--icon-diff-add-base)"
+  if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
+  return "background-color: var(--icon-warning-active)"
+}
+
+const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
+  const kind = kinds?.get(node.path)
+  if (!kind) return
+  if (!marks?.has(node.path)) return
+  return kind
+}
+
+const buildDragImage = (target: HTMLElement) => {
+  const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg")
+  const text = target.querySelector("span")
+  if (!icon || !text) return
+
+  const image = document.createElement("div")
+  image.className =
+    "flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
+  image.style.position = "absolute"
+  image.style.top = "-1000px"
+  image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
+  return image
+}
+
+const withFileDragImage = (event: DragEvent) => {
+  const image = buildDragImage(event.currentTarget as HTMLElement)
+  if (!image) return
+  document.body.appendChild(image)
+  event.dataTransfer?.setDragImage(image, 0, 12)
+  setTimeout(() => document.body.removeChild(image), 0)
+}
+
+const FileTreeNode = (
+  p: ParentProps &
+    ComponentProps<"div"> &
+    ComponentProps<"button"> & {
+      node: FileNode
+      level: number
+      active?: string
+      nodeClass?: string
+      draggable: boolean
+      kinds?: ReadonlyMap<string, Kind>
+      marks?: Set<string>
+      as?: "div" | "button"
+    },
+) => {
+  const [local, rest] = splitProps(p, [
+    "node",
+    "level",
+    "active",
+    "nodeClass",
+    "draggable",
+    "kinds",
+    "marks",
+    "as",
+    "children",
+    "class",
+    "classList",
+  ])
+  const kind = () => visibleKind(local.node, local.kinds, local.marks)
+  const active = () => !!kind() && !local.node.ignored
+  const color = () => {
+    const value = kind()
+    if (!value) return
+    return kindTextColor(value)
+  }
+
+  return (
+    <Dynamic
+      component={local.as ?? "div"}
+      classList={{
+        "w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
+        "bg-surface-base-active": local.node.path === local.active,
+        ...(local.classList ?? {}),
+        [local.class ?? ""]: !!local.class,
+        [local.nodeClass ?? ""]: !!local.nodeClass,
+      }}
+      style={`padding-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
+      draggable={local.draggable}
+      onDragStart={(event: DragEvent) => {
+        if (!local.draggable) return
+        event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
+        event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
+        if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
+        withFileDragImage(event)
+      }}
+      {...rest}
+    >
+      {local.children}
+      <span
+        classList={{
+          "flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
+          "text-text-weaker": local.node.ignored,
+          "text-text-weak": !local.node.ignored && !active(),
+        }}
+        style={active() ? color() : undefined}
+      >
+        {local.node.name}
+      </span>
+      {(() => {
+        const value = kind()
+        if (!value) return null
+        if (local.node.type === "file") {
+          return (
+            <span class="shrink-0 w-4 text-center text-12-medium" style={kindTextColor(value)}>
+              {kindLabel(value)}
+            </span>
+          )
+        }
+        return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={kindDotColor(value)} />
+      })()}
+    </Dynamic>
+  )
+}
+
+const FileTreeNodeTooltip = (props: { enabled: boolean; node: FileNode; kind?: Kind; children: JSXElement }) => {
+  if (!props.enabled) return props.children
+
+  const parts = props.node.path.split("/")
+  const leaf = parts[parts.length - 1] ?? props.node.path
+  const head = parts.slice(0, -1).join("/")
+  const prefix = head ? `${head}/` : ""
+  const label =
+    props.kind === "add"
+      ? "Additions"
+      : props.kind === "del"
+        ? "Deletions"
+        : props.kind === "mix"
+          ? "Modifications"
+          : undefined
+
+  return (
+    <Tooltip
+      openDelay={2000}
+      placement="bottom-start"
+      class="w-full"
+      contentStyle={{ "max-width": "480px", width: "fit-content" }}
+      value={
+        <div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
+          <span
+            class="min-w-0 truncate text-text-invert-base"
+            style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
+          >
+            {prefix}
+          </span>
+          <span class="shrink-0 text-text-invert-strong">{leaf}</span>
+          <Show when={label}>
+            {(text) => (
+              <>
+                <span class="mx-1 font-bold text-text-invert-strong">•</span>
+                <span class="shrink-0 text-text-invert-strong">{text()}</span>
+              </>
+            )}
+          </Show>
+          <Show when={props.node.type === "directory" && props.node.ignored}>
+            <>
+              <span class="mx-1 font-bold text-text-invert-strong">•</span>
+              <span class="shrink-0 text-text-invert-strong">Ignored</span>
+            </>
+          </Show>
+        </div>
+      }
+    >
+      {props.children}
+    </Tooltip>
+  )
+}
+
 export default function FileTree(props: {
   path: string
   class?: string
@@ -230,178 +414,13 @@ export default function FileTree(props: {
     return out
   })
 
-  const Node = (
-    p: ParentProps &
-      ComponentProps<"div"> &
-      ComponentProps<"button"> & {
-        node: FileNode
-        as?: "div" | "button"
-      },
-  ) => {
-    const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"])
-    return (
-      <Dynamic
-        component={local.as ?? "div"}
-        classList={{
-          "w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
-          "bg-surface-base-active": local.node.path === props.active,
-          ...(local.classList ?? {}),
-          [local.class ?? ""]: !!local.class,
-          [props.nodeClass ?? ""]: !!props.nodeClass,
-        }}
-        style={`padding-left: ${Math.max(0, 8 + level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
-        draggable={draggable()}
-        onDragStart={(e: DragEvent) => {
-          if (!draggable()) return
-          e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
-          e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
-          if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
-
-          const dragImage = document.createElement("div")
-          dragImage.className =
-            "flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
-          dragImage.style.position = "absolute"
-          dragImage.style.top = "-1000px"
-
-          const icon =
-            (e.currentTarget as HTMLElement).querySelector('[data-component="file-icon"]') ??
-            (e.currentTarget as HTMLElement).querySelector("svg")
-          const text = (e.currentTarget as HTMLElement).querySelector("span")
-          if (icon && text) {
-            dragImage.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
-          }
-
-          document.body.appendChild(dragImage)
-          e.dataTransfer?.setDragImage(dragImage, 0, 12)
-          setTimeout(() => document.body.removeChild(dragImage), 0)
-        }}
-        {...rest}
-      >
-        {local.children}
-        {(() => {
-          const kind = kinds()?.get(local.node.path)
-          const marked = marks()?.has(local.node.path) ?? false
-          const active = !!kind && marked && !local.node.ignored
-          const color =
-            kind === "add"
-              ? "color: var(--icon-diff-add-base)"
-              : kind === "del"
-                ? "color: var(--icon-diff-delete-base)"
-                : kind === "mix"
-                  ? "color: var(--icon-warning-active)"
-                  : undefined
-          return (
-            <span
-              classList={{
-                "flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
-                "text-text-weaker": local.node.ignored,
-                "text-text-weak": !local.node.ignored && !active,
-              }}
-              style={active ? color : undefined}
-            >
-              {local.node.name}
-            </span>
-          )
-        })()}
-        {(() => {
-          const kind = kinds()?.get(local.node.path)
-          if (!kind) return null
-          if (!marks()?.has(local.node.path)) return null
-
-          if (local.node.type === "file") {
-            const text = kind === "add" ? "A" : kind === "del" ? "D" : "M"
-            const color =
-              kind === "add"
-                ? "color: var(--icon-diff-add-base)"
-                : kind === "del"
-                  ? "color: var(--icon-diff-delete-base)"
-                  : "color: var(--icon-warning-active)"
-
-            return (
-              <span class="shrink-0 w-4 text-center text-12-medium" style={color}>
-                {text}
-              </span>
-            )
-          }
-
-          if (local.node.type === "directory") {
-            const color =
-              kind === "add"
-                ? "background-color: var(--icon-diff-add-base)"
-                : kind === "del"
-                  ? "background-color: var(--icon-diff-delete-base)"
-                  : "background-color: var(--icon-warning-active)"
-
-            return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={color} />
-          }
-
-          return null
-        })()}
-      </Dynamic>
-    )
-  }
-
   return (
     <div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
       <For each={nodes()}>
         {(node) => {
           const expanded = () => file.tree.state(node.path)?.expanded ?? false
           const deep = () => deeps().get(node.path) ?? -1
-          const Wrapper = (p: ParentProps) => {
-            if (!tooltip()) return p.children
-
-            const parts = node.path.split("/")
-            const leaf = parts[parts.length - 1] ?? node.path
-            const head = parts.slice(0, -1).join("/")
-            const prefix = head ? `${head}/` : ""
-
-            const kind = () => kinds()?.get(node.path)
-            const label = () => {
-              const k = kind()
-              if (!k) return
-              if (k === "add") return "Additions"
-              if (k === "del") return "Deletions"
-              return "Modifications"
-            }
-
-            const ignored = () => node.type === "directory" && node.ignored
-
-            return (
-              <Tooltip
-                openDelay={2000}
-                placement="bottom-start"
-                class="w-full"
-                contentStyle={{ "max-width": "480px", width: "fit-content" }}
-                value={
-                  <div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
-                    <span
-                      class="min-w-0 truncate text-text-invert-base"
-                      style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
-                    >
-                      {prefix}
-                    </span>
-                    <span class="shrink-0 text-text-invert-strong">{leaf}</span>
-                    <Show when={label()}>
-                      {(t: () => string) => (
-                        <>
-                          <span class="mx-1 font-bold text-text-invert-strong">•</span>
-                          <span class="shrink-0 text-text-invert-strong">{t()}</span>
-                        </>
-                      )}
-                    </Show>
-                    <Show when={ignored()}>
-                      <>
-                        <span class="mx-1 font-bold text-text-invert-strong">•</span>
-                        <span class="shrink-0 text-text-invert-strong">Ignored</span>
-                      </>
-                    </Show>
-                  </div>
-                }
-              >
-                {p.children}
-              </Tooltip>
-            )
-          }
+          const kind = () => visibleKind(node, kinds(), marks())
 
           return (
             <Switch>
@@ -415,13 +434,21 @@ export default function FileTree(props: {
                   onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
                 >
                   <Collapsible.Trigger>
-                    <Wrapper>
-                      <Node node={node}>
+                    <FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
+                      <FileTreeNode
+                        node={node}
+                        level={level}
+                        active={props.active}
+                        nodeClass={props.nodeClass}
+                        draggable={draggable()}
+                        kinds={kinds()}
+                        marks={marks()}
+                      >
                         <div class="size-4 flex items-center justify-center text-icon-weak">
                           <Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
                         </div>
-                      </Node>
-                    </Wrapper>
+                      </FileTreeNode>
+                    </FileTreeNodeTooltip>
                   </Collapsible.Trigger>
                   <Collapsible.Content class="relative pt-0.5">
                     <div
@@ -451,12 +478,23 @@ export default function FileTree(props: {
                 </Collapsible>
               </Match>
               <Match when={node.type === "file"}>
-                <Wrapper>
-                  <Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
+                <FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
+                  <FileTreeNode
+                    node={node}
+                    level={level}
+                    active={props.active}
+                    nodeClass={props.nodeClass}
+                    draggable={draggable()}
+                    kinds={kinds()}
+                    marks={marks()}
+                    as="button"
+                    type="button"
+                    onClick={() => props.onFileClick?.(node)}
+                  >
                     <div class="w-4 shrink-0" />
                     <FileIcon node={node} class="text-icon-weak size-4" />
-                  </Node>
-                </Wrapper>
+                  </FileTreeNode>
+                </FileTreeNodeTooltip>
               </Match>
             </Switch>
           )

+ 13 - 4
packages/app/src/components/link.tsx

@@ -1,17 +1,26 @@
 import { ComponentProps, splitProps } from "solid-js"
 import { usePlatform } from "@/context/platform"
 
-export interface LinkProps extends ComponentProps<"button"> {
+export interface LinkProps extends Omit<ComponentProps<"a">, "href"> {
   href: string
 }
 
 export function Link(props: LinkProps) {
   const platform = usePlatform()
-  const [local, rest] = splitProps(props, ["href", "children"])
+  const [local, rest] = splitProps(props, ["href", "children", "class"])
 
   return (
-    <button class="text-text-strong underline" onClick={() => platform.openLink(local.href)} {...rest}>
+    <a
+      href={local.href}
+      class={`text-text-strong underline ${local.class ?? ""}`}
+      onClick={(event) => {
+        if (!local.href) return
+        event.preventDefault()
+        platform.openLink(local.href)
+      }}
+      {...rest}
+    >
       {local.children}
-    </button>
+    </a>
   )
 }

+ 58 - 53
packages/app/src/components/prompt-input.tsx

@@ -277,6 +277,47 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   const isFocused = createFocusSignal(() => editorRef)
 
+  const closePopover = () => setStore("popover", null)
+
+  const resetHistoryNavigation = (force = false) => {
+    if (!force && (store.historyIndex < 0 || store.applyingHistory)) return
+    setStore("historyIndex", -1)
+    setStore("savedPrompt", null)
+  }
+
+  const clearEditor = () => {
+    editorRef.innerHTML = ""
+  }
+
+  const setEditorText = (text: string) => {
+    clearEditor()
+    editorRef.textContent = text
+  }
+
+  const focusEditorEnd = () => {
+    requestAnimationFrame(() => {
+      editorRef.focus()
+      const range = document.createRange()
+      const selection = window.getSelection()
+      range.selectNodeContents(editorRef)
+      range.collapse(false)
+      selection?.removeAllRanges()
+      selection?.addRange(range)
+    })
+  }
+
+  const currentCursor = () => {
+    const selection = window.getSelection()
+    if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) return null
+    return getCursorPosition(editorRef)
+  }
+
+  const renderEditorWithCursor = (parts: Prompt) => {
+    const cursor = currentCursor()
+    renderEditor(parts)
+    if (cursor !== null) setCursorPosition(editorRef, cursor)
+  }
+
   createEffect(() => {
     params.id
     if (params.id) return
@@ -290,7 +331,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
 
   createEffect(() => {
-    if (!isFocused()) setStore("popover", null)
+    if (!isFocused()) closePopover()
   })
 
   // Safety: reset composing state on focus change to prevent stuck state
@@ -381,26 +422,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   const handleSlashSelect = (cmd: SlashCommand | undefined) => {
     if (!cmd) return
-    setStore("popover", null)
+    closePopover()
 
     if (cmd.type === "custom") {
       const text = `/${cmd.trigger} `
-      editorRef.innerHTML = ""
-      editorRef.textContent = text
+      setEditorText(text)
       prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
-      requestAnimationFrame(() => {
-        editorRef.focus()
-        const range = document.createRange()
-        const sel = window.getSelection()
-        range.selectNodeContents(editorRef)
-        range.collapse(false)
-        sel?.removeAllRanges()
-        sel?.addRange(range)
-      })
+      focusEditorEnd()
       return
     }
 
-    editorRef.innerHTML = ""
+    clearEditor()
     prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
     command.trigger(cmd.id, "slash")
   }
@@ -454,7 +486,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     })
 
   const renderEditor = (parts: Prompt) => {
-    editorRef.innerHTML = ""
+    clearEditor()
     for (const part of parts) {
       if (part.type === "text") {
         editorRef.appendChild(createTextFragment(part.content))
@@ -514,34 +546,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           mirror.input = false
           if (isNormalizedEditor()) return
 
-          const selection = window.getSelection()
-          let cursorPosition: number | null = null
-          if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
-            cursorPosition = getCursorPosition(editorRef)
-          }
-
-          renderEditor(inputParts)
-
-          if (cursorPosition !== null) {
-            setCursorPosition(editorRef, cursorPosition)
-          }
+          renderEditorWithCursor(inputParts)
           return
         }
 
         const domParts = parseFromDOM()
         if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
 
-        const selection = window.getSelection()
-        let cursorPosition: number | null = null
-        if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
-          cursorPosition = getCursorPosition(editorRef)
-        }
-
-        renderEditor(inputParts)
-
-        if (cursorPosition !== null) {
-          setCursorPosition(editorRef, cursorPosition)
-        }
+        renderEditorWithCursor(inputParts)
       },
     ),
   )
@@ -636,11 +648,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
 
     if (shouldReset) {
-      setStore("popover", null)
-      if (store.historyIndex >= 0 && !store.applyingHistory) {
-        setStore("historyIndex", -1)
-        setStore("savedPrompt", null)
-      }
+      closePopover()
+      resetHistoryNavigation()
       if (prompt.dirty()) {
         mirror.input = true
         prompt.set(DEFAULT_PROMPT, 0)
@@ -662,16 +671,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         slashOnInput(slashMatch[1])
         setStore("popover", "slash")
       } else {
-        setStore("popover", null)
+        closePopover()
       }
     } else {
-      setStore("popover", null)
+      closePopover()
     }
 
-    if (store.historyIndex >= 0 && !store.applyingHistory) {
-      setStore("historyIndex", -1)
-      setStore("savedPrompt", null)
-    }
+    resetHistoryNavigation()
 
     mirror.input = true
     prompt.set([...rawParts, ...images], cursorPosition)
@@ -732,7 +738,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
 
     handleInput()
-    setStore("popover", null)
+    closePopover()
   }
 
   const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
@@ -782,8 +788,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     promptLength,
     addToHistory,
     resetHistoryNavigation: () => {
-      setStore("historyIndex", -1)
-      setStore("savedPrompt", null)
+      resetHistoryNavigation(true)
     },
     setMode: (mode) => setStore("mode", mode),
     setPopover: (popover) => setStore("popover", popover),
@@ -872,7 +877,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
     if (ctrl && event.code === "KeyG") {
       if (store.popover) {
-        setStore("popover", null)
+        closePopover()
         event.preventDefault()
         return
       }
@@ -923,7 +928,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
     if (event.key === "Escape") {
       if (store.popover) {
-        setStore("popover", null)
+        closePopover()
       } else if (working()) {
         abort()
       }

+ 58 - 51
packages/app/src/components/prompt-input/context-items.tsx

@@ -20,61 +20,68 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
     <Show when={props.items.length > 0}>
       <div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
         <For each={props.items}>
-          {(item) => (
-            <Tooltip
-              value={
-                <span class="flex max-w-[300px]">
-                  <span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
-                    {getDirectory(item.path)}
+          {(item) => {
+            const directory = getDirectory(item.path)
+            const filename = getFilename(item.path)
+            const label = getFilenameTruncated(item.path, 14)
+            const selected = props.active(item)
+
+            return (
+              <Tooltip
+                value={
+                  <span class="flex max-w-[300px]">
+                    <span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
+                      {directory}
+                    </span>
+                    <span class="shrink-0">{filename}</span>
                   </span>
-                  <span class="shrink-0">{getFilename(item.path)}</span>
-                </span>
-              }
-              placement="top"
-              openDelay={2000}
-            >
-              <div
-                classList={{
-                  "group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
-                  "cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !props.active(item),
-                  "cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
-                    props.active(item),
-                  "bg-background-stronger": !props.active(item),
-                }}
-                onClick={() => props.openComment(item)}
+                }
+                placement="top"
+                openDelay={2000}
               >
-                <div class="flex items-center gap-1.5">
-                  <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
-                  <div class="flex items-center text-11-regular min-w-0 font-medium">
-                    <span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
-                    <Show when={item.selection}>
-                      {(sel) => (
-                        <span class="text-text-weak whitespace-nowrap shrink-0">
-                          {sel().startLine === sel().endLine
-                            ? `:${sel().startLine}`
-                            : `:${sel().startLine}-${sel().endLine}`}
-                        </span>
-                      )}
-                    </Show>
+                <div
+                  classList={{
+                    "group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
+                    "cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !selected,
+                    "cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
+                      selected,
+                    "bg-background-stronger": !selected,
+                  }}
+                  onClick={() => props.openComment(item)}
+                >
+                  <div class="flex items-center gap-1.5">
+                    <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
+                    <div class="flex items-center text-11-regular min-w-0 font-medium">
+                      <span class="text-text-strong whitespace-nowrap">{label}</span>
+                      <Show when={item.selection}>
+                        {(sel) => (
+                          <span class="text-text-weak whitespace-nowrap shrink-0">
+                            {sel().startLine === sel().endLine
+                              ? `:${sel().startLine}`
+                              : `:${sel().startLine}-${sel().endLine}`}
+                          </span>
+                        )}
+                      </Show>
+                    </div>
+                    <IconButton
+                      type="button"
+                      icon="close-small"
+                      variant="ghost"
+                      class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
+                      onClick={(e) => {
+                        e.stopPropagation()
+                        props.remove(item)
+                      }}
+                      aria-label={props.t("prompt.context.removeFile")}
+                    />
                   </div>
-                  <IconButton
-                    type="button"
-                    icon="close-small"
-                    variant="ghost"
-                    class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
-                    onClick={(e) => {
-                      e.stopPropagation()
-                      props.remove(item)
-                    }}
-                    aria-label={props.t("prompt.context.removeFile")}
-                  />
+                  <Show when={item.comment}>
+                    {(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
+                  </Show>
                 </div>
-                <Show when={item.comment}>
-                  {(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
-                </Show>
-              </div>
-            </Tooltip>
-          )}
+              </Tooltip>
+            )
+          }}
         </For>
       </div>
     </Show>

+ 6 - 1
packages/app/src/components/prompt-input/drag-overlay.tsx

@@ -6,12 +6,17 @@ type PromptDragOverlayProps = {
   label: string
 }
 
+const kindToIcon = {
+  image: "photo",
+  "@mention": "link",
+} as const
+
 export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
   return (
     <Show when={props.type !== null}>
       <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
         <div class="flex flex-col items-center gap-2 text-text-weak">
-          <Icon name={props.type === "@mention" ? "link" : "photo"} class="size-8" />
+          <Icon name={props.type ? kindToIcon[props.type] : kindToIcon.image} class="size-8" />
           <span class="text-14-regular">{props.label}</span>
         </div>
       </div>

+ 11 - 4
packages/app/src/components/prompt-input/image-attachments.tsx

@@ -9,6 +9,13 @@ type PromptImageAttachmentsProps = {
   removeLabel: string
 }
 
+const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
+const imageClass =
+  "size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
+const removeClass =
+  "absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
+const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
+
 export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
   return (
     <Show when={props.attachments.length > 0}>
@@ -19,7 +26,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
               <Show
                 when={attachment.mime.startsWith("image/")}
                 fallback={
-                  <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
+                  <div class={fallbackClass}>
                     <Icon name="folder" class="size-6 text-text-weak" />
                   </div>
                 }
@@ -27,19 +34,19 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
                 <img
                   src={attachment.dataUrl}
                   alt={attachment.filename}
-                  class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
+                  class={imageClass}
                   onClick={() => props.onOpen(attachment)}
                 />
               </Show>
               <button
                 type="button"
                 onClick={() => props.onRemove(attachment.id)}
-                class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
+                class={removeClass}
                 aria-label={props.removeLabel}
               >
                 <Icon name="close" class="size-3 text-text-weak" />
               </button>
-              <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
+              <div class={nameClass}>
                 <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
               </div>
             </div>

+ 39 - 40
packages/app/src/components/prompt-input/slash-popover.tsx

@@ -52,47 +52,46 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
               fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
             >
               <For each={props.atFlat.slice(0, 10)}>
-                {(item) => (
-                  <button
-                    classList={{
-                      "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
-                      "bg-surface-raised-base-hover": props.atActive === props.atKey(item),
-                    }}
-                    onClick={() => props.onAtSelect(item)}
-                    onMouseEnter={() => props.setAtActive(props.atKey(item))}
-                  >
-                    <Show
-                      when={item.type === "agent"}
-                      fallback={
-                        <>
-                          <FileIcon
-                            node={{ path: item.type === "file" ? item.path : "", type: "file" }}
-                            class="shrink-0 size-4"
-                          />
-                          <div class="flex items-center text-14-regular min-w-0">
-                            <span class="text-text-weak whitespace-nowrap truncate min-w-0">
-                              {item.type === "file"
-                                ? item.path.endsWith("/")
-                                  ? item.path
-                                  : getDirectory(item.path)
-                                : ""}
-                            </span>
-                            <Show when={item.type === "file" && !item.path.endsWith("/")}>
-                              <span class="text-text-strong whitespace-nowrap">
-                                {item.type === "file" ? getFilename(item.path) : ""}
-                              </span>
-                            </Show>
-                          </div>
-                        </>
-                      }
+                {(item) => {
+                  const active = props.atActive === props.atKey(item)
+                  const shared = {
+                    "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
+                    "bg-surface-raised-base-hover": active,
+                  }
+
+                  if (item.type === "agent") {
+                    return (
+                      <button
+                        classList={shared}
+                        onClick={() => props.onAtSelect(item)}
+                        onMouseEnter={() => props.setAtActive(props.atKey(item))}
+                      >
+                        <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
+                        <span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
+                      </button>
+                    )
+                  }
+
+                  const isDirectory = item.path.endsWith("/")
+                  const directory = isDirectory ? item.path : getDirectory(item.path)
+                  const filename = isDirectory ? "" : getFilename(item.path)
+
+                  return (
+                    <button
+                      classList={shared}
+                      onClick={() => props.onAtSelect(item)}
+                      onMouseEnter={() => props.setAtActive(props.atKey(item))}
                     >
-                      <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
-                      <span class="text-14-regular text-text-strong whitespace-nowrap">
-                        @{item.type === "agent" ? item.name : ""}
-                      </span>
-                    </Show>
-                  </button>
-                )}
+                      <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
+                      <div class="flex items-center text-14-regular min-w-0">
+                        <span class="text-text-weak whitespace-nowrap truncate min-w-0">{directory}</span>
+                        <Show when={!isDirectory}>
+                          <span class="text-text-strong whitespace-nowrap">{filename}</span>
+                        </Show>
+                      </div>
+                    </button>
+                  )
+                }}
               </For>
             </Show>
           </Match>

+ 49 - 37
packages/app/src/components/question-dock.tsx

@@ -7,6 +7,32 @@ import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
 import { useLanguage } from "@/context/language"
 import { useSDK } from "@/context/sdk"
 
+const writeAt = <T,>(list: T[], index: number, value: T) => {
+  const next = [...list]
+  next[index] = value
+  return next
+}
+
+const pickAnswer = (list: QuestionAnswer[], index: number, value: string) => {
+  return writeAt(list, index, [value])
+}
+
+const toggleAnswer = (list: QuestionAnswer[], index: number, value: string) => {
+  const current = list[index] ?? []
+  const next = current.includes(value) ? current.filter((item) => item !== value) : [...current, value]
+  return writeAt(list, index, next)
+}
+
+const appendAnswer = (list: QuestionAnswer[], index: number, value: string) => {
+  const current = list[index] ?? []
+  if (current.includes(value)) return list
+  return writeAt(list, index, [...current, value])
+}
+
+const writeCustom = (list: string[], index: number, value: string) => {
+  return writeAt(list, index, value)
+}
+
 export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
   const sdk = useSDK()
   const language = useLanguage()
@@ -38,43 +64,45 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
     showToast({ title: language.t("common.requestFailed"), description: message })
   }
 
-  const reply = (answers: QuestionAnswer[]) => {
+  const reply = async (answers: QuestionAnswer[]) => {
     if (store.sending) return
 
     setStore("sending", true)
-    sdk.client.question
-      .reply({ requestID: props.request.id, answers })
-      .catch(fail)
-      .finally(() => setStore("sending", false))
+    try {
+      await sdk.client.question.reply({ requestID: props.request.id, answers })
+    } catch (err) {
+      fail(err)
+    } finally {
+      setStore("sending", false)
+    }
   }
 
-  const reject = () => {
+  const reject = async () => {
     if (store.sending) return
 
     setStore("sending", true)
-    sdk.client.question
-      .reject({ requestID: props.request.id })
-      .catch(fail)
-      .finally(() => setStore("sending", false))
+    try {
+      await sdk.client.question.reject({ requestID: props.request.id })
+    } catch (err) {
+      fail(err)
+    } finally {
+      setStore("sending", false)
+    }
   }
 
   const submit = () => {
-    reply(questions().map((_, i) => store.answers[i] ?? []))
+    void reply(questions().map((_, i) => store.answers[i] ?? []))
   }
 
   const pick = (answer: string, custom: boolean = false) => {
-    const answers = [...store.answers]
-    answers[store.tab] = [answer]
-    setStore("answers", answers)
+    setStore("answers", pickAnswer(store.answers, store.tab, answer))
 
     if (custom) {
-      const inputs = [...store.custom]
-      inputs[store.tab] = answer
-      setStore("custom", inputs)
+      setStore("custom", writeCustom(store.custom, store.tab, answer))
     }
 
     if (single()) {
-      reply([[answer]])
+      void reply([[answer]])
       return
     }
 
@@ -82,15 +110,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
   }
 
   const toggle = (answer: string) => {
-    const existing = store.answers[store.tab] ?? []
-    const next = [...existing]
-    const index = next.indexOf(answer)
-    if (index === -1) next.push(answer)
-    if (index !== -1) next.splice(index, 1)
-
-    const answers = [...store.answers]
-    answers[store.tab] = next
-    setStore("answers", answers)
+    setStore("answers", toggleAnswer(store.answers, store.tab, answer))
   }
 
   const selectTab = (index: number) => {
@@ -126,13 +146,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
     }
 
     if (multi()) {
-      const existing = store.answers[store.tab] ?? []
-      const next = [...existing]
-      if (!next.includes(value)) next.push(value)
-
-      const answers = [...store.answers]
-      answers[store.tab] = next
-      setStore("answers", answers)
+      setStore("answers", appendAnswer(store.answers, store.tab, value))
       setStore("editing", false)
       return
     }
@@ -225,9 +239,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
                   value={input()}
                   disabled={store.sending}
                   onInput={(e) => {
-                    const inputs = [...store.custom]
-                    inputs[store.tab] = e.currentTarget.value
-                    setStore("custom", inputs)
+                    setStore("custom", writeCustom(store.custom, store.tab, e.currentTarget.value))
                   }}
                 />
                 <Button type="submit" variant="primary" size="small" disabled={store.sending}>

+ 11 - 11
packages/app/src/components/server/server-row.tsx

@@ -1,5 +1,5 @@
 import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { JSXElement, ParentProps, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
+import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
 import { serverDisplayName } from "@/context/server"
 import type { ServerHealth } from "@/utils/server-health"
 
@@ -17,6 +17,7 @@ export function ServerRow(props: ServerRowProps) {
   const [truncated, setTruncated] = createSignal(false)
   let nameRef: HTMLSpanElement | undefined
   let versionRef: HTMLSpanElement | undefined
+  const name = createMemo(() => serverDisplayName(props.url))
 
   const check = () => {
     const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -25,25 +26,24 @@ export function ServerRow(props: ServerRowProps) {
   }
 
   createEffect(() => {
+    name()
     props.url
     props.status?.version
-    if (typeof requestAnimationFrame === "function") {
-      requestAnimationFrame(check)
-      return
-    }
-    check()
+    queueMicrotask(check)
   })
 
   onMount(() => {
     check()
-    if (typeof window === "undefined") return
-    window.addEventListener("resize", check)
-    onCleanup(() => window.removeEventListener("resize", check))
+    if (typeof ResizeObserver !== "function") return
+    const observer = new ResizeObserver(check)
+    if (nameRef) observer.observe(nameRef)
+    if (versionRef) observer.observe(versionRef)
+    onCleanup(() => observer.disconnect())
   })
 
   const tooltipValue = () => (
     <span class="flex items-center gap-2">
-      <span>{serverDisplayName(props.url)}</span>
+      <span>{name()}</span>
       <Show when={props.status?.version}>
         <span class="text-text-invert-base">{props.status?.version}</span>
       </Show>
@@ -62,7 +62,7 @@ export function ServerRow(props: ServerRowProps) {
           }}
         />
         <span ref={nameRef} class={props.nameClass ?? "truncate"}>
-          {serverDisplayName(props.url)}
+          {name()}
         </span>
         <Show when={props.status?.version}>
           <span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>

+ 17 - 5
packages/app/src/components/session-context-usage.tsx

@@ -13,6 +13,18 @@ interface SessionContextUsageProps {
   variant?: "button" | "indicator"
 }
 
+function openSessionContext(args: {
+  view: ReturnType<ReturnType<typeof useLayout>["view"]>
+  layout: ReturnType<typeof useLayout>
+  tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]>
+}) {
+  if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open()
+  args.layout.fileTree.open()
+  args.layout.fileTree.setTab("all")
+  args.tabs.open("context")
+  args.tabs.setActive("context")
+}
+
 export function SessionContextUsage(props: SessionContextUsageProps) {
   const sync = useSync()
   const params = useParams()
@@ -41,11 +53,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
 
   const openContext = () => {
     if (!params.id) return
-    if (!view().reviewPanel.opened()) view().reviewPanel.open()
-    layout.fileTree.open()
-    layout.fileTree.setTab("all")
-    tabs().open("context")
-    tabs().setActive("context")
+    openSessionContext({
+      view: view(),
+      layout,
+      tabs: tabs(),
+    })
   }
 
   const circle = () => (

+ 61 - 0
packages/app/src/components/session/session-context-breakdown.test.ts

@@ -0,0 +1,61 @@
+import { describe, expect, test } from "bun:test"
+import type { Message, Part } from "@opencode-ai/sdk/v2/client"
+import { estimateSessionContextBreakdown } from "./session-context-breakdown"
+
+const user = (id: string) => {
+  return {
+    id,
+    role: "user",
+    time: { created: 1 },
+  } as unknown as Message
+}
+
+const assistant = (id: string) => {
+  return {
+    id,
+    role: "assistant",
+    time: { created: 1 },
+  } as unknown as Message
+}
+
+describe("estimateSessionContextBreakdown", () => {
+  test("estimates tokens and keeps remaining tokens as other", () => {
+    const messages = [user("u1"), assistant("a1")]
+    const parts = {
+      u1: [{ type: "text", text: "hello world" }] as unknown as Part[],
+      a1: [{ type: "text", text: "assistant response" }] as unknown as Part[],
+    }
+
+    const output = estimateSessionContextBreakdown({
+      messages,
+      parts,
+      input: 20,
+      systemPrompt: "system prompt",
+    })
+
+    const map = Object.fromEntries(output.map((segment) => [segment.key, segment.tokens]))
+    expect(map.system).toBe(4)
+    expect(map.user).toBe(3)
+    expect(map.assistant).toBe(5)
+    expect(map.other).toBe(8)
+  })
+
+  test("scales segments when estimates exceed input", () => {
+    const messages = [user("u1"), assistant("a1")]
+    const parts = {
+      u1: [{ type: "text", text: "x".repeat(400) }] as unknown as Part[],
+      a1: [{ type: "text", text: "y".repeat(400) }] as unknown as Part[],
+    }
+
+    const output = estimateSessionContextBreakdown({
+      messages,
+      parts,
+      input: 10,
+      systemPrompt: "z".repeat(200),
+    })
+
+    const total = output.reduce((sum, segment) => sum + segment.tokens, 0)
+    expect(total).toBeLessThanOrEqual(10)
+    expect(output.every((segment) => segment.width <= 100)).toBeTrue()
+  })
+})

+ 132 - 0
packages/app/src/components/session/session-context-breakdown.ts

@@ -0,0 +1,132 @@
+import type { Message, Part } from "@opencode-ai/sdk/v2/client"
+
+export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
+
+export type SessionContextBreakdownSegment = {
+  key: SessionContextBreakdownKey
+  tokens: number
+  width: number
+  percent: number
+}
+
+const estimateTokens = (chars: number) => Math.ceil(chars / 4)
+const toPercent = (tokens: number, input: number) => (tokens / input) * 100
+const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10
+
+const charsFromUserPart = (part: Part) => {
+  if (part.type === "text") return part.text.length
+  if (part.type === "file") return part.source?.text.value.length ?? 0
+  if (part.type === "agent") return part.source?.value.length ?? 0
+  return 0
+}
+
+const charsFromAssistantPart = (part: Part) => {
+  if (part.type === "text") return { assistant: part.text.length, tool: 0 }
+  if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 }
+  if (part.type !== "tool") return { assistant: 0, tool: 0 }
+
+  const input = Object.keys(part.state.input).length * 16
+  if (part.state.status === "pending") return { assistant: 0, tool: input + part.state.raw.length }
+  if (part.state.status === "completed") return { assistant: 0, tool: input + part.state.output.length }
+  if (part.state.status === "error") return { assistant: 0, tool: input + part.state.error.length }
+  return { assistant: 0, tool: input }
+}
+
+const build = (
+  tokens: { system: number; user: number; assistant: number; tool: number; other: number },
+  input: number,
+) => {
+  return [
+    {
+      key: "system",
+      tokens: tokens.system,
+    },
+    {
+      key: "user",
+      tokens: tokens.user,
+    },
+    {
+      key: "assistant",
+      tokens: tokens.assistant,
+    },
+    {
+      key: "tool",
+      tokens: tokens.tool,
+    },
+    {
+      key: "other",
+      tokens: tokens.other,
+    },
+  ]
+    .filter((x) => x.tokens > 0)
+    .map((x) => ({
+      key: x.key,
+      tokens: x.tokens,
+      width: toPercent(x.tokens, input),
+      percent: toPercentLabel(x.tokens, input),
+    })) as SessionContextBreakdownSegment[]
+}
+
+export function estimateSessionContextBreakdown(args: {
+  messages: Message[]
+  parts: Record<string, Part[] | undefined>
+  input: number
+  systemPrompt?: string
+}) {
+  if (!args.input) return []
+
+  const counts = args.messages.reduce(
+    (acc, msg) => {
+      const parts = args.parts[msg.id] ?? []
+      if (msg.role === "user") {
+        const user = parts.reduce((sum, part) => sum + charsFromUserPart(part), 0)
+        return { ...acc, user: acc.user + user }
+      }
+
+      if (msg.role !== "assistant") return acc
+      const assistant = parts.reduce(
+        (sum, part) => {
+          const next = charsFromAssistantPart(part)
+          return {
+            assistant: sum.assistant + next.assistant,
+            tool: sum.tool + next.tool,
+          }
+        },
+        { assistant: 0, tool: 0 },
+      )
+      return {
+        ...acc,
+        assistant: acc.assistant + assistant.assistant,
+        tool: acc.tool + assistant.tool,
+      }
+    },
+    {
+      system: args.systemPrompt?.length ?? 0,
+      user: 0,
+      assistant: 0,
+      tool: 0,
+    },
+  )
+
+  const tokens = {
+    system: estimateTokens(counts.system),
+    user: estimateTokens(counts.user),
+    assistant: estimateTokens(counts.assistant),
+    tool: estimateTokens(counts.tool),
+  }
+  const estimated = tokens.system + tokens.user + tokens.assistant + tokens.tool
+
+  if (estimated <= args.input) {
+    return build({ ...tokens, other: args.input - estimated }, args.input)
+  }
+
+  const scale = args.input / estimated
+  const scaled = {
+    system: Math.floor(tokens.system * scale),
+    user: Math.floor(tokens.user * scale),
+    assistant: Math.floor(tokens.assistant * scale),
+    tool: Math.floor(tokens.tool * scale),
+  }
+  const total = scaled.system + scaled.user + scaled.assistant + scaled.tool
+  return build({ ...scaled, other: Math.max(0, args.input - total) }, args.input)
+}

+ 20 - 0
packages/app/src/components/session/session-context-format.ts

@@ -0,0 +1,20 @@
+import { DateTime } from "luxon"
+
+export function createSessionContextFormatter(locale: string) {
+  return {
+    number(value: number | null | undefined) {
+      if (value === undefined) return "—"
+      if (value === null) return "—"
+      return value.toLocaleString(locale)
+    },
+    percent(value: number | null | undefined) {
+      if (value === undefined) return "—"
+      if (value === null) return "—"
+      return value.toLocaleString(locale) + "%"
+    },
+    time(value: number | undefined) {
+      if (!value) return "—"
+      return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED)
+    },
+  }
+}

+ 103 - 185
packages/app/src/components/session/session-context-tab.tsx

@@ -1,7 +1,6 @@
 import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
 import type { JSX } from "solid-js"
 import { useParams } from "@solidjs/router"
-import { DateTime } from "luxon"
 import { useSync } from "@/context/sync"
 import { useLayout } from "@/context/layout"
 import { checksum } from "@opencode-ai/util/encode"
@@ -14,6 +13,8 @@ import { Markdown } from "@opencode-ai/ui/markdown"
 import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
 import { useLanguage } from "@/context/language"
 import { getSessionContextMetrics } from "./session-context-metrics"
+import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
+import { createSessionContextFormatter } from "./session-context-format"
 
 interface SessionContextTabProps {
   messages: () => Message[]
@@ -22,6 +23,74 @@ interface SessionContextTabProps {
   info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
 }
 
+const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
+  system: "var(--syntax-info)",
+  user: "var(--syntax-success)",
+  assistant: "var(--syntax-property)",
+  tool: "var(--syntax-warning)",
+  other: "var(--syntax-comment)",
+}
+
+function Stat(props: { label: string; value: JSX.Element }) {
+  return (
+    <div class="flex flex-col gap-1">
+      <div class="text-12-regular text-text-weak">{props.label}</div>
+      <div class="text-12-medium text-text-strong">{props.value}</div>
+    </div>
+  )
+}
+
+function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) {
+  const file = createMemo(() => {
+    const parts = props.getParts(props.message.id)
+    const contents = JSON.stringify({ message: props.message, parts }, null, 2)
+    return {
+      name: `${props.message.role}-${props.message.id}.json`,
+      contents,
+      cacheKey: checksum(contents),
+    }
+  })
+
+  return (
+    <Code
+      file={file()}
+      overflow="wrap"
+      class="select-text"
+      onRendered={() => requestAnimationFrame(props.onRendered)}
+    />
+  )
+}
+
+function RawMessage(props: {
+  message: Message
+  getParts: (id: string) => Part[]
+  onRendered: () => void
+  time: (value: number | undefined) => string
+}) {
+  return (
+    <Accordion.Item value={props.message.id}>
+      <StickyAccordionHeader>
+        <Accordion.Trigger>
+          <div class="flex items-center justify-between gap-2 w-full">
+            <div class="min-w-0 truncate">
+              {props.message.role} <span class="text-text-base">• {props.message.id}</span>
+            </div>
+            <div class="flex items-center gap-3">
+              <div class="shrink-0 text-12-regular text-text-weak">{props.time(props.message.time.created)}</div>
+              <Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
+            </div>
+          </div>
+        </Accordion.Trigger>
+      </StickyAccordionHeader>
+      <Accordion.Content class="bg-background-base">
+        <div class="p-3">
+          <RawMessageContent message={props.message} getParts={props.getParts} onRendered={props.onRendered} />
+        </div>
+      </Accordion.Content>
+    </Accordion.Item>
+  )
+}
+
 export function SessionContextTab(props: SessionContextTabProps) {
   const params = useParams()
   const sync = useSync()
@@ -37,6 +106,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
 
   const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
   const ctx = createMemo(() => metrics().context)
+  const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
 
   const cost = createMemo(() => {
     return usd().format(metrics().totalCost)
@@ -62,23 +132,6 @@ export function SessionContextTab(props: SessionContextTabProps) {
     return trimmed
   })
 
-  const number = (value: number | null | undefined) => {
-    if (value === undefined) return "—"
-    if (value === null) return "—"
-    return value.toLocaleString(language.locale())
-  }
-
-  const percent = (value: number | null | undefined) => {
-    if (value === undefined) return "—"
-    if (value === null) return "—"
-    return value.toLocaleString(language.locale()) + "%"
-  }
-
-  const time = (value: number | undefined) => {
-    if (!value) return "—"
-    return DateTime.fromMillis(value).setLocale(language.locale()).toLocaleString(DateTime.DATETIME_MED)
-  }
-
   const providerLabel = createMemo(() => {
     const c = ctx()
     if (!c) return "—"
@@ -96,122 +149,23 @@ export function SessionContextTab(props: SessionContextTabProps) {
       () => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
       () => {
         const c = ctx()
-        if (!c) return []
-        const input = c.input
-        if (!input) return []
-
-        const out = {
-          system: systemPrompt()?.length ?? 0,
-          user: 0,
-          assistant: 0,
-          tool: 0,
-        }
-
-        for (const msg of props.messages()) {
-          const parts = (sync.data.part[msg.id] ?? []) as Part[]
-
-          if (msg.role === "user") {
-            for (const part of parts) {
-              if (part.type === "text") out.user += part.text.length
-              if (part.type === "file") out.user += part.source?.text.value.length ?? 0
-              if (part.type === "agent") out.user += part.source?.value.length ?? 0
-            }
-            continue
-          }
-
-          if (msg.role === "assistant") {
-            for (const part of parts) {
-              if (part.type === "text") out.assistant += part.text.length
-              if (part.type === "reasoning") out.assistant += part.text.length
-              if (part.type === "tool") {
-                out.tool += Object.keys(part.state.input).length * 16
-                if (part.state.status === "pending") out.tool += part.state.raw.length
-                if (part.state.status === "completed") out.tool += part.state.output.length
-                if (part.state.status === "error") out.tool += part.state.error.length
-              }
-            }
-          }
-        }
-
-        const estimateTokens = (chars: number) => Math.ceil(chars / 4)
-        const system = estimateTokens(out.system)
-        const user = estimateTokens(out.user)
-        const assistant = estimateTokens(out.assistant)
-        const tool = estimateTokens(out.tool)
-        const estimated = system + user + assistant + tool
-
-        const pct = (tokens: number) => (tokens / input) * 100
-        const pctLabel = (tokens: number) => (Math.round(pct(tokens) * 10) / 10).toString() + "%"
-
-        const build = (tokens: { system: number; user: number; assistant: number; tool: number; other: number }) => {
-          return [
-            {
-              key: "system",
-              label: language.t("context.breakdown.system"),
-              tokens: tokens.system,
-              width: pct(tokens.system),
-              percent: pctLabel(tokens.system),
-              color: "var(--syntax-info)",
-            },
-            {
-              key: "user",
-              label: language.t("context.breakdown.user"),
-              tokens: tokens.user,
-              width: pct(tokens.user),
-              percent: pctLabel(tokens.user),
-              color: "var(--syntax-success)",
-            },
-            {
-              key: "assistant",
-              label: language.t("context.breakdown.assistant"),
-              tokens: tokens.assistant,
-              width: pct(tokens.assistant),
-              percent: pctLabel(tokens.assistant),
-              color: "var(--syntax-property)",
-            },
-            {
-              key: "tool",
-              label: language.t("context.breakdown.tool"),
-              tokens: tokens.tool,
-              width: pct(tokens.tool),
-              percent: pctLabel(tokens.tool),
-              color: "var(--syntax-warning)",
-            },
-            {
-              key: "other",
-              label: language.t("context.breakdown.other"),
-              tokens: tokens.other,
-              width: pct(tokens.other),
-              percent: pctLabel(tokens.other),
-              color: "var(--syntax-comment)",
-            },
-          ].filter((x) => x.tokens > 0)
-        }
-
-        if (estimated <= input) {
-          return build({ system, user, assistant, tool, other: input - estimated })
-        }
-
-        const scale = input / estimated
-        const scaled = {
-          system: Math.floor(system * scale),
-          user: Math.floor(user * scale),
-          assistant: Math.floor(assistant * scale),
-          tool: Math.floor(tool * scale),
-        }
-        const scaledTotal = scaled.system + scaled.user + scaled.assistant + scaled.tool
-        return build({ ...scaled, other: Math.max(0, input - scaledTotal) })
+        if (!c?.input) return []
+        return estimateSessionContextBreakdown({
+          messages: props.messages(),
+          parts: sync.data.part as Record<string, Part[] | undefined>,
+          input: c.input,
+          systemPrompt: systemPrompt(),
+        })
       },
     ),
   )
 
-  function Stat(statProps: { label: string; value: JSX.Element }) {
-    return (
-      <div class="flex flex-col gap-1">
-        <div class="text-12-regular text-text-weak">{statProps.label}</div>
-        <div class="text-12-medium text-text-strong">{statProps.value}</div>
-      </div>
-    )
+  const breakdownLabel = (key: SessionContextBreakdownKey) => {
+    if (key === "system") return language.t("context.breakdown.system")
+    if (key === "user") return language.t("context.breakdown.user")
+    if (key === "assistant") return language.t("context.breakdown.assistant")
+    if (key === "tool") return language.t("context.breakdown.tool")
+    return language.t("context.breakdown.other")
   }
 
   const stats = createMemo(() => {
@@ -222,15 +176,15 @@ export function SessionContextTab(props: SessionContextTabProps) {
       { label: language.t("context.stats.messages"), value: count.all.toLocaleString(language.locale()) },
       { label: language.t("context.stats.provider"), value: providerLabel() },
       { label: language.t("context.stats.model"), value: modelLabel() },
-      { label: language.t("context.stats.limit"), value: number(c?.limit) },
-      { label: language.t("context.stats.totalTokens"), value: number(c?.total) },
-      { label: language.t("context.stats.usage"), value: percent(c?.usage) },
-      { label: language.t("context.stats.inputTokens"), value: number(c?.input) },
-      { label: language.t("context.stats.outputTokens"), value: number(c?.output) },
-      { label: language.t("context.stats.reasoningTokens"), value: number(c?.reasoning) },
+      { label: language.t("context.stats.limit"), value: formatter().number(c?.limit) },
+      { label: language.t("context.stats.totalTokens"), value: formatter().number(c?.total) },
+      { label: language.t("context.stats.usage"), value: formatter().percent(c?.usage) },
+      { label: language.t("context.stats.inputTokens"), value: formatter().number(c?.input) },
+      { label: language.t("context.stats.outputTokens"), value: formatter().number(c?.output) },
+      { label: language.t("context.stats.reasoningTokens"), value: formatter().number(c?.reasoning) },
       {
         label: language.t("context.stats.cacheTokens"),
-        value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}`,
+        value: `${formatter().number(c?.cacheRead)} / ${formatter().number(c?.cacheWrite)}`,
       },
       { label: language.t("context.stats.userMessages"), value: count.user.toLocaleString(language.locale()) },
       {
@@ -238,55 +192,15 @@ export function SessionContextTab(props: SessionContextTabProps) {
         value: count.assistant.toLocaleString(language.locale()),
       },
       { label: language.t("context.stats.totalCost"), value: cost() },
-      { label: language.t("context.stats.sessionCreated"), value: time(props.info()?.time.created) },
-      { label: language.t("context.stats.lastActivity"), value: time(c?.message.time.created) },
+      { label: language.t("context.stats.sessionCreated"), value: formatter().time(props.info()?.time.created) },
+      { label: language.t("context.stats.lastActivity"), value: formatter().time(c?.message.time.created) },
     ] satisfies { label: string; value: JSX.Element }[]
   })
 
-  function RawMessageContent(msgProps: { message: Message }) {
-    const file = createMemo(() => {
-      const parts = (sync.data.part[msgProps.message.id] ?? []) as Part[]
-      const contents = JSON.stringify({ message: msgProps.message, parts }, null, 2)
-      return {
-        name: `${msgProps.message.role}-${msgProps.message.id}.json`,
-        contents,
-        cacheKey: checksum(contents),
-      }
-    })
-
-    return (
-      <Code file={file()} overflow="wrap" class="select-text" onRendered={() => requestAnimationFrame(restoreScroll)} />
-    )
-  }
-
-  function RawMessage(msgProps: { message: Message }) {
-    return (
-      <Accordion.Item value={msgProps.message.id}>
-        <StickyAccordionHeader>
-          <Accordion.Trigger>
-            <div class="flex items-center justify-between gap-2 w-full">
-              <div class="min-w-0 truncate">
-                {msgProps.message.role} <span class="text-text-base">• {msgProps.message.id}</span>
-              </div>
-              <div class="flex items-center gap-3">
-                <div class="shrink-0 text-12-regular text-text-weak">{time(msgProps.message.time.created)}</div>
-                <Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
-              </div>
-            </div>
-          </Accordion.Trigger>
-        </StickyAccordionHeader>
-        <Accordion.Content class="bg-background-base">
-          <div class="p-3">
-            <RawMessageContent message={msgProps.message} />
-          </div>
-        </Accordion.Content>
-      </Accordion.Item>
-    )
-  }
-
   let scroll: HTMLDivElement | undefined
   let frame: number | undefined
   let pending: { x: number; y: number } | undefined
+  const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[]
 
   const restoreScroll = () => {
     const el = scroll
@@ -356,7 +270,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
                     class="h-full"
                     style={{
                       width: `${segment.width}%`,
-                      "background-color": segment.color,
+                      "background-color": BREAKDOWN_COLOR[segment.key],
                     }}
                   />
                 )}
@@ -366,9 +280,9 @@ export function SessionContextTab(props: SessionContextTabProps) {
               <For each={breakdown()}>
                 {(segment) => (
                   <div class="flex items-center gap-1 text-11-regular text-text-weak">
-                    <div class="size-2 rounded-sm" style={{ "background-color": segment.color }} />
-                    <div>{segment.label}</div>
-                    <div class="text-text-weaker">{segment.percent}</div>
+                    <div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
+                    <div>{breakdownLabel(segment.key)}</div>
+                    <div class="text-text-weaker">{segment.percent.toLocaleString(language.locale())}%</div>
                   </div>
                 )}
               </For>
@@ -391,7 +305,11 @@ export function SessionContextTab(props: SessionContextTabProps) {
         <div class="flex flex-col gap-2">
           <div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
           <Accordion multiple>
-            <For each={props.messages()}>{(message) => <RawMessage message={message} />}</For>
+            <For each={props.messages()}>
+              {(message) => (
+                <RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
+              )}
+            </For>
           </Accordion>
         </div>
       </div>

+ 185 - 163
packages/app/src/components/session/session-header.tsx

@@ -25,6 +25,164 @@ import { Keybind } from "@opencode-ai/ui/keybind"
 import { showToast } from "@opencode-ai/ui/toast"
 import { StatusPopover } from "../status-popover"
 
+const OPEN_APPS = [
+  "vscode",
+  "cursor",
+  "zed",
+  "textmate",
+  "antigravity",
+  "finder",
+  "terminal",
+  "iterm2",
+  "ghostty",
+  "xcode",
+  "android-studio",
+  "powershell",
+  "sublime-text",
+] as const
+
+type OpenApp = (typeof OPEN_APPS)[number]
+type OS = "macos" | "windows" | "linux" | "unknown"
+
+const MAC_APPS = [
+  { id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
+  { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
+  { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
+  { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
+  { id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
+  { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
+  { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
+  { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
+  { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
+  { id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
+  { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
+] as const
+
+const WINDOWS_APPS = [
+  { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
+  { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
+  { id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
+  { id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
+  { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
+] as const
+
+const LINUX_APPS = [
+  { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
+  { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
+  { id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
+  { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
+] as const
+
+type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number]
+type OpenIcon = OpenApp | "file-explorer"
+const OPEN_ICON_BASE = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
+
+const openIconSize = (id: OpenIcon) => (OPEN_ICON_BASE.has(id) ? "size-4" : "size-[19px]")
+
+const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
+  if (platform.platform === "desktop" && platform.os) return platform.os
+  if (typeof navigator !== "object") return "unknown"
+  const value = navigator.platform || navigator.userAgent
+  if (/Mac/i.test(value)) return "macos"
+  if (/Win/i.test(value)) return "windows"
+  if (/Linux/i.test(value)) return "linux"
+  return "unknown"
+}
+
+const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
+  showToast({
+    variant: "error",
+    title: language.t("common.requestFailed"),
+    description: err instanceof Error ? err.message : String(err),
+  })
+}
+
+function useSessionShare(args: {
+  globalSDK: ReturnType<typeof useGlobalSDK>
+  currentSession: () =>
+    | {
+        id: string
+        share?: {
+          url?: string
+        }
+      }
+    | undefined
+  projectDirectory: () => string
+  platform: ReturnType<typeof usePlatform>
+}) {
+  const [state, setState] = createStore({
+    share: false,
+    unshare: false,
+    copied: false,
+    timer: undefined as number | undefined,
+  })
+  const shareUrl = createMemo(() => args.currentSession()?.share?.url)
+
+  createEffect(() => {
+    const url = shareUrl()
+    if (url) return
+    if (state.timer) window.clearTimeout(state.timer)
+    setState({ copied: false, timer: undefined })
+  })
+
+  onCleanup(() => {
+    if (state.timer) window.clearTimeout(state.timer)
+  })
+
+  const shareSession = () => {
+    const session = args.currentSession()
+    if (!session || state.share) return
+    setState("share", true)
+    args.globalSDK.client.session
+      .share({ sessionID: session.id, directory: args.projectDirectory() })
+      .catch((error) => {
+        console.error("Failed to share session", error)
+      })
+      .finally(() => {
+        setState("share", false)
+      })
+  }
+
+  const unshareSession = () => {
+    const session = args.currentSession()
+    if (!session || state.unshare) return
+    setState("unshare", true)
+    args.globalSDK.client.session
+      .unshare({ sessionID: session.id, directory: args.projectDirectory() })
+      .catch((error) => {
+        console.error("Failed to unshare session", error)
+      })
+      .finally(() => {
+        setState("unshare", false)
+      })
+  }
+
+  const copyLink = (onError: (error: unknown) => void) => {
+    const url = shareUrl()
+    if (!url) return
+    navigator.clipboard
+      .writeText(url)
+      .then(() => {
+        if (state.timer) window.clearTimeout(state.timer)
+        setState("copied", true)
+        const timer = window.setTimeout(() => {
+          setState("copied", false)
+          setState("timer", undefined)
+        }, 3000)
+        setState("timer", timer)
+      })
+      .catch(onError)
+  }
+
+  const viewShare = () => {
+    const url = shareUrl()
+    if (!url) return
+    args.platform.openLink(url)
+  }
+
+  return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare }
+}
+
 export function SessionHeader() {
   const globalSDK = useGlobalSDK()
   const layout = useLayout()
@@ -53,62 +211,7 @@ export function SessionHeader() {
   const showShare = createMemo(() => shareEnabled() && !!currentSession())
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const view = createMemo(() => layout.view(sessionKey))
-
-  const OPEN_APPS = [
-    "vscode",
-    "cursor",
-    "zed",
-    "textmate",
-    "antigravity",
-    "finder",
-    "terminal",
-    "iterm2",
-    "ghostty",
-    "xcode",
-    "android-studio",
-    "powershell",
-    "sublime-text",
-  ] as const
-  type OpenApp = (typeof OPEN_APPS)[number]
-
-  const MAC_APPS = [
-    { id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
-    { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
-    { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
-    { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
-    { id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
-    { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
-    { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
-    { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
-    { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
-    { id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
-    { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
-  ] as const
-
-  const WINDOWS_APPS = [
-    { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
-    { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
-    { id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
-    { id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
-    { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
-  ] as const
-
-  const LINUX_APPS = [
-    { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
-    { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
-    { id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
-    { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
-  ] as const
-
-  const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => {
-    if (platform.platform === "desktop" && platform.os) return platform.os
-    if (typeof navigator !== "object") return "unknown"
-    const value = navigator.platform || navigator.userAgent
-    if (/Mac/i.test(value)) return "macos"
-    if (/Win/i.test(value)) return "windows"
-    if (/Linux/i.test(value)) return "linux"
-    return "unknown"
-  })
+  const os = createMemo(() => detectOS(platform))
 
   const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true })
 
@@ -154,10 +257,6 @@ export function SessionHeader() {
     ] as const
   })
 
-  type OpenIcon = OpenApp | "file-explorer"
-  const base = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
-  const size = (id: OpenIcon) => (base.has(id) ? "size-4" : "size-[19px]")
-
   const checksReady = createMemo(() => {
     if (platform.platform !== "desktop") return true
     if (!platform.checkAppExists) return true
@@ -186,13 +285,7 @@ export function SessionHeader() {
 
     const item = options().find((o) => o.id === app)
     const openWith = item && "openWith" in item ? item.openWith : undefined
-    Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => {
-      showToast({
-        variant: "error",
-        title: language.t("common.requestFailed"),
-        description: err instanceof Error ? err.message : String(err),
-      })
-    })
+    Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => showRequestError(language, err))
   }
 
   const copyPath = () => {
@@ -208,87 +301,16 @@ export function SessionHeader() {
           description: directory,
         })
       })
-      .catch((err: unknown) => {
-        showToast({
-          variant: "error",
-          title: language.t("common.requestFailed"),
-          description: err instanceof Error ? err.message : String(err),
-        })
-      })
+      .catch((err: unknown) => showRequestError(language, err))
   }
 
-  const [state, setState] = createStore({
-    share: false,
-    unshare: false,
-    copied: false,
-    timer: undefined as number | undefined,
-  })
-  const shareUrl = createMemo(() => currentSession()?.share?.url)
-
-  createEffect(() => {
-    const url = shareUrl()
-    if (url) return
-    if (state.timer) window.clearTimeout(state.timer)
-    setState({ copied: false, timer: undefined })
-  })
-
-  onCleanup(() => {
-    if (state.timer) window.clearTimeout(state.timer)
+  const share = useSessionShare({
+    globalSDK,
+    currentSession,
+    projectDirectory,
+    platform,
   })
 
-  function shareSession() {
-    const session = currentSession()
-    if (!session || state.share) return
-    setState("share", true)
-    globalSDK.client.session
-      .share({ sessionID: session.id, directory: projectDirectory() })
-      .catch((error) => {
-        console.error("Failed to share session", error)
-      })
-      .finally(() => {
-        setState("share", false)
-      })
-  }
-
-  function unshareSession() {
-    const session = currentSession()
-    if (!session || state.unshare) return
-    setState("unshare", true)
-    globalSDK.client.session
-      .unshare({ sessionID: session.id, directory: projectDirectory() })
-      .catch((error) => {
-        console.error("Failed to unshare session", error)
-      })
-      .finally(() => {
-        setState("unshare", false)
-      })
-  }
-
-  function copyLink() {
-    const url = shareUrl()
-    if (!url) return
-    navigator.clipboard
-      .writeText(url)
-      .then(() => {
-        if (state.timer) window.clearTimeout(state.timer)
-        setState("copied", true)
-        const timer = window.setTimeout(() => {
-          setState("copied", false)
-          setState("timer", undefined)
-        }, 3000)
-        setState("timer", timer)
-      })
-      .catch((error) => {
-        console.error("Failed to copy share link", error)
-      })
-  }
-
-  function viewShare() {
-    const url = shareUrl()
-    if (!url) return
-    platform.openLink(url)
-  }
-
   const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
   const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
 
@@ -391,7 +413,7 @@ export function SessionHeader() {
                                       }}
                                     >
                                       <div class="flex size-5 shrink-0 items-center justify-center">
-                                        <AppIcon id={o.icon} class={size(o.icon)} />
+                                        <AppIcon id={o.icon} class={openIconSize(o.icon)} />
                                       </div>
                                       <DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
                                       <DropdownMenu.ItemIndicator>
@@ -428,7 +450,7 @@ export function SessionHeader() {
                   <Popover
                     title={language.t("session.share.popover.title")}
                     description={
-                      shareUrl()
+                      share.shareUrl()
                         ? language.t("session.share.popover.description.shared")
                         : language.t("session.share.popover.description.unshared")
                     }
@@ -441,24 +463,24 @@ export function SessionHeader() {
                       variant: "ghost",
                       class:
                         "rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
-                      classList: { "rounded-r-none": shareUrl() !== undefined },
+                      classList: { "rounded-r-none": share.shareUrl() !== undefined },
                       style: { scale: 1 },
                     }}
                     trigger={language.t("session.share.action.share")}
                   >
                     <div class="flex flex-col gap-2">
                       <Show
-                        when={shareUrl()}
+                        when={share.shareUrl()}
                         fallback={
                           <div class="flex">
                             <Button
                               size="large"
                               variant="primary"
                               class="w-1/2"
-                              onClick={shareSession}
-                              disabled={state.share}
+                              onClick={share.shareSession}
+                              disabled={share.state.share}
                             >
-                              {state.share
+                              {share.state.share
                                 ? language.t("session.share.action.publishing")
                                 : language.t("session.share.action.publish")}
                             </Button>
@@ -467,7 +489,7 @@ export function SessionHeader() {
                       >
                         <div class="flex flex-col gap-2">
                           <TextField
-                            value={shareUrl() ?? ""}
+                            value={share.shareUrl() ?? ""}
                             readOnly
                             copyable
                             copyKind="link"
@@ -479,10 +501,10 @@ export function SessionHeader() {
                               size="large"
                               variant="secondary"
                               class="w-full shadow-none border border-border-weak-base"
-                              onClick={unshareSession}
-                              disabled={state.unshare}
+                              onClick={share.unshareSession}
+                              disabled={share.state.unshare}
                             >
-                              {state.unshare
+                              {share.state.unshare
                                 ? language.t("session.share.action.unpublishing")
                                 : language.t("session.share.action.unpublish")}
                             </Button>
@@ -490,8 +512,8 @@ export function SessionHeader() {
                               size="large"
                               variant="primary"
                               class="w-full"
-                              onClick={viewShare}
-                              disabled={state.unshare}
+                              onClick={share.viewShare}
+                              disabled={share.state.unshare}
                             >
                               {language.t("session.share.action.view")}
                             </Button>
@@ -500,10 +522,10 @@ export function SessionHeader() {
                       </Show>
                     </div>
                   </Popover>
-                  <Show when={shareUrl()} fallback={<div aria-hidden="true" />}>
+                  <Show when={share.shareUrl()} fallback={<div aria-hidden="true" />}>
                     <Tooltip
                       value={
-                        state.copied
+                        share.state.copied
                           ? language.t("session.share.copy.copied")
                           : language.t("session.share.copy.copyLink")
                       }
@@ -511,13 +533,13 @@ export function SessionHeader() {
                       gutter={8}
                     >
                       <IconButton
-                        icon={state.copied ? "check" : "link"}
+                        icon={share.state.copied ? "check" : "link"}
                         variant="ghost"
                         class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none"
-                        onClick={copyLink}
-                        disabled={state.unshare}
+                        onClick={() => share.copyLink((error) => showRequestError(language, error))}
+                        disabled={share.state.unshare}
                         aria-label={
-                          state.copied
+                          share.state.copied
                             ? language.t("session.share.copy.copied")
                             : language.t("session.share.copy.copyLink")
                         }

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

@@ -8,6 +8,8 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
 
 const MAIN_WORKTREE = "main"
 const CREATE_WORKTREE = "create"
+const ROOT_CLASS =
+  "size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]"
 
 interface NewSessionViewProps {
   worktree: string
@@ -47,7 +49,7 @@ export function NewSessionView(props: NewSessionViewProps) {
   }
 
   return (
-    <div class="size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]">
+    <div class={ROOT_CLASS}>
       <div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
       <div class="flex justify-center items-center gap-3">
         <Icon name="folder" size="small" />

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

@@ -31,8 +31,12 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
   const command = useCommand()
   const sortable = createSortable(props.tab)
   const path = createMemo(() => file.pathFromTab(props.tab))
+  const content = createMemo(() => {
+    const value = path()
+    if (!value) return
+    return <FileVisual path={value} />
+  })
   return (
-    // @ts-ignore
     <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
       <div class="relative h-full">
         <Tabs.Trigger
@@ -55,7 +59,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
           hideCloseButton
           onMiddleClick={() => props.onTabClose(props.tab)}
         >
-          <Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
+          <Show when={content()}>{(value) => value()}</Show>
         </Tabs.Trigger>
       </div>
     </div>

+ 21 - 10
packages/app/src/components/session/session-sortable-terminal-tab.tsx

@@ -1,5 +1,5 @@
 import type { JSX } from "solid-js"
-import { Show } from "solid-js"
+import { Show, createEffect, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createSortable } from "@thisbeyond/solid-dnd"
 import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -20,6 +20,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
     menuPosition: { x: 0, y: 0 },
     blurEnabled: false,
   })
+  let input: HTMLInputElement | undefined
+  let blurFrame: number | undefined
 
   const isDefaultTitle = () => {
     const number = props.terminal.titleNumber
@@ -77,13 +79,6 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
     setStore("blurEnabled", false)
     setStore("title", props.terminal.title)
     setStore("editing", true)
-    setTimeout(() => {
-      const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement
-      if (!input) return
-      input.focus()
-      input.select()
-      setTimeout(() => setStore("blurEnabled", true), 100)
-    }, 10)
   }
 
   const save = () => {
@@ -114,9 +109,25 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
     setStore("menuOpen", true)
   }
 
+  createEffect(() => {
+    if (!store.editing) return
+    if (!input) return
+    input.focus()
+    input.select()
+    if (blurFrame !== undefined) cancelAnimationFrame(blurFrame)
+    blurFrame = requestAnimationFrame(() => {
+      blurFrame = undefined
+      setStore("blurEnabled", true)
+    })
+  })
+
+  onCleanup(() => {
+    if (blurFrame === undefined) return
+    cancelAnimationFrame(blurFrame)
+  })
+
   return (
     <div
-      // @ts-ignore
       use:sortable
       class="outline-none focus:outline-none focus-visible:outline-none"
       classList={{
@@ -153,7 +164,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
         <Show when={store.editing}>
           <div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
             <input
-              id={`terminal-title-input-${props.terminal.id}`}
+              ref={input}
               type="text"
               value={store.title}
               onInput={(e) => setStore("title", e.currentTarget.value)}

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

@@ -2,6 +2,7 @@ import { Component } from "solid-js"
 import { useLanguage } from "@/context/language"
 
 export const SettingsAgents: Component = () => {
+  // TODO: Replace this placeholder with full agents settings controls.
   const language = useLanguage()
 
   return (

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

@@ -2,6 +2,7 @@ import { Component } from "solid-js"
 import { useLanguage } from "@/context/language"
 
 export const SettingsCommands: Component = () => {
+  // TODO: Replace this placeholder with full commands settings controls.
   const language = useLanguage()
 
   return (

+ 260 - 270
packages/app/src/components/settings-general.tsx

@@ -1,4 +1,4 @@
-import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js"
+import { Component, Show, createMemo, createResource, 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"
@@ -133,6 +133,261 @@ export const SettingsGeneral: Component = () => {
 
   const soundOptions = [...SOUND_OPTIONS]
 
+  const soundSelectProps = (current: () => string, set: (id: string) => void) => ({
+    options: soundOptions,
+    current: soundOptions.find((o) => o.id === current()),
+    value: (o: (typeof soundOptions)[number]) => o.id,
+    label: (o: (typeof soundOptions)[number]) => language.t(o.label),
+    onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
+      if (!option) return
+      playDemoSound(option.src)
+    },
+    onSelect: (option: (typeof soundOptions)[number] | undefined) => {
+      if (!option) return
+      set(option.id)
+      playDemoSound(option.src)
+    },
+    variant: "secondary" as const,
+    size: "small" as const,
+    triggerVariant: "settings" as const,
+  })
+
+  const AppearanceSection = () => (
+    <div class="flex flex-col gap-1">
+      <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
+
+      <div class="bg-surface-raised-base px-4 rounded-lg">
+        <SettingsRow
+          title={language.t("settings.general.row.language.title")}
+          description={language.t("settings.general.row.language.description")}
+        >
+          <Select
+            data-action="settings-language"
+            options={languageOptions()}
+            current={languageOptions().find((o) => o.value === language.locale())}
+            value={(o) => o.value}
+            label={(o) => o.label}
+            onSelect={(option) => option && language.setLocale(option.value)}
+            variant="secondary"
+            size="small"
+            triggerVariant="settings"
+          />
+        </SettingsRow>
+
+        <SettingsRow
+          title={language.t("settings.general.row.appearance.title")}
+          description={language.t("settings.general.row.appearance.description")}
+        >
+          <Select
+            data-action="settings-color-scheme"
+            options={colorSchemeOptions()}
+            current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
+            value={(o) => o.value}
+            label={(o) => o.label}
+            onSelect={(option) => option && theme.setColorScheme(option.value)}
+            onHighlight={(option) => {
+              if (!option) return
+              theme.previewColorScheme(option.value)
+              return () => theme.cancelPreview()
+            }}
+            variant="secondary"
+            size="small"
+            triggerVariant="settings"
+          />
+        </SettingsRow>
+
+        <SettingsRow
+          title={language.t("settings.general.row.theme.title")}
+          description={
+            <>
+              {language.t("settings.general.row.theme.description")}{" "}
+              <Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
+            </>
+          }
+        >
+          <Select
+            data-action="settings-theme"
+            options={themeOptions()}
+            current={themeOptions().find((o) => o.id === theme.themeId())}
+            value={(o) => o.id}
+            label={(o) => o.name}
+            onSelect={(option) => {
+              if (!option) return
+              theme.setTheme(option.id)
+            }}
+            onHighlight={(option) => {
+              if (!option) return
+              theme.previewTheme(option.id)
+              return () => theme.cancelPreview()
+            }}
+            variant="secondary"
+            size="small"
+            triggerVariant="settings"
+          />
+        </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>
+        </SettingsRow>
+      </div>
+    </div>
+  )
+
+  const NotificationsSection = () => (
+    <div class="flex flex-col gap-1">
+      <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
+
+      <div class="bg-surface-raised-base px-4 rounded-lg">
+        <SettingsRow
+          title={language.t("settings.general.notifications.agent.title")}
+          description={language.t("settings.general.notifications.agent.description")}
+        >
+          <div data-action="settings-notifications-agent">
+            <Switch
+              checked={settings.notifications.agent()}
+              onChange={(checked) => settings.notifications.setAgent(checked)}
+            />
+          </div>
+        </SettingsRow>
+
+        <SettingsRow
+          title={language.t("settings.general.notifications.permissions.title")}
+          description={language.t("settings.general.notifications.permissions.description")}
+        >
+          <div data-action="settings-notifications-permissions">
+            <Switch
+              checked={settings.notifications.permissions()}
+              onChange={(checked) => settings.notifications.setPermissions(checked)}
+            />
+          </div>
+        </SettingsRow>
+
+        <SettingsRow
+          title={language.t("settings.general.notifications.errors.title")}
+          description={language.t("settings.general.notifications.errors.description")}
+        >
+          <div data-action="settings-notifications-errors">
+            <Switch
+              checked={settings.notifications.errors()}
+              onChange={(checked) => settings.notifications.setErrors(checked)}
+            />
+          </div>
+        </SettingsRow>
+      </div>
+    </div>
+  )
+
+  const SoundsSection = () => (
+    <div class="flex flex-col gap-1">
+      <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
+
+      <div class="bg-surface-raised-base px-4 rounded-lg">
+        <SettingsRow
+          title={language.t("settings.general.sounds.agent.title")}
+          description={language.t("settings.general.sounds.agent.description")}
+        >
+          <Select
+            data-action="settings-sounds-agent"
+            {...soundSelectProps(
+              () => settings.sounds.agent(),
+              (id) => settings.sounds.setAgent(id),
+            )}
+          />
+        </SettingsRow>
+
+        <SettingsRow
+          title={language.t("settings.general.sounds.permissions.title")}
+          description={language.t("settings.general.sounds.permissions.description")}
+        >
+          <Select
+            data-action="settings-sounds-permissions"
+            {...soundSelectProps(
+              () => settings.sounds.permissions(),
+              (id) => settings.sounds.setPermissions(id),
+            )}
+          />
+        </SettingsRow>
+
+        <SettingsRow
+          title={language.t("settings.general.sounds.errors.title")}
+          description={language.t("settings.general.sounds.errors.description")}
+        >
+          <Select
+            data-action="settings-sounds-errors"
+            {...soundSelectProps(
+              () => settings.sounds.errors(),
+              (id) => settings.sounds.setErrors(id),
+            )}
+          />
+        </SettingsRow>
+      </div>
+    </div>
+  )
+
+  const UpdatesSection = () => (
+    <div class="flex flex-col gap-1">
+      <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
+
+      <div class="bg-surface-raised-base px-4 rounded-lg">
+        <SettingsRow
+          title={language.t("settings.updates.row.startup.title")}
+          description={language.t("settings.updates.row.startup.description")}
+        >
+          <div data-action="settings-updates-startup">
+            <Switch
+              checked={settings.updates.startup()}
+              disabled={!platform.checkUpdate}
+              onChange={(checked) => settings.updates.setStartup(checked)}
+            />
+          </div>
+        </SettingsRow>
+
+        <SettingsRow
+          title={language.t("settings.general.row.releaseNotes.title")}
+          description={language.t("settings.general.row.releaseNotes.description")}
+        >
+          <div data-action="settings-release-notes">
+            <Switch
+              checked={settings.general.releaseNotes()}
+              onChange={(checked) => settings.general.setReleaseNotes(checked)}
+            />
+          </div>
+        </SettingsRow>
+
+        <SettingsRow
+          title={language.t("settings.updates.row.check.title")}
+          description={language.t("settings.updates.row.check.description")}
+        >
+          <Button size="small" variant="secondary" disabled={store.checking || !platform.checkUpdate} onClick={check}>
+            {store.checking
+              ? language.t("settings.updates.action.checking")
+              : language.t("settings.updates.action.checkNow")}
+          </Button>
+        </SettingsRow>
+      </div>
+    </div>
+  )
+
   return (
     <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
       <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
@@ -142,230 +397,11 @@ export const SettingsGeneral: Component = () => {
       </div>
 
       <div class="flex flex-col gap-8 w-full">
-        {/* Appearance Section */}
-        <div class="flex flex-col gap-1">
-          <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
-
-          <div class="bg-surface-raised-base px-4 rounded-lg">
-            <SettingsRow
-              title={language.t("settings.general.row.language.title")}
-              description={language.t("settings.general.row.language.description")}
-            >
-              <Select
-                data-action="settings-language"
-                options={languageOptions()}
-                current={languageOptions().find((o) => o.value === language.locale())}
-                value={(o) => o.value}
-                label={(o) => o.label}
-                onSelect={(option) => option && language.setLocale(option.value)}
-                variant="secondary"
-                size="small"
-                triggerVariant="settings"
-              />
-            </SettingsRow>
-
-            <SettingsRow
-              title={language.t("settings.general.row.appearance.title")}
-              description={language.t("settings.general.row.appearance.description")}
-            >
-              <Select
-                data-action="settings-color-scheme"
-                options={colorSchemeOptions()}
-                current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
-                value={(o) => o.value}
-                label={(o) => o.label}
-                onSelect={(option) => option && theme.setColorScheme(option.value)}
-                onHighlight={(option) => {
-                  if (!option) return
-                  theme.previewColorScheme(option.value)
-                  return () => theme.cancelPreview()
-                }}
-                variant="secondary"
-                size="small"
-                triggerVariant="settings"
-              />
-            </SettingsRow>
-
-            <SettingsRow
-              title={language.t("settings.general.row.theme.title")}
-              description={
-                <>
-                  {language.t("settings.general.row.theme.description")}{" "}
-                  <Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
-                </>
-              }
-            >
-              <Select
-                data-action="settings-theme"
-                options={themeOptions()}
-                current={themeOptions().find((o) => o.id === theme.themeId())}
-                value={(o) => o.id}
-                label={(o) => o.name}
-                onSelect={(option) => {
-                  if (!option) return
-                  theme.setTheme(option.id)
-                }}
-                onHighlight={(option) => {
-                  if (!option) return
-                  theme.previewTheme(option.id)
-                  return () => theme.cancelPreview()
-                }}
-                variant="secondary"
-                size="small"
-                triggerVariant="settings"
-              />
-            </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>
-            </SettingsRow>
-          </div>
-        </div>
+        <AppearanceSection />
 
-        {/* System notifications Section */}
-        <div class="flex flex-col gap-1">
-          <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
-
-          <div class="bg-surface-raised-base px-4 rounded-lg">
-            <SettingsRow
-              title={language.t("settings.general.notifications.agent.title")}
-              description={language.t("settings.general.notifications.agent.description")}
-            >
-              <div data-action="settings-notifications-agent">
-                <Switch
-                  checked={settings.notifications.agent()}
-                  onChange={(checked) => settings.notifications.setAgent(checked)}
-                />
-              </div>
-            </SettingsRow>
-
-            <SettingsRow
-              title={language.t("settings.general.notifications.permissions.title")}
-              description={language.t("settings.general.notifications.permissions.description")}
-            >
-              <div data-action="settings-notifications-permissions">
-                <Switch
-                  checked={settings.notifications.permissions()}
-                  onChange={(checked) => settings.notifications.setPermissions(checked)}
-                />
-              </div>
-            </SettingsRow>
-
-            <SettingsRow
-              title={language.t("settings.general.notifications.errors.title")}
-              description={language.t("settings.general.notifications.errors.description")}
-            >
-              <div data-action="settings-notifications-errors">
-                <Switch
-                  checked={settings.notifications.errors()}
-                  onChange={(checked) => settings.notifications.setErrors(checked)}
-                />
-              </div>
-            </SettingsRow>
-          </div>
-        </div>
+        <NotificationsSection />
 
-        {/* Sound effects Section */}
-        <div class="flex flex-col gap-1">
-          <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
-
-          <div class="bg-surface-raised-base px-4 rounded-lg">
-            <SettingsRow
-              title={language.t("settings.general.sounds.agent.title")}
-              description={language.t("settings.general.sounds.agent.description")}
-            >
-              <Select
-                data-action="settings-sounds-agent"
-                options={soundOptions}
-                current={soundOptions.find((o) => o.id === settings.sounds.agent())}
-                value={(o) => o.id}
-                label={(o) => language.t(o.label)}
-                onHighlight={(option) => {
-                  if (!option) return
-                  playDemoSound(option.src)
-                }}
-                onSelect={(option) => {
-                  if (!option) return
-                  settings.sounds.setAgent(option.id)
-                  playDemoSound(option.src)
-                }}
-                variant="secondary"
-                size="small"
-                triggerVariant="settings"
-              />
-            </SettingsRow>
-
-            <SettingsRow
-              title={language.t("settings.general.sounds.permissions.title")}
-              description={language.t("settings.general.sounds.permissions.description")}
-            >
-              <Select
-                data-action="settings-sounds-permissions"
-                options={soundOptions}
-                current={soundOptions.find((o) => o.id === settings.sounds.permissions())}
-                value={(o) => o.id}
-                label={(o) => language.t(o.label)}
-                onHighlight={(option) => {
-                  if (!option) return
-                  playDemoSound(option.src)
-                }}
-                onSelect={(option) => {
-                  if (!option) return
-                  settings.sounds.setPermissions(option.id)
-                  playDemoSound(option.src)
-                }}
-                variant="secondary"
-                size="small"
-                triggerVariant="settings"
-              />
-            </SettingsRow>
-
-            <SettingsRow
-              title={language.t("settings.general.sounds.errors.title")}
-              description={language.t("settings.general.sounds.errors.description")}
-            >
-              <Select
-                data-action="settings-sounds-errors"
-                options={soundOptions}
-                current={soundOptions.find((o) => o.id === settings.sounds.errors())}
-                value={(o) => o.id}
-                label={(o) => language.t(o.label)}
-                onHighlight={(option) => {
-                  if (!option) return
-                  playDemoSound(option.src)
-                }}
-                onSelect={(option) => {
-                  if (!option) return
-                  settings.sounds.setErrors(option.id)
-                  playDemoSound(option.src)
-                }}
-                variant="secondary"
-                size="small"
-                triggerVariant="settings"
-              />
-            </SettingsRow>
-          </div>
-        </div>
+        <SoundsSection />
 
         <Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
           {(_) => {
@@ -395,53 +431,7 @@ export const SettingsGeneral: Component = () => {
           }}
         </Show>
 
-        {/* Updates Section */}
-        <div class="flex flex-col gap-1">
-          <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
-
-          <div class="bg-surface-raised-base px-4 rounded-lg">
-            <SettingsRow
-              title={language.t("settings.updates.row.startup.title")}
-              description={language.t("settings.updates.row.startup.description")}
-            >
-              <div data-action="settings-updates-startup">
-                <Switch
-                  checked={settings.updates.startup()}
-                  disabled={!platform.checkUpdate}
-                  onChange={(checked) => settings.updates.setStartup(checked)}
-                />
-              </div>
-            </SettingsRow>
-
-            <SettingsRow
-              title={language.t("settings.general.row.releaseNotes.title")}
-              description={language.t("settings.general.row.releaseNotes.description")}
-            >
-              <div data-action="settings-release-notes">
-                <Switch
-                  checked={settings.general.releaseNotes()}
-                  onChange={(checked) => settings.general.setReleaseNotes(checked)}
-                />
-              </div>
-            </SettingsRow>
-
-            <SettingsRow
-              title={language.t("settings.updates.row.check.title")}
-              description={language.t("settings.updates.row.check.description")}
-            >
-              <Button
-                size="small"
-                variant="secondary"
-                disabled={store.checking || !platform.checkUpdate}
-                onClick={check}
-              >
-                {store.checking
-                  ? language.t("settings.updates.action.checking")
-                  : language.t("settings.updates.action.checkNow")}
-              </Button>
-            </SettingsRow>
-          </div>
-        </div>
+        <UpdatesSection />
 
         <Show when={linux()}>
           {(_) => {

+ 160 - 143
packages/app/src/components/settings-keybinds.tsx

@@ -21,6 +21,9 @@ type KeybindMeta = {
   group: KeybindGroup
 }
 
+type KeybindMap = Record<string, string | undefined>
+type CommandContext = ReturnType<typeof useCommand>
+
 const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
 
 type GroupKey =
@@ -107,6 +110,150 @@ function signatures(config: string | undefined) {
   return sigs
 }
 
+function keybinds(value: unknown): KeybindMap {
+  if (!value || typeof value !== "object" || Array.isArray(value)) return {}
+  return value as KeybindMap
+}
+
+function listFor(command: CommandContext, map: KeybindMap, palette: string) {
+  const out = new Map<string, KeybindMeta>()
+  out.set(PALETTE_ID, { title: palette, group: "General" })
+
+  for (const opt of command.catalog) {
+    if (opt.id.startsWith("suggested.")) continue
+    out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
+  }
+
+  for (const opt of command.options) {
+    if (opt.id.startsWith("suggested.")) continue
+    out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
+  }
+
+  for (const [id, value] of Object.entries(map)) {
+    if (typeof value !== "string") continue
+    if (out.has(id)) continue
+    out.set(id, { title: id, group: groupFor(id) })
+  }
+
+  return out
+}
+
+function groupedFor(list: Map<string, KeybindMeta>) {
+  const out = new Map<KeybindGroup, string[]>()
+  for (const group of GROUPS) out.set(group, [])
+
+  for (const [id, item] of list) {
+    const ids = out.get(item.group)
+    if (!ids) continue
+    ids.push(id)
+  }
+
+  for (const group of GROUPS) {
+    const ids = out.get(group)
+    if (!ids) continue
+    ids.sort((a, b) => (list.get(a)?.title ?? "").localeCompare(list.get(b)?.title ?? ""))
+  }
+
+  return out
+}
+
+function filteredFor(
+  query: string,
+  list: Map<string, KeybindMeta>,
+  grouped: Map<KeybindGroup, string[]>,
+  keybind: (id: string) => string,
+) {
+  const value = query.toLowerCase().trim()
+  if (!value) return grouped
+
+  const out = new Map<KeybindGroup, string[]>()
+  for (const group of GROUPS) out.set(group, [])
+
+  const items = Array.from(list.entries()).map(([id, meta]) => ({
+    id,
+    title: meta.title,
+    group: meta.group,
+    keybind: keybind(id),
+  }))
+
+  const results = fuzzysort.go(value, items, {
+    keys: ["title", "keybind"],
+    threshold: -10000,
+  })
+
+  for (const result of results) {
+    const ids = out.get(result.obj.group)
+    if (!ids) continue
+    ids.push(result.obj.id)
+  }
+
+  return out
+}
+
+function useKeyCapture(input: {
+  active: () => string | null
+  stop: () => void
+  set: (id: string, keybind: string) => void
+  used: () => Map<string, { id: string; title: string }[]>
+  language: ReturnType<typeof useLanguage>
+}) {
+  onMount(() => {
+    const handle = (event: KeyboardEvent) => {
+      const id = input.active()
+      if (!id) return
+
+      event.preventDefault()
+      event.stopPropagation()
+      event.stopImmediatePropagation()
+
+      if (event.key === "Escape") {
+        input.stop()
+        return
+      }
+
+      const clear =
+        (event.key === "Backspace" || event.key === "Delete") &&
+        !event.ctrlKey &&
+        !event.metaKey &&
+        !event.altKey &&
+        !event.shiftKey
+      if (clear) {
+        input.set(id, "none")
+        input.stop()
+        return
+      }
+
+      const next = recordKeybind(event)
+      if (!next) return
+
+      const conflicts = new Map<string, string>()
+      for (const sig of signatures(next)) {
+        for (const item of input.used().get(sig) ?? []) {
+          if (item.id === id) continue
+          conflicts.set(item.id, item.title)
+        }
+      }
+
+      if (conflicts.size > 0) {
+        showToast({
+          title: input.language.t("settings.shortcuts.conflict.title"),
+          description: input.language.t("settings.shortcuts.conflict.description", {
+            keybind: formatKeybind(next),
+            titles: [...conflicts.values()].join(", "),
+          }),
+        })
+        return
+      }
+
+      input.set(id, next)
+      input.stop()
+    }
+
+    document.addEventListener("keydown", handle, true)
+    onCleanup(() => document.removeEventListener("keydown", handle, true))
+  })
+}
+
 export const SettingsKeybinds: Component = () => {
   const command = useCommand()
   const language = useLanguage()
@@ -135,11 +282,9 @@ export const SettingsKeybinds: Component = () => {
     command.keybinds(false)
   }
 
-  const hasOverrides = createMemo(() => {
-    const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined
-    if (!keybinds) return false
-    return Object.values(keybinds).some((x) => typeof x === "string")
-  })
+  const map = createMemo(() => keybinds(settings.current.keybinds))
+
+  const hasOverrides = createMemo(() => Object.values(map()).some((x) => typeof x === "string"))
 
   const resetAll = () => {
     stop()
@@ -152,88 +297,15 @@ export const SettingsKeybinds: Component = () => {
 
   const list = createMemo(() => {
     language.locale()
-    const out = new Map<string, KeybindMeta>()
-    out.set(PALETTE_ID, { title: language.t("command.palette"), group: "General" })
-
-    for (const opt of command.catalog) {
-      if (opt.id.startsWith("suggested.")) continue
-      out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
-    }
-
-    for (const opt of command.options) {
-      if (opt.id.startsWith("suggested.")) continue
-      out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
-    }
-
-    const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined
-    if (keybinds) {
-      for (const [id, value] of Object.entries(keybinds)) {
-        if (typeof value !== "string") continue
-        if (out.has(id)) continue
-        out.set(id, { title: id, group: groupFor(id) })
-      }
-    }
-
-    return out
+    return listFor(command, map(), language.t("command.palette"))
   })
 
   const title = (id: string) => list().get(id)?.title ?? ""
 
-  const grouped = createMemo(() => {
-    const map = list()
-    const out = new Map<KeybindGroup, string[]>()
-
-    for (const group of GROUPS) out.set(group, [])
-
-    for (const [id, item] of map) {
-      const ids = out.get(item.group)
-      if (!ids) continue
-      ids.push(id)
-    }
-
-    for (const group of GROUPS) {
-      const ids = out.get(group)
-      if (!ids) continue
-
-      ids.sort((a, b) => {
-        const at = map.get(a)?.title ?? ""
-        const bt = map.get(b)?.title ?? ""
-        return at.localeCompare(bt)
-      })
-    }
-
-    return out
-  })
+  const grouped = createMemo(() => groupedFor(list()))
 
   const filtered = createMemo(() => {
-    const query = store.filter.toLowerCase().trim()
-    if (!query) return grouped()
-
-    const map = list()
-    const out = new Map<KeybindGroup, string[]>()
-
-    for (const group of GROUPS) out.set(group, [])
-
-    const items = Array.from(map.entries()).map(([id, meta]) => ({
-      id,
-      title: meta.title,
-      group: meta.group,
-      keybind: command.keybind(id) || "",
-    }))
-
-    const results = fuzzysort.go(query, items, {
-      keys: ["title", "keybind"],
-      threshold: -10000,
-    })
-
-    for (const result of results) {
-      const item = result.obj
-      const ids = out.get(item.group)
-      if (!ids) continue
-      ids.push(item.id)
-    }
-
-    return out
+    return filteredFor(store.filter, list(), grouped(), (id) => command.keybind(id) || "")
   })
 
   const hasResults = createMemo(() => {
@@ -282,69 +354,14 @@ export const SettingsKeybinds: Component = () => {
     return map
   })
 
-  const setKeybind = (id: string, keybind: string) => {
-    settings.keybinds.set(id, keybind)
-  }
-
-  onMount(() => {
-    const handle = (event: KeyboardEvent) => {
-      const id = store.active
-      if (!id) return
-
-      event.preventDefault()
-      event.stopPropagation()
-      event.stopImmediatePropagation()
+  const setKeybind = (id: string, keybind: string) => settings.keybinds.set(id, keybind)
 
-      if (event.key === "Escape") {
-        stop()
-        return
-      }
-
-      const clear =
-        (event.key === "Backspace" || event.key === "Delete") &&
-        !event.ctrlKey &&
-        !event.metaKey &&
-        !event.altKey &&
-        !event.shiftKey
-      if (clear) {
-        setKeybind(id, "none")
-        stop()
-        return
-      }
-
-      const next = recordKeybind(event)
-      if (!next) return
-
-      const map = used()
-      const conflicts = new Map<string, string>()
-
-      for (const sig of signatures(next)) {
-        const list = map.get(sig) ?? []
-        for (const item of list) {
-          if (item.id === id) continue
-          conflicts.set(item.id, item.title)
-        }
-      }
-
-      if (conflicts.size > 0) {
-        showToast({
-          title: language.t("settings.shortcuts.conflict.title"),
-          description: language.t("settings.shortcuts.conflict.description", {
-            keybind: formatKeybind(next),
-            titles: [...conflicts.values()].join(", "),
-          }),
-        })
-        return
-      }
-
-      setKeybind(id, next)
-      stop()
-    }
-
-    document.addEventListener("keydown", handle, true)
-    onCleanup(() => {
-      document.removeEventListener("keydown", handle, true)
-    })
+  useKeyCapture({
+    active: () => store.active,
+    stop,
+    set: setKeybind,
+    used,
+    language,
   })
 
   onCleanup(() => {

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

@@ -2,6 +2,7 @@ import { Component } from "solid-js"
 import { useLanguage } from "@/context/language"
 
 export const SettingsMcp: Component = () => {
+  // TODO: Replace this placeholder with full MCP settings controls.
   const language = useLanguage()
 
   return (

+ 21 - 14
packages/app/src/components/settings-models.tsx

@@ -12,6 +12,25 @@ import { popularProviders } from "@/hooks/use-providers"
 
 type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
 
+const ListLoadingState: Component<{ label: string }> = (props) => {
+  return (
+    <div class="flex flex-col items-center justify-center py-12 text-center">
+      <span class="text-14-regular text-text-weak">{props.label}</span>
+    </div>
+  )
+}
+
+const ListEmptyState: Component<{ message: string; filter: string }> = (props) => {
+  return (
+    <div class="flex flex-col items-center justify-center py-12 text-center">
+      <span class="text-14-regular text-text-weak">{props.message}</span>
+      <Show when={props.filter}>
+        <span class="text-14-regular text-text-strong mt-1">&quot;{props.filter}&quot;</span>
+      </Show>
+    </div>
+  )
+}
+
 export const SettingsModels: Component = () => {
   const language = useLanguage()
   const models = useModels()
@@ -68,24 +87,12 @@ export const SettingsModels: Component = () => {
         <Show
           when={!list.grouped.loading}
           fallback={
-            <div class="flex flex-col items-center justify-center py-12 text-center">
-              <span class="text-14-regular text-text-weak">
-                {language.t("common.loading")}
-                {language.t("common.loading.ellipsis")}
-              </span>
-            </div>
+            <ListLoadingState label={`${language.t("common.loading")}${language.t("common.loading.ellipsis")}`} />
           }
         >
           <Show
             when={list.flat().length > 0}
-            fallback={
-              <div class="flex flex-col items-center justify-center py-12 text-center">
-                <span class="text-14-regular text-text-weak">{language.t("dialog.model.empty")}</span>
-                <Show when={list.filter()}>
-                  <span class="text-14-regular text-text-strong mt-1">&quot;{list.filter()}&quot;</span>
-                </Show>
-              </div>
-            }
+            fallback={<ListEmptyState message={language.t("dialog.model.empty")} filter={list.filter()} />}
           >
             <For each={list.grouped.latest}>
               {(group) => (

+ 5 - 3
packages/app/src/components/settings-permissions.tsx

@@ -165,12 +165,14 @@ export const SettingsPermissions: Component = () => {
     const nextValue =
       existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action
 
-    globalSync.set("config", "permission", { ...map, [id]: nextValue })
-    globalSync.updateConfig({ permission: { [id]: nextValue } }).catch((err: unknown) => {
+    const rollback = (err: unknown) => {
       globalSync.set("config", "permission", before)
       const message = err instanceof Error ? err.message : String(err)
       showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message })
-    })
+    }
+
+    globalSync.set("config", "permission", { ...map, [id]: nextValue })
+    globalSync.updateConfig({ permission: { [id]: nextValue } }).catch(rollback)
   }
 
   return (

+ 24 - 40
packages/app/src/components/settings-providers.tsx

@@ -14,7 +14,17 @@ import { DialogSelectProvider } from "./dialog-select-provider"
 import { DialogCustomProvider } from "./dialog-custom-provider"
 
 type ProviderSource = "env" | "api" | "config" | "custom"
-type ProviderMeta = { source?: ProviderSource }
+type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
+
+const PROVIDER_NOTES = [
+  { match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
+  { match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
+  { match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
+  { match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
+  { match: (id: string) => id === "google", key: "dialog.provider.google.note" },
+  { match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" },
+  { match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
+] as const
 
 export const SettingsProviders: Component = () => {
   const dialog = useDialog()
@@ -44,22 +54,28 @@ export const SettingsProviders: Component = () => {
     return items
   })
 
-  const source = (item: unknown) => (item as ProviderMeta).source
+  const source = (item: ProviderItem): ProviderSource | undefined => {
+    if (!("source" in item)) return
+    const value = item.source
+    if (value === "env" || value === "api" || value === "config" || value === "custom") return value
+    return
+  }
 
-  const type = (item: unknown) => {
+  const type = (item: ProviderItem) => {
     const current = source(item)
     if (current === "env") return language.t("settings.providers.tag.environment")
     if (current === "api") return language.t("provider.connect.method.apiKey")
     if (current === "config") {
-      const id = (item as { id?: string }).id
-      if (id && isConfigCustom(id)) return language.t("settings.providers.tag.custom")
+      if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
       return language.t("settings.providers.tag.config")
     }
     if (current === "custom") return language.t("settings.providers.tag.custom")
     return language.t("settings.providers.tag.other")
   }
 
-  const canDisconnect = (item: unknown) => source(item) !== "env"
+  const canDisconnect = (item: ProviderItem) => source(item) !== "env"
+
+  const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
 
   const isConfigCustom = (providerID: string) => {
     const provider = globalSync.data.config.provider?.[providerID]
@@ -175,40 +191,8 @@ export const SettingsProviders: Component = () => {
                         <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
                       </Show>
                     </div>
-                    <Show when={item.id === "opencode"}>
-                      <span class="text-12-regular text-text-weak pl-8">
-                        {language.t("dialog.provider.opencode.note")}
-                      </span>
-                    </Show>
-                    <Show when={item.id === "anthropic"}>
-                      <span class="text-12-regular text-text-weak pl-8">
-                        {language.t("dialog.provider.anthropic.note")}
-                      </span>
-                    </Show>
-                    <Show when={item.id.startsWith("github-copilot")}>
-                      <span class="text-12-regular text-text-weak pl-8">
-                        {language.t("dialog.provider.copilot.note")}
-                      </span>
-                    </Show>
-                    <Show when={item.id === "openai"}>
-                      <span class="text-12-regular text-text-weak pl-8">
-                        {language.t("dialog.provider.openai.note")}
-                      </span>
-                    </Show>
-                    <Show when={item.id === "google"}>
-                      <span class="text-12-regular text-text-weak pl-8">
-                        {language.t("dialog.provider.google.note")}
-                      </span>
-                    </Show>
-                    <Show when={item.id === "openrouter"}>
-                      <span class="text-12-regular text-text-weak pl-8">
-                        {language.t("dialog.provider.openrouter.note")}
-                      </span>
-                    </Show>
-                    <Show when={item.id === "vercel"}>
-                      <span class="text-12-regular text-text-weak pl-8">
-                        {language.t("dialog.provider.vercel.note")}
-                      </span>
+                    <Show when={note(item.id)}>
+                      {(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}
                     </Show>
                   </div>
                   <Button

+ 151 - 116
packages/app/src/components/status-popover.tsx

@@ -1,4 +1,4 @@
-import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
+import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSXElement } from "solid-js"
 import { createStore, reconcile } from "solid-js/store"
 import { useNavigate } from "@solidjs/router"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -7,134 +7,189 @@ import { Tabs } from "@opencode-ai/ui/tabs"
 import { Button } from "@opencode-ai/ui/button"
 import { Switch } from "@opencode-ai/ui/switch"
 import { Icon } from "@opencode-ai/ui/icon"
+import { showToast } from "@opencode-ai/ui/toast"
 import { useSync } from "@/context/sync"
 import { useSDK } from "@/context/sdk"
 import { normalizeServerUrl, useServer } from "@/context/server"
 import { usePlatform } from "@/context/platform"
 import { useLanguage } from "@/context/language"
 import { DialogSelectServer } from "./dialog-select-server"
-import { showToast } from "@opencode-ai/ui/toast"
 import { ServerRow } from "@/components/server/server-row"
 import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
 
-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 pollMs = 10_000
 
-  const [store, setStore] = createStore({
-    status: {} as Record<string, ServerHealth | undefined>,
-    loading: null as string | null,
-    defaultServerUrl: undefined as string | undefined,
-  })
-  const fetcher = platform.fetch ?? globalThis.fetch
+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 servers = createMemo(() => {
-    const current = server.url
-    const list = server.list
-    if (!current) return list
-    if (!list.includes(current)) return [current, ...list]
-    return [current, ...list.filter((x) => x !== current)]
+const listServersByHealth = (
+  list: string[],
+  active: string | undefined,
+  status: Record<string, 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 (a === active) return -1
+    if (b === active) return 1
+    const diff = rank(status[a]) - rank(status[b])
+    if (diff !== 0) return diff
+    return (order.get(a) ?? 0) - (order.get(b) ?? 0)
   })
+}
 
-  const sortedServers = createMemo(() => {
+const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => {
+  const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>)
+
+  createEffect(() => {
     const list = servers()
-    if (!list.length) return list
-    const active = server.url
-    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
+    let dead = false
+
+    const refresh = async () => {
+      const results: Record<string, ServerHealth> = {}
+      await Promise.all(
+        list.map(async (url) => {
+          results[url] = await checkServerHealth(url, fetcher)
+        }),
+      )
+      if (dead) return
+      setStatus(reconcile(results))
     }
-    return list.slice().sort((a, b) => {
-      if (a === active) return -1
-      if (b === active) return 1
-      const diff = rank(store.status[a]) - rank(store.status[b])
-      if (diff !== 0) return diff
-      return (order.get(a) ?? 0) - (order.get(b) ?? 0)
+
+    void refresh()
+    const id = setInterval(() => void refresh(), pollMs)
+    onCleanup(() => {
+      dead = true
+      clearInterval(id)
     })
   })
 
-  async function refreshHealth() {
-    const results: Record<string, ServerHealth> = {}
-    await Promise.all(
-      servers().map(async (url) => {
-        results[url] = await checkServerHealth(url, fetcher)
-      }),
-    )
-    setStore("status", reconcile(results))
-  }
+  return status
+}
+
+const useDefaultServerUrl = (
+  get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
+) => {
+  const [url, setUrl] = createSignal<string | undefined>()
+  const [tick, setTick] = createSignal(0)
 
   createEffect(() => {
-    servers()
-    refreshHealth()
-    const interval = setInterval(refreshHealth, 10_000)
-    onCleanup(() => clearInterval(interval))
+    tick()
+    let dead = false
+    const result = get?.()
+    if (!result) {
+      setUrl(undefined)
+      onCleanup(() => {
+        dead = true
+      })
+      return
+    }
+
+    if (result instanceof Promise) {
+      void result.then((next) => {
+        if (dead) return
+        setUrl(next ? normalizeServerUrl(next) : undefined)
+      })
+      onCleanup(() => {
+        dead = true
+      })
+      return
+    }
+
+    setUrl(normalizeServerUrl(result))
+    onCleanup(() => {
+      dead = true
+    })
   })
 
-  const mcpItems = createMemo(() =>
-    Object.entries(sync.data.mcp ?? {})
-      .map(([name, status]) => ({ name, status: status.status }))
-      .sort((a, b) => a.name.localeCompare(b.name)),
-  )
+  return { url, refresh: () => setTick((value) => value + 1) }
+}
 
-  const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length)
+const useMcpToggle = (input: {
+  sync: ReturnType<typeof useSync>
+  sdk: ReturnType<typeof useSDK>
+  language: ReturnType<typeof useLanguage>
+}) => {
+  const [loading, setLoading] = createSignal<string | null>(null)
 
-  const toggleMcp = async (name: string) => {
-    if (store.loading) return
-    setStore("loading", name)
+  const toggle = async (name: string) => {
+    if (loading()) return
+    setLoading(name)
 
     try {
-      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)
+      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: language.t("common.requestFailed"),
+        title: input.language.t("common.requestFailed"),
         description: err instanceof Error ? err.message : String(err),
       })
     } finally {
-      setStore("loading", null)
+      setLoading(null)
     }
   }
 
+  return { loading, toggle }
+}
+
+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 fetcher = platform.fetch ?? globalThis.fetch
+  const servers = createMemo(() => {
+    const current = server.url
+    const list = server.list
+    if (!current) return list
+    if (!list.includes(current)) return [current, ...list]
+    return [current, ...list.filter((item) => item !== current)]
+  })
+  const health = useServerHealth(servers, fetcher)
+  const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health))
+  const mcp = useMcpToggle({ sync, sdk, language })
+  const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl)
+  const mcpItems = createMemo(() =>
+    Object.entries(sync.data.mcp ?? {})
+      .map(([name, status]) => ({ name, status: status.status }))
+      .sort((a, b) => a.name.localeCompare(b.name)),
+  )
+  const mcpConnected = createMemo(() => mcpItems().filter((item) => item.status === "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 serverHealthy = server.healthy() === true
-    const anyMcpIssue = mcpItems().some((m) => m.status !== "connected" && m.status !== "disabled")
+    const anyMcpIssue = mcpItems().some((item) => item.status !== "connected" && item.status !== "disabled")
     return serverHealthy && !anyMcpIssue
   })
 
-  const serverCount = createMemo(() => sortedServers().length)
-
-  const refreshDefaultServerUrl = () => {
-    const result = platform.getDefaultServerUrl?.()
-    if (!result) {
-      setStore("defaultServerUrl", undefined)
-      return
-    }
-    if (result instanceof Promise) {
-      result.then((url) => setStore("defaultServerUrl", url ? normalizeServerUrl(url) : undefined))
-      return
-    }
-    setStore("defaultServerUrl", normalizeServerUrl(result))
-  }
-
-  createEffect(() => {
-    refreshDefaultServerUrl()
-  })
-
   return (
     <Popover
       triggerAs={Button}
@@ -173,7 +228,7 @@ export function StatusPopover() {
         >
           <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">
-              {serverCount() > 0 ? `${serverCount()} ` : ""}
+              {sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
               {language.t("status.popover.tab.servers")}
             </Tabs.Trigger>
             <Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
@@ -195,11 +250,7 @@ export function StatusPopover() {
               <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
                 <For each={sortedServers()}>
                   {(url) => {
-                    const isActive = () => url === server.url
-                    const isDefault = () => url === store.defaultServerUrl
-                    const status = () => store.status[url]
-                    const isBlocked = () => status()?.healthy === false
-
+                    const isBlocked = () => health[url]?.healthy === false
                     return (
                       <button
                         type="button"
@@ -217,13 +268,13 @@ export function StatusPopover() {
                       >
                         <ServerRow
                           url={url}
-                          status={status()}
+                          status={health[url]}
                           dimmed={isBlocked()}
                           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={isDefault()}>
+                            <Show when={url === defaultServer.url()}>
                               <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
                                 {language.t("common.default")}
                               </span>
@@ -231,7 +282,7 @@ export function StatusPopover() {
                           }
                         >
                           <div class="flex-1" />
-                          <Show when={isActive()}>
+                          <Show when={url === server.url}>
                             <Icon name="check" size="small" class="text-icon-weak shrink-0" />
                           </Show>
                         </ServerRow>
@@ -243,7 +294,7 @@ export function StatusPopover() {
                 <Button
                   variant="secondary"
                   class="mt-3 self-start h-8 px-3 py-1.5"
-                  onClick={() => dialog.show(() => <DialogSelectServer />, refreshDefaultServerUrl)}
+                  onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
                 >
                   {language.t("status.popover.action.manageServers")}
                 </Button>
@@ -269,8 +320,8 @@ export function StatusPopover() {
                         <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={() => toggleMcp(item.name)}
-                          disabled={store.loading === item.name}
+                          onClick={() => mcp.toggle(item.name)}
+                          disabled={mcp.loading() === item.name}
                         >
                           <div
                             classList={{
@@ -286,8 +337,8 @@ export function StatusPopover() {
                           <div onClick={(event) => event.stopPropagation()}>
                             <Switch
                               checked={enabled()}
-                              disabled={store.loading === item.name}
-                              onChange={() => toggleMcp(item.name)}
+                              disabled={mcp.loading() === item.name}
+                              onChange={() => mcp.toggle(item.name)}
                             />
                           </div>
                         </button>
@@ -334,23 +385,7 @@ export function StatusPopover() {
               <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">
-                      {(() => {
-                        const value = language.t("dialog.plugins.empty")
-                        const file = "opencode.json"
-                        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)}
-                          </>
-                        )
-                      })()}
-                    </div>
-                  }
+                  fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
                 >
                   <For each={plugins()}>
                     {(plugin) => (

+ 104 - 88
packages/app/src/components/terminal.tsx

@@ -56,6 +56,91 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
   },
 }
 
+const debugTerminal = (...values: unknown[]) => {
+  if (!import.meta.env.DEV) return
+  console.debug("[terminal]", ...values)
+}
+
+const useTerminalUiBindings = (input: {
+  container: HTMLDivElement
+  term: Term
+  cleanups: VoidFunction[]
+  handlePointerDown: () => void
+  handleLinkClick: (event: MouseEvent) => void
+}) => {
+  const handleCopy = (event: ClipboardEvent) => {
+    const selection = input.term.getSelection()
+    if (!selection) return
+
+    const clipboard = event.clipboardData
+    if (!clipboard) return
+
+    event.preventDefault()
+    clipboard.setData("text/plain", selection)
+  }
+
+  const handlePaste = (event: ClipboardEvent) => {
+    const clipboard = event.clipboardData
+    const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? ""
+    if (!text) return
+
+    event.preventDefault()
+    event.stopPropagation()
+    input.term.paste(text)
+  }
+
+  const handleTextareaFocus = () => {
+    input.term.options.cursorBlink = true
+  }
+  const handleTextareaBlur = () => {
+    input.term.options.cursorBlink = false
+  }
+
+  input.container.addEventListener("copy", handleCopy, true)
+  input.cleanups.push(() => input.container.removeEventListener("copy", handleCopy, true))
+
+  input.container.addEventListener("paste", handlePaste, true)
+  input.cleanups.push(() => input.container.removeEventListener("paste", handlePaste, true))
+
+  input.container.addEventListener("pointerdown", input.handlePointerDown)
+  input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown))
+
+  input.container.addEventListener("click", input.handleLinkClick, { capture: true })
+  input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true }))
+
+  input.term.textarea?.addEventListener("focus", handleTextareaFocus)
+  input.term.textarea?.addEventListener("blur", handleTextareaBlur)
+  input.cleanups.push(() => input.term.textarea?.removeEventListener("focus", handleTextareaFocus))
+  input.cleanups.push(() => input.term.textarea?.removeEventListener("blur", handleTextareaBlur))
+}
+
+const persistTerminal = (input: {
+  term: Term | undefined
+  addon: SerializeAddon | undefined
+  cursor: number
+  pty: LocalPTY
+  onCleanup?: (pty: LocalPTY) => void
+}) => {
+  if (!input.addon || !input.onCleanup || !input.term) return
+  const buffer = (() => {
+    try {
+      return input.addon.serialize()
+    } catch {
+      debugTerminal("failed to serialize terminal buffer")
+      return ""
+    }
+  })()
+
+  input.onCleanup({
+    ...input.pty,
+    buffer,
+    cursor: input.cursor,
+    rows: input.term.rows,
+    cols: input.term.cols,
+    scrollY: input.term.getViewportY(),
+  })
+}
+
 export const Terminal = (props: TerminalProps) => {
   const platform = usePlatform()
   const sdk = useSDK()
@@ -70,8 +155,6 @@ export const Terminal = (props: TerminalProps) => {
   let serializeAddon: SerializeAddon
   let fitAddon: FitAddon
   let handleResize: () => void
-  let handleTextareaFocus: () => void
-  let handleTextareaBlur: () => void
   let disposed = false
   const cleanups: VoidFunction[] = []
   const start =
@@ -84,12 +167,23 @@ export const Terminal = (props: TerminalProps) => {
     for (const fn of fns) {
       try {
         fn()
-      } catch {
-        // ignore
+      } catch (err) {
+        debugTerminal("cleanup failed", err)
       }
     }
   }
 
+  const pushSize = (cols: number, rows: number) => {
+    return sdk.client.pty
+      .update({
+        ptyID: local.pty.id,
+        size: { cols, rows },
+      })
+      .catch((err) => {
+        debugTerminal("failed to sync terminal size", err)
+      })
+  }
+
   const getTerminalColors = (): TerminalColors => {
     const mode = theme.mode() === "dark" ? "dark" : "light"
     const fallback = DEFAULT_TERMINAL_COLORS[mode]
@@ -219,27 +313,6 @@ export const Terminal = (props: TerminalProps) => {
       ghostty = g
       term = t
 
-      const handleCopy = (event: ClipboardEvent) => {
-        const selection = t.getSelection()
-        if (!selection) return
-
-        const clipboard = event.clipboardData
-        if (!clipboard) return
-
-        event.preventDefault()
-        clipboard.setData("text/plain", selection)
-      }
-
-      const handlePaste = (event: ClipboardEvent) => {
-        const clipboard = event.clipboardData
-        const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? ""
-        if (!text) return
-
-        event.preventDefault()
-        event.stopPropagation()
-        t.paste(text)
-      }
-
       t.attachCustomKeyEventHandler((event) => {
         const key = event.key.toLowerCase()
 
@@ -255,12 +328,6 @@ export const Terminal = (props: TerminalProps) => {
         return matchKeybind(keybinds, event)
       })
 
-      container.addEventListener("copy", handleCopy, true)
-      cleanups.push(() => container.removeEventListener("copy", handleCopy, true))
-
-      container.addEventListener("paste", handlePaste, true)
-      cleanups.push(() => container.removeEventListener("paste", handlePaste, true))
-
       const fit = new mod.FitAddon()
       const serializer = new SerializeAddon()
       cleanups.push(() => disposeIfDisposable(fit))
@@ -270,24 +337,7 @@ export const Terminal = (props: TerminalProps) => {
       serializeAddon = serializer
 
       t.open(container)
-
-      container.addEventListener("pointerdown", handlePointerDown)
-      cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown))
-
-      container.addEventListener("click", handleLinkClick, { capture: true })
-      cleanups.push(() => container.removeEventListener("click", handleLinkClick, { capture: true }))
-
-      handleTextareaFocus = () => {
-        t.options.cursorBlink = true
-      }
-      handleTextareaBlur = () => {
-        t.options.cursorBlink = false
-      }
-
-      t.textarea?.addEventListener("focus", handleTextareaFocus)
-      t.textarea?.addEventListener("blur", handleTextareaBlur)
-      cleanups.push(() => t.textarea?.removeEventListener("focus", handleTextareaFocus))
-      cleanups.push(() => t.textarea?.removeEventListener("blur", handleTextareaBlur))
+      useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick })
 
       focusTerminal()
 
@@ -316,15 +366,7 @@ export const Terminal = (props: TerminalProps) => {
 
       const onResize = t.onResize(async (size) => {
         if (socket.readyState === WebSocket.OPEN) {
-          await sdk.client.pty
-            .update({
-              ptyID: local.pty.id,
-              size: {
-                cols: size.cols,
-                rows: size.rows,
-              },
-            })
-            .catch(() => {})
+          await pushSize(size.cols, size.rows)
         }
       })
       cleanups.push(() => disposeIfDisposable(onResize))
@@ -346,15 +388,7 @@ export const Terminal = (props: TerminalProps) => {
 
       const handleOpen = () => {
         local.onConnect?.()
-        sdk.client.pty
-          .update({
-            ptyID: local.pty.id,
-            size: {
-              cols: t.cols,
-              rows: t.rows,
-            },
-          })
-          .catch(() => {})
+        void pushSize(t.cols, t.rows)
       }
       socket.addEventListener("open", handleOpen)
       cleanups.push(() => socket.removeEventListener("open", handleOpen))
@@ -374,8 +408,8 @@ export const Terminal = (props: TerminalProps) => {
             if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
               cursor = next
             }
-          } catch {
-            // ignore
+          } catch (err) {
+            debugTerminal("invalid websocket control frame", err)
           }
           return
         }
@@ -425,25 +459,7 @@ export const Terminal = (props: TerminalProps) => {
 
   onCleanup(() => {
     disposed = true
-    const t = term
-    if (serializeAddon && props.onCleanup && t) {
-      const buffer = (() => {
-        try {
-          return serializeAddon.serialize()
-        } catch {
-          return ""
-        }
-      })()
-      props.onCleanup({
-        ...local.pty,
-        buffer,
-        cursor,
-        rows: t.rows,
-        cols: t.cols,
-        scrollY: t.getViewportY(),
-      })
-    }
-
+    persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
     cleanup()
   })
 

+ 25 - 23
packages/app/src/components/titlebar.tsx

@@ -13,6 +13,28 @@ import { useCommand } from "@/context/command"
 import { useLanguage } from "@/context/language"
 import { applyPath, backPath, forwardPath } from "./titlebar-history"
 
+type TauriDesktopWindow = {
+  startDragging?: () => Promise<void>
+  toggleMaximize?: () => Promise<void>
+}
+
+type TauriThemeWindow = {
+  setTheme?: (theme?: "light" | "dark" | null) => Promise<void>
+}
+
+type TauriApi = {
+  window?: {
+    getCurrentWindow?: () => TauriDesktopWindow
+  }
+  webviewWindow?: {
+    getCurrentWebviewWindow?: () => TauriThemeWindow
+  }
+}
+
+const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
+const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
+const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
+
 export function Titlebar() {
   const layout = useLayout()
   const platform = usePlatform()
@@ -82,22 +104,7 @@ export function Titlebar() {
 
   const getWin = () => {
     if (platform.platform !== "desktop") return
-
-    const tauri = (
-      window as unknown as {
-        __TAURI__?: {
-          window?: {
-            getCurrentWindow?: () => {
-              startDragging?: () => Promise<void>
-              toggleMaximize?: () => Promise<void>
-            }
-          }
-        }
-      }
-    ).__TAURI__
-    if (!tauri?.window?.getCurrentWindow) return
-
-    return tauri.window.getCurrentWindow()
+    return currentDesktopWindow()
   }
 
   createEffect(() => {
@@ -106,13 +113,8 @@ export function Titlebar() {
     const scheme = theme.colorScheme()
     const value = scheme === "system" ? null : scheme
 
-    const tauri = (window as unknown as { __TAURI__?: { webviewWindow?: { getCurrentWebviewWindow?: () => unknown } } })
-      .__TAURI__
-    const get = tauri?.webviewWindow?.getCurrentWebviewWindow
-    if (!get) return
-
-    const win = get() as { setTheme?: (theme?: "light" | "dark" | null) => Promise<void> }
-    if (!win.setTheme) return
+    const win = currentThemeWindow()
+    if (!win?.setTheme) return
 
     void win.setTheme(value).catch(() => undefined)
   })

+ 33 - 9
packages/app/src/context/command.tsx

@@ -11,6 +11,7 @@ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(na
 const PALETTE_ID = "command.palette"
 const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
 const SUGGESTED_PREFIX = "suggested."
+const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new"])
 
 function actionId(id: string) {
   if (!id.startsWith(SUGGESTED_PREFIX)) return id
@@ -33,6 +34,11 @@ function signatureFromEvent(event: KeyboardEvent) {
   return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
 }
 
+function isAllowedEditableKeybind(id: string | undefined) {
+  if (!id) return false
+  return EDITABLE_KEYBIND_IDS.has(actionId(id))
+}
+
 export type KeybindConfig = string
 
 export interface Keybind {
@@ -56,6 +62,8 @@ export interface CommandOption {
   onHighlight?: () => (() => void) | void
 }
 
+type CommandSource = "palette" | "keybind" | "slash"
+
 export type CommandCatalogItem = {
   title: string
   description?: string
@@ -169,6 +177,14 @@ export function formatKeybind(config: string): string {
   return IS_MAC ? parts.join("") : parts.join("+")
 }
 
+function isEditableTarget(target: EventTarget | null) {
+  if (!(target instanceof HTMLElement)) return false
+  if (target.isContentEditable) return true
+  if (target.closest("[contenteditable='true']")) return true
+  if (target.closest("input, textarea, select")) return true
+  return false
+}
+
 export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
   name: "Command",
   init: () => {
@@ -275,13 +291,18 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
       return map
     })
 
-    const run = (id: string, source?: "palette" | "keybind" | "slash") => {
+    const optionMap = createMemo(() => {
+      const map = new Map<string, CommandOption>()
       for (const option of options()) {
-        if (option.id === id || option.id === "suggested." + id) {
-          option.onSelect?.(source)
-          return
-        }
+        map.set(option.id, option)
+        map.set(actionId(option.id), option)
       }
+      return map
+    })
+
+    const run = (id: string, source?: CommandSource) => {
+      const option = optionMap().get(id)
+      option?.onSelect?.(source)
     }
 
     const showPalette = () => {
@@ -292,14 +313,17 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
       if (suspended() || dialog.active) return
 
       const sig = signatureFromEvent(event)
+      const isPalette = palette().has(sig)
+      const option = keymap().get(sig)
+
+      if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id)) return
 
-      if (palette().has(sig)) {
+      if (isPalette) {
         event.preventDefault()
         showPalette()
         return
       }
 
-      const option = keymap().get(sig)
       if (!option) return
       event.preventDefault()
       option.onSelect?.("keybind")
@@ -332,7 +356,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
 
     return {
       register,
-      trigger(id: string, source?: "palette" | "keybind" | "slash") {
+      trigger(id: string, source?: CommandSource) {
         run(id, source)
       },
       keybind(id: string) {
@@ -351,7 +375,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
       },
       show: showPalette,
       keybinds(enabled: boolean) {
-        setStore("suspendCount", (count) => count + (enabled ? -1 : 1))
+        setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1)))
       },
       suspended,
       get catalog() {

+ 41 - 0
packages/app/src/context/comments.test.ts

@@ -109,4 +109,45 @@ describe("comments session indexing", () => {
       dispose()
     })
   })
+
+  test("remove keeps focus when same comment id exists in another file", () => {
+    createRoot((dispose) => {
+      const comments = createCommentSessionForTest({
+        "a.ts": [line("a.ts", "shared", 10)],
+        "b.ts": [line("b.ts", "shared", 20)],
+      })
+
+      comments.setFocus({ file: "b.ts", id: "shared" })
+      comments.remove("a.ts", "shared")
+
+      expect(comments.focus()).toEqual({ file: "b.ts", id: "shared" })
+      expect(comments.list("a.ts")).toEqual([])
+      expect(comments.list("b.ts").map((item) => item.id)).toEqual(["shared"])
+
+      dispose()
+    })
+  })
+
+  test("setFocus and setActive updater callbacks receive current state", () => {
+    createRoot((dispose) => {
+      const comments = createCommentSessionForTest()
+
+      comments.setFocus({ file: "a.ts", id: "a1" })
+      comments.setFocus((current) => {
+        expect(current).toEqual({ file: "a.ts", id: "a1" })
+        return { file: "b.ts", id: "b1" }
+      })
+
+      comments.setActive({ file: "c.ts", id: "c1" })
+      comments.setActive((current) => {
+        expect(current).toEqual({ file: "c.ts", id: "c1" })
+        return null
+      })
+
+      expect(comments.focus()).toEqual({ file: "b.ts", id: "b1" })
+      expect(comments.active()).toBeNull()
+
+      dispose()
+    })
+  })
 })

+ 30 - 28
packages/app/src/context/comments.tsx

@@ -1,4 +1,4 @@
-import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
+import { batch, createMemo, createRoot, onCleanup } from "solid-js"
 import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useParams } from "@solidjs/router"
@@ -20,6 +20,19 @@ type CommentFocus = { file: string; id: string }
 const WORKSPACE_KEY = "__workspace__"
 const MAX_COMMENT_SESSIONS = 20
 
+function sessionKey(dir: string, id: string | undefined) {
+  return `${dir}\n${id ?? WORKSPACE_KEY}`
+}
+
+function decodeSessionKey(key: string) {
+  const split = key.lastIndexOf("\n")
+  if (split < 0) return { dir: key, id: WORKSPACE_KEY }
+  return {
+    dir: key.slice(0, split),
+    id: key.slice(split + 1),
+  }
+}
+
 type CommentStore = {
   comments: Record<string, LineComment[]>
 }
@@ -31,24 +44,24 @@ function aggregate(comments: Record<string, LineComment[]>) {
     .sort((a, b) => a.time - b.time)
 }
 
-function insert(items: LineComment[], next: LineComment) {
-  const index = items.findIndex((item) => item.time > next.time)
-  if (index < 0) return [...items, next]
-  return [...items.slice(0, index), next, ...items.slice(index)]
-}
-
 function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
   const [state, setState] = createStore({
     focus: null as CommentFocus | null,
     active: null as CommentFocus | null,
-    all: aggregate(store.comments),
   })
 
+  const all = () => aggregate(store.comments)
+
+  const setRef = (
+    key: "focus" | "active",
+    value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null),
+  ) => setState(key, value)
+
   const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
-    setState("focus", value)
+    setRef("focus", value)
 
   const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
-    setState("active", value)
+    setRef("active", value)
 
   const list = (file: string) => store.comments[file] ?? []
 
@@ -61,7 +74,6 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
 
     batch(() => {
       setStore("comments", input.file, (items) => [...(items ?? []), next])
-      setState("all", (items) => insert(items, next))
       setFocus({ file: input.file, id: next.id })
     })
 
@@ -71,15 +83,13 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
   const remove = (file: string, id: string) => {
     batch(() => {
       setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id))
-      setState("all", (items) => items.filter((item) => !(item.file === file && item.id === id)))
-      setFocus((current) => (current?.id === id ? null : current))
+      setFocus((current) => (current?.file === file && current.id === id ? null : current))
     })
   }
 
   const clear = () => {
     batch(() => {
       setStore("comments", reconcile({}))
-      setState("all", [])
       setFocus(null)
       setActive(null)
     })
@@ -87,17 +97,16 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
 
   return {
     list,
-    all: () => state.all,
+    all,
     add,
     remove,
     clear,
     focus: () => state.focus,
     setFocus,
-    clearFocus: () => setFocus(null),
+    clearFocus: () => setRef("focus", null),
     active: () => state.active,
     setActive,
-    clearActive: () => setActive(null),
-    reindex: () => setState("all", aggregate(store.comments)),
+    clearActive: () => setRef("active", null),
   }
 }
 
@@ -117,11 +126,6 @@ function createCommentSession(dir: string, id: string | undefined) {
   )
   const session = createCommentSessionState(store, setStore)
 
-  createEffect(() => {
-    if (!ready()) return
-    session.reindex()
-  })
-
   return {
     ready,
     list: session.list,
@@ -145,11 +149,9 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
     const params = useParams()
     const cache = createScopedCache(
       (key) => {
-        const split = key.lastIndexOf("\n")
-        const dir = split >= 0 ? key.slice(0, split) : key
-        const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
+        const decoded = decodeSessionKey(key)
         return createRoot((dispose) => ({
-          value: createCommentSession(dir, id === WORKSPACE_KEY ? undefined : id),
+          value: createCommentSession(decoded.dir, decoded.id === WORKSPACE_KEY ? undefined : decoded.id),
           dispose,
         }))
       },
@@ -162,7 +164,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
     onCleanup(() => cache.clear())
 
     const load = (dir: string, id: string | undefined) => {
-      const key = `${dir}\n${id ?? WORKSPACE_KEY}`
+      const key = sessionKey(dir, id)
       return cache.get(key).value
     }
 

+ 57 - 44
packages/app/src/context/file.tsx

@@ -43,6 +43,12 @@ export {
   touchFileContent,
 }
 
+function errorMessage(error: unknown) {
+  if (error instanceof Error && error.message) return error.message
+  if (typeof error === "string" && error) return error
+  return "Unknown error"
+}
+
 export const { use: useFile, provider: FileProvider } = createSimpleContext({
   name: "File",
   gate: false,
@@ -110,6 +116,45 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
       setStore("file", file, { path: file, name: getFilename(file) })
     }
 
+    const setLoading = (file: string) => {
+      setStore(
+        "file",
+        file,
+        produce((draft) => {
+          draft.loading = true
+          draft.error = undefined
+        }),
+      )
+    }
+
+    const setLoaded = (file: string, content: FileState["content"]) => {
+      setStore(
+        "file",
+        file,
+        produce((draft) => {
+          draft.loaded = true
+          draft.loading = false
+          draft.content = content
+        }),
+      )
+    }
+
+    const setLoadError = (file: string, message: string) => {
+      setStore(
+        "file",
+        file,
+        produce((draft) => {
+          draft.loading = false
+          draft.error = message
+        }),
+      )
+      showToast({
+        variant: "error",
+        title: language.t("toast.file.loadFailed.title"),
+        description: message,
+      })
+    }
+
     const load = (input: string, options?: { force?: boolean }) => {
       const file = path.normalize(input)
       if (!file) return Promise.resolve()
@@ -124,29 +169,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
       const pending = inflight.get(key)
       if (pending) return pending
 
-      setStore(
-        "file",
-        file,
-        produce((draft) => {
-          draft.loading = true
-          draft.error = undefined
-        }),
-      )
+      setLoading(file)
 
       const promise = sdk.client.file
         .read({ path: file })
         .then((x) => {
           if (scope() !== directory) return
           const content = x.data
-          setStore(
-            "file",
-            file,
-            produce((draft) => {
-              draft.loaded = true
-              draft.loading = false
-              draft.content = content
-            }),
-          )
+          setLoaded(file, content)
 
           if (!content) return
           touchFileContent(file, approxBytes(content))
@@ -154,19 +184,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
         })
         .catch((e) => {
           if (scope() !== directory) return
-          setStore(
-            "file",
-            file,
-            produce((draft) => {
-              draft.loading = false
-              draft.error = e.message
-            }),
-          )
-          showToast({
-            variant: "error",
-            title: language.t("toast.file.loadFailed.title"),
-            description: e.message,
-          })
+          setLoadError(file, errorMessage(e))
         })
         .finally(() => {
           inflight.delete(key)
@@ -211,21 +229,16 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
       return state
     }
 
-    const scrollTop = (input: string) => view().scrollTop(path.normalize(input))
-    const scrollLeft = (input: string) => view().scrollLeft(path.normalize(input))
-    const selectedLines = (input: string) => view().selectedLines(path.normalize(input))
-
-    const setScrollTop = (input: string, top: number) => {
-      view().setScrollTop(path.normalize(input), top)
-    }
-
-    const setScrollLeft = (input: string, left: number) => {
-      view().setScrollLeft(path.normalize(input), left)
-    }
-
-    const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
-      view().setSelectedLines(path.normalize(input), range)
+    function withPath(input: string, action: (file: string) => unknown) {
+      return action(path.normalize(input))
     }
+    const scrollTop = (input: string) => withPath(input, (file) => view().scrollTop(file))
+    const scrollLeft = (input: string) => withPath(input, (file) => view().scrollLeft(file))
+    const selectedLines = (input: string) => withPath(input, (file) => view().selectedLines(file))
+    const setScrollTop = (input: string, top: number) => withPath(input, (file) => view().setScrollTop(file, top))
+    const setScrollLeft = (input: string, left: number) => withPath(input, (file) => view().setScrollLeft(file, left))
+    const setSelectedLines = (input: string, range: SelectedLineRange | null) =>
+      withPath(input, (file) => view().setSelectedLines(file, range))
 
     onCleanup(() => {
       stop()

+ 15 - 7
packages/app/src/context/global-sdk.tsx

@@ -31,9 +31,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
     }>()
 
     type Queued = { directory: string; payload: Event }
+    const FLUSH_FRAME_MS = 16
+    const STREAM_YIELD_MS = 8
 
-    let queue: Array<Queued | undefined> = []
-    let buffer: Array<Queued | undefined> = []
+    let queue: Queued[] = []
+    let buffer: Queued[] = []
     const coalesced = new Map<string, number>()
     let timer: ReturnType<typeof setTimeout> | undefined
     let last = 0
@@ -62,7 +64,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
       last = Date.now()
       batch(() => {
         for (const event of events) {
-          if (!event) continue
           emitter.emit(event.directory, event.payload)
         }
       })
@@ -73,9 +74,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
     const schedule = () => {
       if (timer) return
       const elapsed = Date.now() - last
-      timer = setTimeout(flush, Math.max(0, 16 - elapsed))
+      timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
     }
 
+    let streamErrorLogged = false
+
     void (async () => {
       const events = await eventSdk.global.event()
       let yielded = Date.now()
@@ -86,20 +89,25 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
         if (k) {
           const i = coalesced.get(k)
           if (i !== undefined) {
-            queue[i] = undefined
+            queue[i] = { directory, payload }
+            continue
           }
           coalesced.set(k, queue.length)
         }
         queue.push({ directory, payload })
         schedule()
 
-        if (Date.now() - yielded < 8) continue
+        if (Date.now() - yielded < STREAM_YIELD_MS) continue
         yielded = Date.now()
         await new Promise<void>((resolve) => setTimeout(resolve, 0))
       }
     })()
       .finally(flush)
-      .catch(() => undefined)
+      .catch((error) => {
+        if (streamErrorLogged) return
+        streamErrorLogged = true
+        console.error("[global-sdk] event stream failed", error)
+      })
 
     onCleanup(() => {
       abort.abort()

+ 42 - 28
packages/app/src/context/global-sync.tsx

@@ -47,6 +47,20 @@ type GlobalStore = {
   reload: undefined | "pending" | "complete"
 }
 
+function errorMessage(error: unknown) {
+  if (error instanceof Error && error.message) return error.message
+  if (typeof error === "string" && error) return error
+  return "Unknown error"
+}
+
+function setDevStats(value: {
+  activeDirectoryStores: number
+  evictions: number
+  loadSessionsFullFetchFallback: number
+}) {
+  ;(globalThis as { __OPENCODE_GLOBAL_SYNC_STATS?: typeof value }).__OPENCODE_GLOBAL_SYNC_STATS = value
+}
+
 function createGlobalSync() {
   const globalSDK = useGlobalSDK()
   const platform = usePlatform()
@@ -81,19 +95,11 @@ function createGlobalSync() {
 
   const updateStats = (activeDirectoryStores: number) => {
     if (!import.meta.env.DEV) return
-    ;(
-      globalThis as {
-        __OPENCODE_GLOBAL_SYNC_STATS?: {
-          activeDirectoryStores: number
-          evictions: number
-          loadSessionsFullFetchFallback: number
-        }
-      }
-    ).__OPENCODE_GLOBAL_SYNC_STATS = {
+    setDevStats({
       activeDirectoryStores,
       evictions: stats.evictions,
       loadSessionsFullFetchFallback: stats.loadSessionsFallback,
-    }
+    })
   }
 
   const paused = () => untrack(() => globalStore.reload) !== undefined
@@ -204,7 +210,10 @@ function createGlobalSync() {
       .catch((err) => {
         console.error("Failed to load sessions", err)
         const project = getFilename(directory)
-        showToast({ title: language.t("toast.session.listFailed.title", { project }), description: err.message })
+        showToast({
+          title: language.t("toast.session.listFailed.title", { project }),
+          description: errorMessage(err),
+        })
       })
 
     sessionLoads.set(directory, promise)
@@ -307,12 +316,28 @@ function createGlobalSync() {
     void bootstrap()
   })
 
-  function projectMeta(directory: string, patch: ProjectMeta) {
-    children.projectMeta(directory, patch)
+  const projectApi = {
+    loadSessions,
+    meta(directory: string, patch: ProjectMeta) {
+      children.projectMeta(directory, patch)
+    },
+    icon(directory: string, value: string | undefined) {
+      children.projectIcon(directory, value)
+    },
   }
 
-  function projectIcon(directory: string, value: string | undefined) {
-    children.projectIcon(directory, value)
+  const updateConfig = async (config: Config) => {
+    setGlobalStore("reload", "pending")
+    return globalSDK.client.global.config
+      .update({ config })
+      .then(bootstrap)
+      .then(() => {
+        setGlobalStore("reload", "complete")
+      })
+      .catch((error) => {
+        setGlobalStore("reload", undefined)
+        throw error
+      })
   }
 
   return {
@@ -326,19 +351,8 @@ function createGlobalSync() {
     },
     child: children.child,
     bootstrap,
-    updateConfig: (config: Config) => {
-      setGlobalStore("reload", "pending")
-      return globalSDK.client.global.config.update({ config }).finally(() => {
-        setTimeout(() => {
-          setGlobalStore("reload", "complete")
-        }, 1000)
-      })
-    },
-    project: {
-      loadSessions,
-      meta: projectMeta,
-      icon: projectIcon,
-    },
+    updateConfig,
+    project: projectApi,
   }
 }
 

+ 43 - 36
packages/app/src/context/highlights.tsx

@@ -119,9 +119,7 @@ function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; p
   const highlights = releases.slice(start, end).flatMap((release) => release.highlights)
   const seen = new Set<string>()
   const unique = highlights.filter((highlight) => {
-    const key = [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join(
-      "\n",
-    )
+    const key = dedupeKey(highlight)
     if (seen.has(key)) return false
     seen.add(key)
     return true
@@ -129,6 +127,16 @@ function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; p
   return unique.slice(0, 5)
 }
 
+function dedupeKey(highlight: Highlight) {
+  return [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join("\n")
+}
+
+function loadReleaseHighlights(value: unknown, current?: string, previous?: string) {
+  const releases = parseChangelog(value)
+  if (!releases?.length) return []
+  return sliceHighlights({ releases, current, previous })
+}
+
 export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({
   name: "Highlights",
   gate: false,
@@ -140,32 +148,21 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
 
     const [from, setFrom] = createSignal<string | undefined>(undefined)
     const [to, setTo] = createSignal<string | undefined>(undefined)
-    const [timer, setTimer] = createSignal<ReturnType<typeof setTimeout> | undefined>(undefined)
     const state = { started: false }
+    let timer: ReturnType<typeof setTimeout> | undefined
+
+    const clearTimer = () => {
+      if (timer === undefined) return
+      clearTimeout(timer)
+      timer = undefined
+    }
 
     const markSeen = () => {
       if (!platform.version) return
       setStore("version", platform.version)
     }
 
-    createEffect(() => {
-      if (state.started) return
-      if (!ready()) return
-      if (!settings.ready()) return
-      if (!platform.version) return
-      state.started = true
-
-      const previous = store.version
-      if (!previous) {
-        setStore("version", platform.version)
-        return
-      }
-
-      if (previous === platform.version) return
-
-      setFrom(previous)
-      setTo(platform.version)
-
+    const start = (previous: string) => {
       if (!settings.general.releaseNotes()) {
         markSeen()
         return
@@ -175,9 +172,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
       const controller = new AbortController()
       onCleanup(() => {
         controller.abort()
-        const id = timer()
-        if (id === undefined) return
-        clearTimeout(id)
+        clearTimer()
       })
 
       fetcher(CHANGELOG_URL, {
@@ -187,15 +182,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
         .then((response) => (response.ok ? (response.json() as Promise<unknown>) : undefined))
         .then((json) => {
           if (!json) return
-          const releases = parseChangelog(json)
-          if (!releases) return
-          if (releases.length === 0) return
-          const highlights = sliceHighlights({
-            releases,
-            current: platform.version,
-            previous,
-          })
-
+          const highlights = loadReleaseHighlights(json, platform.version, previous)
           if (controller.signal.aborted) return
 
           if (highlights.length === 0) {
@@ -203,13 +190,33 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
             return
           }
 
-          const timer = setTimeout(() => {
+          timer = setTimeout(() => {
+            timer = undefined
             markSeen()
             dialog.show(() => <DialogReleaseNotes highlights={highlights} />)
           }, 500)
-          setTimer(timer)
         })
         .catch(() => undefined)
+    }
+
+    createEffect(() => {
+      if (state.started) return
+      if (!ready()) return
+      if (!settings.ready()) return
+      if (!platform.version) return
+      state.started = true
+
+      const previous = store.version
+      if (!previous) {
+        setStore("version", platform.version)
+        return
+      }
+
+      if (previous === platform.version) return
+
+      setFrom(previous)
+      setTo(platform.version)
+      start(previous)
     })
 
     return {

+ 68 - 79
packages/app/src/context/language.tsx

@@ -76,6 +76,66 @@ const LOCALES: readonly Locale[] = [
   "th",
 ]
 
+const LABEL_KEY: Record<Locale, keyof Dictionary> = {
+  en: "language.en",
+  zh: "language.zh",
+  zht: "language.zht",
+  ko: "language.ko",
+  de: "language.de",
+  es: "language.es",
+  fr: "language.fr",
+  da: "language.da",
+  ja: "language.ja",
+  pl: "language.pl",
+  ru: "language.ru",
+  ar: "language.ar",
+  no: "language.no",
+  br: "language.br",
+  th: "language.th",
+  bs: "language.bs",
+}
+
+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 }) },
+}
+
+const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
+  { locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
+  { locale: "zh", match: (language) => language.startsWith("zh") },
+  { locale: "ko", match: (language) => language.startsWith("ko") },
+  { locale: "de", match: (language) => language.startsWith("de") },
+  { locale: "es", match: (language) => language.startsWith("es") },
+  { locale: "fr", match: (language) => language.startsWith("fr") },
+  { locale: "da", match: (language) => language.startsWith("da") },
+  { locale: "ja", match: (language) => language.startsWith("ja") },
+  { locale: "pl", match: (language) => language.startsWith("pl") },
+  { locale: "ru", match: (language) => language.startsWith("ru") },
+  { locale: "ar", match: (language) => language.startsWith("ar") },
+  {
+    locale: "no",
+    match: (language) => language.startsWith("no") || language.startsWith("nb") || language.startsWith("nn"),
+  },
+  { locale: "br", match: (language) => language.startsWith("pt") },
+  { locale: "th", match: (language) => language.startsWith("th") },
+  { locale: "bs", match: (language) => language.startsWith("bs") },
+]
+
 type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
 const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
   zh,
@@ -102,28 +162,9 @@ function detectLocale(): Locale {
   const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
   for (const language of languages) {
     if (!language) continue
-    if (language.toLowerCase().startsWith("zh")) {
-      if (language.toLowerCase().includes("hant")) return "zht"
-      return "zh"
-    }
-    if (language.toLowerCase().startsWith("ko")) return "ko"
-    if (language.toLowerCase().startsWith("de")) return "de"
-    if (language.toLowerCase().startsWith("es")) return "es"
-    if (language.toLowerCase().startsWith("fr")) return "fr"
-    if (language.toLowerCase().startsWith("da")) return "da"
-    if (language.toLowerCase().startsWith("ja")) return "ja"
-    if (language.toLowerCase().startsWith("pl")) return "pl"
-    if (language.toLowerCase().startsWith("ru")) return "ru"
-    if (language.toLowerCase().startsWith("ar")) return "ar"
-    if (
-      language.toLowerCase().startsWith("no") ||
-      language.toLowerCase().startsWith("nb") ||
-      language.toLowerCase().startsWith("nn")
-    )
-      return "no"
-    if (language.toLowerCase().startsWith("pt")) return "br"
-    if (language.toLowerCase().startsWith("th")) return "th"
-    if (language.toLowerCase().startsWith("bs")) return "bs"
+    const normalized = language.toLowerCase()
+    const match = localeMatchers.find((entry) => entry.match(normalized))
+    if (match) return match.locale
   }
 
   return "en"
@@ -139,24 +180,9 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
       }),
     )
 
-    const locale = createMemo<Locale>(() => {
-      if (store.locale === "zh") return "zh"
-      if (store.locale === "zht") return "zht"
-      if (store.locale === "ko") return "ko"
-      if (store.locale === "de") return "de"
-      if (store.locale === "es") return "es"
-      if (store.locale === "fr") return "fr"
-      if (store.locale === "da") return "da"
-      if (store.locale === "ja") return "ja"
-      if (store.locale === "pl") return "pl"
-      if (store.locale === "ru") return "ru"
-      if (store.locale === "ar") return "ar"
-      if (store.locale === "no") return "no"
-      if (store.locale === "br") return "br"
-      if (store.locale === "th") return "th"
-      if (store.locale === "bs") return "bs"
-      return "en"
-    })
+    const locale = createMemo<Locale>(() =>
+      LOCALES.includes(store.locale as Locale) ? (store.locale as Locale) : "en",
+    )
 
     createEffect(() => {
       const current = locale()
@@ -164,48 +190,11 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
       setStore("locale", current)
     })
 
-    const base = i18n.flatten({ ...en, ...uiEn })
-    const dict = createMemo<Dictionary>(() => {
-      if (locale() === "en") return base
-      if (locale() === "zh") return { ...base, ...i18n.flatten({ ...zh, ...uiZh }) }
-      if (locale() === "zht") return { ...base, ...i18n.flatten({ ...zht, ...uiZht }) }
-      if (locale() === "de") return { ...base, ...i18n.flatten({ ...de, ...uiDe }) }
-      if (locale() === "es") return { ...base, ...i18n.flatten({ ...es, ...uiEs }) }
-      if (locale() === "fr") return { ...base, ...i18n.flatten({ ...fr, ...uiFr }) }
-      if (locale() === "da") return { ...base, ...i18n.flatten({ ...da, ...uiDa }) }
-      if (locale() === "ja") return { ...base, ...i18n.flatten({ ...ja, ...uiJa }) }
-      if (locale() === "pl") return { ...base, ...i18n.flatten({ ...pl, ...uiPl }) }
-      if (locale() === "ru") return { ...base, ...i18n.flatten({ ...ru, ...uiRu }) }
-      if (locale() === "ar") return { ...base, ...i18n.flatten({ ...ar, ...uiAr }) }
-      if (locale() === "no") return { ...base, ...i18n.flatten({ ...no, ...uiNo }) }
-      if (locale() === "br") return { ...base, ...i18n.flatten({ ...br, ...uiBr }) }
-      if (locale() === "th") return { ...base, ...i18n.flatten({ ...th, ...uiTh }) }
-      if (locale() === "bs") return { ...base, ...i18n.flatten({ ...bs, ...uiBs }) }
-      return { ...base, ...i18n.flatten({ ...ko, ...uiKo }) }
-    })
+    const dict = createMemo<Dictionary>(() => DICT[locale()])
 
     const t = i18n.translator(dict, i18n.resolveTemplate)
 
-    const labelKey: Record<Locale, keyof Dictionary> = {
-      en: "language.en",
-      zh: "language.zh",
-      zht: "language.zht",
-      ko: "language.ko",
-      de: "language.de",
-      es: "language.es",
-      fr: "language.fr",
-      da: "language.da",
-      ja: "language.ja",
-      pl: "language.pl",
-      ru: "language.ru",
-      ar: "language.ar",
-      no: "language.no",
-      br: "language.br",
-      th: "language.th",
-      bs: "language.bs",
-    }
-
-    const label = (value: Locale) => t(labelKey[value])
+    const label = (value: Locale) => t(LABEL_KEY[value])
 
     createEffect(() => {
       if (typeof document !== "object") return

+ 42 - 63
packages/app/src/context/layout.tsx

@@ -11,6 +11,9 @@ import { same } from "@/utils/same"
 import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
 
 const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
+const DEFAULT_PANEL_WIDTH = 344
+const DEFAULT_SESSION_WIDTH = 600
+const DEFAULT_TERMINAL_HEIGHT = 280
 export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
 
 export function getAvatarColors(key?: string) {
@@ -85,6 +88,14 @@ export function pruneSessionKeys(input: {
     .slice(input.max)
 }
 
+function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
+  const all = current?.all ?? []
+  if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab }
+  if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab }
+  if (!all.includes(tab)) return { all: [...all, tab], active: tab }
+  return { all, active: tab }
+}
+
 export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
   name: "Layout",
   init: () => {
@@ -116,11 +127,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         if (!isRecord(fileTree)) return fileTree
         if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
 
-        const width = typeof fileTree.width === "number" ? fileTree.width : 344
+        const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_PANEL_WIDTH
         return {
           ...fileTree,
           opened: true,
-          width: width === 260 ? 344 : width,
+          width: width === 260 ? DEFAULT_PANEL_WIDTH : width,
           tab: "changes",
         }
       })()
@@ -151,12 +162,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       createStore({
         sidebar: {
           opened: false,
-          width: 344,
+          width: DEFAULT_PANEL_WIDTH,
           workspaces: {} as Record<string, boolean>,
           workspacesDefault: false,
         },
         terminal: {
-          height: 280,
+          height: DEFAULT_TERMINAL_HEIGHT,
           opened: false,
         },
         review: {
@@ -165,11 +176,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         },
         fileTree: {
           opened: true,
-          width: 344,
+          width: DEFAULT_PANEL_WIDTH,
           tab: "changes" as "changes" | "all",
         },
         session: {
-          width: 600,
+          width: DEFAULT_SESSION_WIDTH,
         },
         mobileSidebar: {
           opened: false,
@@ -184,8 +195,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
 
     const MAX_SESSION_KEYS = 50
     const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
-    const meta = { active: undefined as string | undefined, pruned: false }
-    const used = new Map<string, number>()
+    const usage = {
+      active: undefined as string | undefined,
+      pruned: false,
+      used: new Map<string, number>(),
+    }
 
     const SESSION_STATE_KEYS = [
       { key: "prompt", legacy: "prompt", version: "v2" },
@@ -214,7 +228,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       const drop = pruneSessionKeys({
         keep,
         max: MAX_SESSION_KEYS,
-        used,
+        used: usage.used,
         view: Object.keys(store.sessionView),
         tabs: Object.keys(store.sessionTabs),
       })
@@ -233,18 +247,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       dropSessionState(drop)
 
       for (const key of drop) {
-        used.delete(key)
+        usage.used.delete(key)
       }
     }
 
     function touch(sessionKey: string) {
-      meta.active = sessionKey
-      used.set(sessionKey, Date.now())
+      usage.active = sessionKey
+      usage.used.set(sessionKey, Date.now())
 
       if (!ready()) return
-      if (meta.pruned) return
+      if (usage.pruned) return
 
-      meta.pruned = true
+      usage.pruned = true
       prune(sessionKey)
     }
 
@@ -253,7 +267,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
       onFlush: (sessionKey, next) => {
         const current = store.sessionView[sessionKey]
-        const keep = meta.active ?? sessionKey
+        const keep = usage.active ?? sessionKey
         if (!current) {
           setStore("sessionView", sessionKey, { scroll: next })
           prune(keep)
@@ -269,10 +283,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
 
     createEffect(() => {
       if (!ready()) return
-      if (meta.pruned) return
-      const active = meta.active
+      if (usage.pruned) return
+      const active = usage.active
       if (!active) return
-      meta.pruned = true
+      usage.pruned = true
       prune(active)
     })
 
@@ -546,32 +560,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       },
       fileTree: {
         opened: createMemo(() => store.fileTree?.opened ?? true),
-        width: createMemo(() => store.fileTree?.width ?? 344),
+        width: createMemo(() => store.fileTree?.width ?? DEFAULT_PANEL_WIDTH),
         tab: createMemo(() => store.fileTree?.tab ?? "changes"),
         setTab(tab: "changes" | "all") {
           if (!store.fileTree) {
-            setStore("fileTree", { opened: true, width: 344, tab })
+            setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab })
             return
           }
           setStore("fileTree", "tab", tab)
         },
         open() {
           if (!store.fileTree) {
-            setStore("fileTree", { opened: true, width: 344, tab: "changes" })
+            setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
             return
           }
           setStore("fileTree", "opened", true)
         },
         close() {
           if (!store.fileTree) {
-            setStore("fileTree", { opened: false, width: 344, tab: "changes" })
+            setStore("fileTree", { opened: false, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
             return
           }
           setStore("fileTree", "opened", false)
         },
         toggle() {
           if (!store.fileTree) {
-            setStore("fileTree", { opened: true, width: 344, tab: "changes" })
+            setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
             return
           }
           setStore("fileTree", "opened", (x) => !x)
@@ -585,7 +599,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         },
       },
       session: {
-        width: createMemo(() => store.session?.width ?? 600),
+        width: createMemo(() => store.session?.width ?? DEFAULT_SESSION_WIDTH),
         resize(width: number) {
           if (!store.session) {
             setStore("session", { width })
@@ -617,7 +631,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
               pendingMessage: messageID,
               pendingMessageAt: at,
             })
-            prune(meta.active ?? sessionKey)
+            prune(usage.active ?? sessionKey)
             return
           }
 
@@ -658,7 +672,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         function setTerminalOpened(next: boolean) {
           const current = store.terminal
           if (!current) {
-            setStore("terminal", { height: 280, opened: next })
+            setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next })
             return
           }
 
@@ -755,43 +769,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           },
           async open(tab: string) {
             const session = key()
-            const current = store.sessionTabs[session] ?? { all: [] }
-
-            if (tab === "review") {
-              if (!store.sessionTabs[session]) {
-                setStore("sessionTabs", session, { all: current.all.filter((x) => x !== "review"), active: tab })
-                return
-              }
-              setStore("sessionTabs", session, "active", tab)
-              return
-            }
-
-            if (tab === "context") {
-              const all = [tab, ...current.all.filter((x) => x !== tab)]
-              if (!store.sessionTabs[session]) {
-                setStore("sessionTabs", session, { all, active: tab })
-                return
-              }
-              setStore("sessionTabs", session, "all", all)
-              setStore("sessionTabs", session, "active", tab)
-              return
-            }
-
-            if (!current.all.includes(tab)) {
-              if (!store.sessionTabs[session]) {
-                setStore("sessionTabs", session, { all: [tab], active: tab })
-                return
-              }
-              setStore("sessionTabs", session, "all", [...current.all, tab])
-              setStore("sessionTabs", session, "active", tab)
-              return
-            }
-
-            if (!store.sessionTabs[session]) {
-              setStore("sessionTabs", session, { all: current.all, active: tab })
-              return
-            }
-            setStore("sessionTabs", session, "active", tab)
+            const next = nextSessionTabsForOpen(store.sessionTabs[session], tab)
+            setStore("sessionTabs", session, next)
           },
           close(tab: string) {
             const session = key()

+ 36 - 36
packages/app/src/context/local.tsx

@@ -16,16 +16,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     const sdk = useSDK()
     const sync = useSync()
     const providers = useProviders()
+    const connected = createMemo(() => new Set(providers.connected().map((provider) => provider.id)))
 
     function isModelValid(model: ModelKey) {
       const provider = providers.all().find((x) => x.id === model.providerID)
-      return (
-        !!provider?.models[model.modelID] &&
-        providers
-          .connected()
-          .map((p) => p.id)
-          .includes(model.providerID)
-      )
+      return !!provider?.models[model.modelID] && connected().has(model.providerID)
     }
 
     function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
@@ -36,6 +31,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       }
     }
 
+    let setModel: (model: ModelKey | undefined, options?: { recent?: boolean }) => void = () => undefined
+
     const agent = (() => {
       const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
       const [store, setStore] = createStore<{
@@ -75,7 +72,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           if (!value) return
           setStore("current", value.name)
           if (value.model)
-            model.set({
+            setModel({
               providerID: value.model.providerID,
               modelID: value.model.modelID,
             })
@@ -92,38 +89,37 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
         model: {},
       })
 
-      const fallbackModel = createMemo<ModelKey | undefined>(() => {
-        if (sync.data.config.model) {
-          const [providerID, modelID] = sync.data.config.model.split("/")
-          if (isModelValid({ providerID, modelID })) {
-            return {
-              providerID,
-              modelID,
-            }
-          }
-        }
+      const resolveConfigured = () => {
+        if (!sync.data.config.model) return
+        const [providerID, modelID] = sync.data.config.model.split("/")
+        const key = { providerID, modelID }
+        if (isModelValid(key)) return key
+      }
 
+      const resolveRecent = () => {
         for (const item of models.recent.list()) {
-          if (isModelValid(item)) {
-            return item
-          }
+          if (isModelValid(item)) return item
         }
+      }
 
+      const resolveDefault = () => {
         const defaults = providers.default()
-        for (const p of providers.connected()) {
-          const configured = defaults[p.id]
+        for (const provider of providers.connected()) {
+          const configured = defaults[provider.id]
           if (configured) {
-            const key = { providerID: p.id, modelID: configured }
+            const key = { providerID: provider.id, modelID: configured }
             if (isModelValid(key)) return key
           }
 
-          const first = Object.values(p.models)[0]
+          const first = Object.values(provider.models)[0]
           if (!first) continue
-          const key = { providerID: p.id, modelID: first.id }
+          const key = { providerID: provider.id, modelID: first.id }
           if (isModelValid(key)) return key
         }
+      }
 
-        return undefined
+      const fallbackModel = createMemo<ModelKey | undefined>(() => {
+        return resolveConfigured() ?? resolveRecent() ?? resolveDefault()
       })
 
       const current = createMemo(() => {
@@ -163,21 +159,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
         })
       }
 
+      const set = (model: ModelKey | undefined, options?: { recent?: boolean }) => {
+        batch(() => {
+          const currentAgent = agent.current()
+          const next = model ?? fallbackModel()
+          if (currentAgent) setEphemeral("model", currentAgent.name, next)
+          if (model) models.setVisibility(model, true)
+          if (options?.recent && model) models.recent.push(model)
+        })
+      }
+
+      setModel = set
+
       return {
         ready: models.ready,
         current,
         recent,
         list: models.list,
         cycle,
-        set(model: ModelKey | undefined, options?: { recent?: boolean }) {
-          batch(() => {
-            const currentAgent = agent.current()
-            const next = model ?? fallbackModel()
-            if (currentAgent) setEphemeral("model", currentAgent.name, next)
-            if (model) models.setVisibility(model, true)
-            if (options?.recent && model) models.recent.push(model)
-          })
-        },
+        set,
         visible(model: ModelKey) {
           return models.visible(model)
         },

+ 31 - 8
packages/app/src/context/models.tsx

@@ -16,6 +16,12 @@ type Store = {
   variant?: Record<string, string | undefined>
 }
 
+const RECENT_LIMIT = 5
+
+function modelKey(model: ModelKey) {
+  return `${model.providerID}:${model.modelID}`
+}
+
 export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
   name: "Models",
   init: () => {
@@ -39,10 +45,27 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
       ),
     )
 
+    const release = createMemo(
+      () =>
+        new Map(
+          available().map((model) => {
+            const parsed = DateTime.fromISO(model.release_date)
+            return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
+          }),
+        ),
+    )
+
     const latest = createMemo(() =>
       pipe(
         available(),
-        filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6),
+        filter(
+          (x) =>
+            Math.abs(
+              (release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
+                .diffNow()
+                .as("months"),
+            ) < 6,
+        ),
         groupBy((x) => x.provider.id),
         mapValues((models) =>
           pipe(
@@ -61,7 +84,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
       ),
     )
 
-    const latestSet = createMemo(() => new Set(latest().map((x) => `${x.providerID}:${x.modelID}`)))
+    const latestSet = createMemo(() => new Set(latest().map((x) => modelKey(x))))
 
     const visibility = createMemo(() => {
       const map = new Map<string, Visibility>()
@@ -82,20 +105,20 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
     function update(model: ModelKey, state: Visibility) {
       const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
       if (index >= 0) {
-        setStore("user", index, { visibility: state })
+        setStore("user", index, (current) => ({ ...current, visibility: state }))
         return
       }
       setStore("user", store.user.length, { ...model, visibility: state })
     }
 
     const visible = (model: ModelKey) => {
-      const key = `${model.providerID}:${model.modelID}`
+      const key = modelKey(model)
       const state = visibility().get(key)
       if (state === "hide") return false
       if (state === "show") return true
       if (latestSet().has(key)) return true
-      const m = find(model)
-      if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true
+      const date = release().get(key)
+      if (!date?.isValid) return true
       return false
     }
 
@@ -104,8 +127,8 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
     }
 
     const push = (model: ModelKey) => {
-      const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
-      if (uniq.length > 5) uniq.pop()
+      const uniq = uniqueBy([model, ...store.recent], (x) => `${x.providerID}:${x.modelID}`)
+      if (uniq.length > RECENT_LIMIT) uniq.pop()
       setStore("recent", uniq)
     }
 

+ 73 - 69
packages/app/src/context/notification.tsx

@@ -18,7 +18,7 @@ import { buildNotificationIndex } from "./notification-index"
 type NotificationBase = {
   directory?: string
   session?: string
-  metadata?: any
+  metadata?: unknown
   time: number
   viewed: boolean
 }
@@ -84,89 +84,93 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
 
     const index = createMemo(() => buildNotificationIndex(store.list))
 
-    const lookup = (directory: string, sessionID?: string) => {
-      if (!sessionID) return Promise.resolve(undefined)
+    const lookup = async (directory: string, sessionID?: string) => {
+      if (!sessionID) return undefined
       const [syncStore] = globalSync.child(directory, { bootstrap: false })
       const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
-      if (match.found) return Promise.resolve(syncStore.session[match.index])
+      if (match.found) return syncStore.session[match.index]
       return globalSDK.client.session
         .get({ directory, sessionID })
         .then((x) => x.data)
         .catch(() => undefined)
     }
 
+    const viewedInCurrentSession = (directory: string, sessionID?: string) => {
+      const activeDirectory = currentDirectory()
+      const activeSession = currentSession()
+      if (!activeDirectory) return false
+      if (!activeSession) return false
+      if (!sessionID) return false
+      if (directory !== activeDirectory) return false
+      return sessionID === activeSession
+    }
+
+    const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
+      const sessionID = event.properties.sessionID
+      void lookup(directory, sessionID).then((session) => {
+        if (meta.disposed) return
+        if (!session) return
+        if (session.parentID) return
+
+        playSound(soundSrc(settings.sounds.agent()))
+
+        append({
+          directory,
+          time,
+          viewed: viewedInCurrentSession(directory, sessionID),
+          type: "turn-complete",
+          session: sessionID,
+        })
+
+        const href = `/${base64Encode(directory)}/session/${sessionID}`
+        if (settings.notifications.agent()) {
+          void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
+        }
+      })
+    }
+
+    const handleSessionError = (
+      directory: string,
+      event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
+      time: number,
+    ) => {
+      const sessionID = event.properties.sessionID
+      void lookup(directory, sessionID).then((session) => {
+        if (meta.disposed) return
+        if (session?.parentID) return
+
+        playSound(soundSrc(settings.sounds.errors()))
+
+        const error = "error" in event.properties ? event.properties.error : undefined
+        append({
+          directory,
+          time,
+          viewed: viewedInCurrentSession(directory, sessionID),
+          type: "error",
+          session: sessionID ?? "global",
+          error,
+        })
+        const description =
+          session?.title ??
+          (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
+        const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
+        if (settings.notifications.errors()) {
+          void platform.notify(language.t("notification.session.error.title"), description, href)
+        }
+      })
+    }
+
     const unsub = globalSDK.event.listen((e) => {
       const event = e.details
       if (event.type !== "session.idle" && event.type !== "session.error") return
 
       const directory = e.name
       const time = Date.now()
-      const viewed = (sessionID?: string) => {
-        const activeDirectory = currentDirectory()
-        const activeSession = currentSession()
-        if (!activeDirectory) return false
-        if (!activeSession) return false
-        if (!sessionID) return false
-        if (directory !== activeDirectory) return false
-        return sessionID === activeSession
-      }
-      switch (event.type) {
-        case "session.idle": {
-          const sessionID = event.properties.sessionID
-          void lookup(directory, sessionID).then((session) => {
-            if (meta.disposed) return
-            if (!session) return
-            if (session.parentID) return
-
-            playSound(soundSrc(settings.sounds.agent()))
-
-            append({
-              directory,
-              time,
-              viewed: viewed(sessionID),
-              type: "turn-complete",
-              session: sessionID,
-            })
-
-            const href = `/${base64Encode(directory)}/session/${sessionID}`
-            if (settings.notifications.agent()) {
-              void platform.notify(
-                language.t("notification.session.responseReady.title"),
-                session.title ?? sessionID,
-                href,
-              )
-            }
-          })
-          break
-        }
-        case "session.error": {
-          const sessionID = event.properties.sessionID
-          void lookup(directory, sessionID).then((session) => {
-            if (meta.disposed) return
-            if (session?.parentID) return
-
-            playSound(soundSrc(settings.sounds.errors()))
-
-            const error = "error" in event.properties ? event.properties.error : undefined
-            append({
-              directory,
-              time,
-              viewed: viewed(sessionID),
-              type: "error",
-              session: sessionID ?? "global",
-              error,
-            })
-            const description =
-              session?.title ??
-              (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
-            const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
-            if (settings.notifications.errors()) {
-              void platform.notify(language.t("notification.session.error.title"), description, href)
-            }
-          })
-          break
-        }
+      if (event.type === "session.idle") {
+        handleSessionIdle(directory, event, time)
+        return
       }
+      handleSessionError(directory, event, time)
     })
     onCleanup(() => {
       meta.disposed = true

+ 14 - 2
packages/app/src/context/permission.tsx

@@ -33,7 +33,7 @@ function isNonAllowRule(rule: unknown) {
   return false
 }
 
-function hasAutoAcceptPermissionConfig(permission: unknown) {
+function hasPermissionPromptRules(permission: unknown) {
   if (!permission) return false
   if (typeof permission === "string") return permission !== "allow"
   if (typeof permission !== "object") return false
@@ -57,7 +57,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
       const directory = decode64(params.dir)
       if (!directory) return false
       const [store] = globalSync.child(directory)
-      return hasAutoAcceptPermissionConfig(store.config.permission)
+      return hasPermissionPromptRules(store.config.permission)
     })
 
     const [store, setStore, _, ready] = persisted(
@@ -70,6 +70,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
     const MAX_RESPONDED = 1000
     const RESPONDED_TTL_MS = 60 * 60 * 1000
     const responded = new Map<string, number>()
+    const enableVersion = new Map<string, number>()
 
     function pruneResponded(now: number) {
       for (const [id, ts] of responded) {
@@ -114,6 +115,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
       return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false
     }
 
+    function bumpEnableVersion(sessionID: string, directory?: string) {
+      const key = acceptKey(sessionID, directory)
+      const next = (enableVersion.get(key) ?? 0) + 1
+      enableVersion.set(key, next)
+      return next
+    }
+
     const unsubscribe = globalSDK.event.listen((e) => {
       const event = e.details
       if (event?.type !== "permission.asked") return
@@ -128,6 +136,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
 
     function enable(sessionID: string, directory: string) {
       const key = acceptKey(sessionID, directory)
+      const version = bumpEnableVersion(sessionID, directory)
       setStore(
         produce((draft) => {
           draft.autoAcceptEdits[key] = true
@@ -138,6 +147,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
       globalSDK.client.permission
         .list({ directory })
         .then((x) => {
+          if (enableVersion.get(key) !== version) return
+          if (!isAutoAccepting(sessionID, directory)) return
           for (const perm of x.data ?? []) {
             if (!perm?.id) continue
             if (perm.sessionID !== sessionID) continue
@@ -149,6 +160,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
     }
 
     function disable(sessionID: string, directory?: string) {
+      bumpEnableVersion(sessionID, directory)
       const key = directory ? acceptKey(sessionID, directory) : undefined
       setStore(
         produce((draft) => {

+ 10 - 4
packages/app/src/context/platform.tsx

@@ -2,6 +2,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
 import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
 import type { Accessor } from "solid-js"
 
+type PickerPaths = string | string[] | null
+type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
+type OpenFilePickerOptions = { title?: string; multiple?: boolean }
+type SaveFilePickerOptions = { title?: string; defaultPath?: string }
+type UpdateInfo = { updateAvailable: boolean; version?: string }
+
 export type Platform = {
   /** Platform discriminator */
   platform: "web" | "desktop"
@@ -31,19 +37,19 @@ export type Platform = {
   notify(title: string, description?: string, href?: string): Promise<void>
 
   /** Open directory picker dialog (native on Tauri, server-backed on web) */
-  openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
+  openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
 
   /** Open native file picker dialog (Tauri only) */
-  openFilePickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
+  openFilePickerDialog?(opts?: OpenFilePickerOptions): Promise<PickerPaths>
 
   /** Save file picker dialog (Tauri only) */
-  saveFilePickerDialog?(opts?: { title?: string; defaultPath?: string }): Promise<string | null>
+  saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
 
   /** Storage mechanism, defaults to localStorage */
   storage?: (name?: string) => SyncStorage | AsyncStorage
 
   /** Check for updates (Tauri only) */
-  checkUpdate?(): Promise<{ updateAvailable: boolean; version?: string }>
+  checkUpdate?(): Promise<UpdateInfo>
 
   /** Install updates (Tauri only) */
   update?(): Promise<void>

+ 61 - 48
packages/app/src/context/prompt.tsx

@@ -1,4 +1,4 @@
-import { createStore } from "solid-js/store"
+import { createStore, type SetStoreFunction } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { batch, createMemo, createRoot, onCleanup } from "solid-js"
 import { useParams } from "@solidjs/router"
@@ -60,27 +60,23 @@ function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
   )
 }
 
+function isPartEqual(partA: ContentPart, partB: ContentPart) {
+  switch (partA.type) {
+    case "text":
+      return partB.type === "text" && partA.content === partB.content
+    case "file":
+      return partB.type === "file" && partA.path === partB.path && isSelectionEqual(partA.selection, partB.selection)
+    case "agent":
+      return partB.type === "agent" && partA.name === partB.name
+    case "image":
+      return partB.type === "image" && partA.id === partB.id
+  }
+}
+
 export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
   if (promptA.length !== promptB.length) return false
   for (let i = 0; i < promptA.length; i++) {
-    const partA = promptA[i]
-    const partB = promptB[i]
-    if (partA.type !== partB.type) return false
-    if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
-      return false
-    }
-    if (partA.type === "file") {
-      const fileA = partA as FileAttachmentPart
-      const fileB = partB as FileAttachmentPart
-      if (fileA.path !== fileB.path) return false
-      if (!isSelectionEqual(fileA.selection, fileB.selection)) return false
-    }
-    if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) {
-      return false
-    }
-    if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
-      return false
-    }
+    if (!isPartEqual(promptA[i], promptB[i])) return false
   }
   return true
 }
@@ -104,6 +100,48 @@ function clonePrompt(prompt: Prompt): Prompt {
   return prompt.map(clonePart)
 }
 
+function contextItemKey(item: ContextItem) {
+  if (item.type !== "file") return item.type
+  const start = item.selection?.startLine
+  const end = item.selection?.endLine
+  const key = `${item.type}:${item.path}:${start}:${end}`
+
+  if (item.commentID) {
+    return `${key}:c=${item.commentID}`
+  }
+
+  const comment = item.comment?.trim()
+  if (!comment) return key
+  const digest = checksum(comment) ?? comment
+  return `${key}:c=${digest.slice(0, 8)}`
+}
+
+function createPromptActions(
+  setStore: SetStoreFunction<{
+    prompt: Prompt
+    cursor?: number
+    context: {
+      items: (ContextItem & { key: string })[]
+    }
+  }>,
+) {
+  return {
+    set(prompt: Prompt, cursorPosition?: number) {
+      const next = clonePrompt(prompt)
+      batch(() => {
+        setStore("prompt", next)
+        if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
+      })
+    },
+    reset() {
+      batch(() => {
+        setStore("prompt", clonePrompt(DEFAULT_PROMPT))
+        setStore("cursor", 0)
+      })
+    },
+  }
+}
+
 const WORKSPACE_KEY = "__workspace__"
 const MAX_PROMPT_SESSIONS = 20
 
@@ -134,21 +172,7 @@ function createPromptSession(dir: string, id: string | undefined) {
     }),
   )
 
-  function keyForItem(item: ContextItem) {
-    if (item.type !== "file") return item.type
-    const start = item.selection?.startLine
-    const end = item.selection?.endLine
-    const key = `${item.type}:${item.path}:${start}:${end}`
-
-    if (item.commentID) {
-      return `${key}:c=${item.commentID}`
-    }
-
-    const comment = item.comment?.trim()
-    if (!comment) return key
-    const digest = checksum(comment) ?? comment
-    return `${key}:c=${digest.slice(0, 8)}`
-  }
+  const actions = createPromptActions(setStore)
 
   return {
     ready,
@@ -158,7 +182,7 @@ function createPromptSession(dir: string, id: string | undefined) {
     context: {
       items: createMemo(() => store.context.items),
       add(item: ContextItem) {
-        const key = keyForItem(item)
+        const key = contextItemKey(item)
         if (store.context.items.find((x) => x.key === key)) return
         setStore("context", "items", (items) => [...items, { key, ...item }])
       },
@@ -166,19 +190,8 @@ function createPromptSession(dir: string, id: string | undefined) {
         setStore("context", "items", (items) => items.filter((x) => x.key !== key))
       },
     },
-    set(prompt: Prompt, cursorPosition?: number) {
-      const next = clonePrompt(prompt)
-      batch(() => {
-        setStore("prompt", next)
-        if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
-      })
-    },
-    reset() {
-      batch(() => {
-        setStore("prompt", clonePrompt(DEFAULT_PROMPT))
-        setStore("cursor", 0)
-      })
-    },
+    set: actions.set,
+    reset: actions.reset,
   }
 }
 

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

@@ -5,6 +5,10 @@ import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
 import { useGlobalSDK } from "./global-sdk"
 import { usePlatform } from "./platform"
 
+type SDKEventMap = {
+  [key in Event["type"]]: Extract<Event, { type: key }>
+}
+
 export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
   name: "SDK",
   init: (props: { directory: Accessor<string> }) => {
@@ -21,9 +25,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
       }),
     )
 
-    const emitter = createGlobalEmitter<{
-      [key in Event["type"]]: Extract<Event, { type: key }>
-    }>()
+    const emitter = createGlobalEmitter<SDKEventMap>()
 
     createEffect(() => {
       const unsub = globalSDK.event.on(directory(), (event) => {

+ 69 - 62
packages/app/src/context/server.tsx

@@ -6,6 +6,7 @@ import { Persist, persisted } from "@/utils/persist"
 import { checkServerHealth } from "@/utils/server-health"
 
 type StoredProject = { worktree: string; expanded: boolean }
+const HEALTH_POLL_INTERVAL_MS = 10_000
 
 export function normalizeServerUrl(input: string) {
   const trimmed = input.trim()
@@ -48,81 +49,51 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
 
     const healthy = () => state.healthy
 
-    function setActive(input: string) {
-      const url = normalizeServerUrl(input)
-      if (!url) return
-      setState("active", url)
-    }
+    const defaultUrl = () => normalizeServerUrl(props.defaultUrl)
 
-    function add(input: string) {
-      const url = normalizeServerUrl(input)
-      if (!url) return
+    function reconcileStartup() {
+      const fallback = defaultUrl()
+      if (!fallback) return
 
-      const fallback = normalizeServerUrl(props.defaultUrl)
-      if (fallback && url === fallback) {
+      const previousSidecarUrl = normalizeServerUrl(store.currentSidecarUrl)
+      const list = previousSidecarUrl ? store.list.filter((url) => url !== previousSidecarUrl) : store.list
+      if (!props.isSidecar) {
         batch(() => {
-          if (!store.list.includes(url)) {
-            // Add the fallback url to the list if it's not already in the list
-            setStore("list", store.list.length, url)
-          }
-          setState("active", url)
+          setStore("list", list)
+          if (store.currentSidecarUrl) setStore("currentSidecarUrl", "")
+          setState("active", fallback)
         })
         return
       }
 
+      const nextList = list.includes(fallback) ? list : [...list, fallback]
       batch(() => {
-        if (!store.list.includes(url)) {
-          setStore("list", store.list.length, url)
-        }
-        setState("active", url)
+        setStore("list", nextList)
+        setStore("currentSidecarUrl", fallback)
+        setState("active", fallback)
       })
     }
 
-    function remove(input: string) {
-      const url = normalizeServerUrl(input)
-      if (!url) return
-
-      const list = store.list.filter((x) => x !== url)
-      const next = state.active === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : state.active
-
-      batch(() => {
-        setStore("list", list)
-        setState("active", next)
-      })
-    }
+    function updateServerList(url: string, remove = false) {
+      if (remove) {
+        const list = store.list.filter((x) => x !== url)
+        const next = state.active === url ? (list[0] ?? defaultUrl() ?? "") : state.active
+        batch(() => {
+          setStore("list", list)
+          setState("active", next)
+        })
+        return
+      }
 
-    createEffect(() => {
-      if (!ready()) return
-      if (state.active) return
-      const url = normalizeServerUrl(props.defaultUrl)
-      if (!url) return
       batch(() => {
-        // Remove the previous startup sidecar url
-        if (store.currentSidecarUrl) {
-          remove(store.currentSidecarUrl)
-        }
-
-        // Add the new sidecar url
-        if (props.isSidecar && props.defaultUrl) {
-          add(props.defaultUrl)
-          setStore("currentSidecarUrl", props.defaultUrl)
+        if (!store.list.includes(url)) {
+          setStore("list", store.list.length, url)
         }
-
         setState("active", url)
       })
-    })
-
-    const isReady = createMemo(() => ready() && !!state.active)
-
-    const fetcher = platform.fetch ?? globalThis.fetch
-    const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
-
-    createEffect(() => {
-      const url = state.active
-      if (!url) return
-
-      setState("healthy", undefined)
+    }
 
+    function startHealthPolling(url: string) {
       let alive = true
       let busy = false
 
@@ -140,12 +111,48 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
       }
 
       run()
-      const interval = setInterval(run, 10_000)
-
-      onCleanup(() => {
+      const interval = setInterval(run, HEALTH_POLL_INTERVAL_MS)
+      return () => {
         alive = false
         clearInterval(interval)
-      })
+      }
+    }
+
+    function setActive(input: string) {
+      const url = normalizeServerUrl(input)
+      if (!url) return
+      setState("active", url)
+    }
+
+    function add(input: string) {
+      const url = normalizeServerUrl(input)
+      if (!url) return
+      updateServerList(url)
+    }
+
+    function remove(input: string) {
+      const url = normalizeServerUrl(input)
+      if (!url) return
+      updateServerList(url, true)
+    }
+
+    createEffect(() => {
+      if (!ready()) return
+      if (state.active) return
+      reconcileStartup()
+    })
+
+    const isReady = createMemo(() => ready() && !!state.active)
+
+    const fetcher = platform.fetch ?? globalThis.fetch
+    const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
+
+    createEffect(() => {
+      const url = state.active
+      if (!url) return
+
+      setState("healthy", undefined)
+      onCleanup(startHealthPolling(url))
     })
 
     const origin = createMemo(() => projectsKey(state.active))

+ 22 - 13
packages/app/src/context/settings.tsx

@@ -85,6 +85,10 @@ 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)
+}
+
 export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
   name: "Settings",
   init: () => {
@@ -101,27 +105,27 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
         return store
       },
       general: {
-        autoSave: createMemo(() => store.general?.autoSave ?? defaultSettings.general.autoSave),
+        autoSave: withFallback(() => store.general?.autoSave, defaultSettings.general.autoSave),
         setAutoSave(value: boolean) {
           setStore("general", "autoSave", value)
         },
-        releaseNotes: createMemo(() => store.general?.releaseNotes ?? defaultSettings.general.releaseNotes),
+        releaseNotes: withFallback(() => store.general?.releaseNotes, defaultSettings.general.releaseNotes),
         setReleaseNotes(value: boolean) {
           setStore("general", "releaseNotes", value)
         },
       },
       updates: {
-        startup: createMemo(() => store.updates?.startup ?? defaultSettings.updates.startup),
+        startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
         setStartup(value: boolean) {
           setStore("updates", "startup", value)
         },
       },
       appearance: {
-        fontSize: createMemo(() => store.appearance?.fontSize ?? defaultSettings.appearance.fontSize),
+        fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
         setFontSize(value: number) {
           setStore("appearance", "fontSize", value)
         },
-        font: createMemo(() => store.appearance?.font ?? defaultSettings.appearance.font),
+        font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font),
         setFont(value: string) {
           setStore("appearance", "font", value)
         },
@@ -132,42 +136,47 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
           setStore("keybinds", action, keybind)
         },
         reset(action: string) {
-          setStore("keybinds", action, undefined!)
+          setStore("keybinds", (current) => {
+            if (!Object.prototype.hasOwnProperty.call(current, action)) return current
+            const next = { ...current }
+            delete next[action]
+            return next
+          })
         },
         resetAll() {
           setStore("keybinds", reconcile({}))
         },
       },
       permissions: {
-        autoApprove: createMemo(() => store.permissions?.autoApprove ?? defaultSettings.permissions.autoApprove),
+        autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove),
         setAutoApprove(value: boolean) {
           setStore("permissions", "autoApprove", value)
         },
       },
       notifications: {
-        agent: createMemo(() => store.notifications?.agent ?? defaultSettings.notifications.agent),
+        agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
         setAgent(value: boolean) {
           setStore("notifications", "agent", value)
         },
-        permissions: createMemo(() => store.notifications?.permissions ?? defaultSettings.notifications.permissions),
+        permissions: withFallback(() => store.notifications?.permissions, defaultSettings.notifications.permissions),
         setPermissions(value: boolean) {
           setStore("notifications", "permissions", value)
         },
-        errors: createMemo(() => store.notifications?.errors ?? defaultSettings.notifications.errors),
+        errors: withFallback(() => store.notifications?.errors, defaultSettings.notifications.errors),
         setErrors(value: boolean) {
           setStore("notifications", "errors", value)
         },
       },
       sounds: {
-        agent: createMemo(() => store.sounds?.agent ?? defaultSettings.sounds.agent),
+        agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent),
         setAgent(value: string) {
           setStore("sounds", "agent", value)
         },
-        permissions: createMemo(() => store.sounds?.permissions ?? defaultSettings.sounds.permissions),
+        permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions),
         setPermissions(value: string) {
           setStore("sounds", "permissions", value)
         },
-        errors: createMemo(() => store.sounds?.errors ?? defaultSettings.sounds.errors),
+        errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors),
         setErrors(value: string) {
           setStore("sounds", "errors", value)
         },

+ 88 - 81
packages/app/src/context/sync.tsx

@@ -7,6 +7,20 @@ import { useGlobalSync } from "./global-sync"
 import { useSDK } from "./sdk"
 import type { Message, Part } from "@opencode-ai/sdk/v2/client"
 
+function sortParts(parts: Part[]) {
+  return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
+}
+
+function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
+  const pending = map.get(key)
+  if (pending) return pending
+  const promise = task().finally(() => {
+    map.delete(key)
+  })
+  map.set(key, promise)
+  return promise
+}
+
 const keyFor = (directory: string, id: string) => `${directory}\n${id}`
 
 const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
@@ -36,7 +50,7 @@ export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddI
     const result = Binary.search(messages, input.message.id, (m) => m.id)
     messages.splice(result.index, 0, input.message)
   }
-  draft.part[input.message.id] = input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
+  draft.part[input.message.id] = sortParts(input.parts)
 }
 
 export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
@@ -48,6 +62,34 @@ export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticR
   delete draft.part[input.messageID]
 }
 
+function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) {
+  setStore("message", input.sessionID, (messages: Message[] | undefined) => {
+    if (!messages) return [input.message]
+    const result = Binary.search(messages, input.message.id, (m) => m.id)
+    const next = [...messages]
+    next.splice(result.index, 0, input.message)
+    return next
+  })
+  setStore("part", input.message.id, sortParts(input.parts))
+}
+
+function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) {
+  setStore("message", input.sessionID, (messages: Message[] | undefined) => {
+    if (!messages) return messages
+    const result = Binary.search(messages, input.messageID, (m) => m.id)
+    if (!result.found) return messages
+    const next = [...messages]
+    next.splice(result.index, 1)
+    return next
+  })
+  setStore("part", (part: Record<string, Part[] | undefined>) => {
+    if (!(input.messageID in part)) return part
+    const next = { ...part }
+    delete next[input.messageID]
+    return next
+  })
+}
+
 export const { use: useSync, provider: SyncProvider } = createSimpleContext({
   name: "Sync",
   init: () => {
@@ -63,7 +105,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       return globalSync.child(directory)
     }
     const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
-    const chunk = 400
+    const messagePageSize = 400
     const inflight = new Map<string, Promise<void>>()
     const inflightDiff = new Map<string, Promise<void>>()
     const inflightTodo = new Map<string, Promise<void>>()
@@ -81,8 +123,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
     }
 
     const limitFor = (count: number) => {
-      if (count <= chunk) return chunk
-      return Math.ceil(count / chunk) * chunk
+      if (count <= messagePageSize) return messagePageSize
+      return Math.ceil(count / messagePageSize) * messagePageSize
+    }
+
+    const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
+      const messages = await retry(() =>
+        input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
+      )
+      const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
+      const session = items
+        .map((x) => x.info)
+        .filter((m) => !!m?.id)
+        .sort((a, b) => cmp(a.id, b.id))
+      const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
+      return {
+        session,
+        part,
+        complete: session.length < input.limit,
+      }
     }
 
     const loadMessages = async (input: {
@@ -96,30 +155,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       if (meta.loading[key]) return
 
       setMeta("loading", key, true)
-      await retry(() => input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }))
-        .then((messages) => {
-          const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
-          const next = items
-            .map((x) => x.info)
-            .filter((m) => !!m?.id)
-            .sort((a, b) => cmp(a.id, b.id))
-
+      await fetchMessages(input)
+        .then((next) => {
           batch(() => {
-            input.setStore("message", input.sessionID, reconcile(next, { key: "id" }))
-
-            for (const message of items) {
-              input.setStore(
-                "part",
-                message.info.id,
-                reconcile(
-                  message.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
-                  { key: "id" },
-                ),
-              )
+            input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
+            for (const message of next.part) {
+              input.setStore("part", message.id, reconcile(message.part, { key: "id" }))
             }
-
             setMeta("limit", key, input.limit)
-            setMeta("complete", key, next.length < input.limit)
+            setMeta("complete", key, next.complete)
           })
         })
         .finally(() => {
@@ -151,19 +195,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         optimistic: {
           add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
             const [, setStore] = target(input.directory)
-            setStore(
-              produce((draft) => {
-                applyOptimisticAdd(draft as OptimisticStore, input)
-              }),
-            )
+            setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
           },
           remove(input: { directory?: string; sessionID: string; messageID: string }) {
             const [, setStore] = target(input.directory)
-            setStore(
-              produce((draft) => {
-                applyOptimisticRemove(draft as OptimisticStore, input)
-              }),
-            )
+            setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
           },
         },
         addOptimisticMessage(input: {
@@ -182,15 +218,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             model: input.model,
           }
           const [, setStore] = target()
-          setStore(
-            produce((draft) => {
-              applyOptimisticAdd(draft as OptimisticStore, {
-                sessionID: input.sessionID,
-                message,
-                parts: input.parts,
-              })
-            }),
-          )
+          setOptimisticAdd(setStore as (...args: unknown[]) => void, {
+            sessionID: input.sessionID,
+            message,
+            parts: input.parts,
+          })
         },
         async sync(sessionID: string) {
           const directory = sdk.directory
@@ -205,11 +237,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           const hasMessages = store.message[sessionID] !== undefined
           const hydrated = meta.limit[key] !== undefined
           if (hasSession && hasMessages && hydrated) return
-          const pending = inflight.get(key)
-          if (pending) return pending
 
           const count = store.message[sessionID]?.length ?? 0
-          const limit = hydrated ? (meta.limit[key] ?? chunk) : limitFor(count)
+          const limit = hydrated ? (meta.limit[key] ?? messagePageSize) : limitFor(count)
 
           const sessionReq = hasSession
             ? Promise.resolve()
@@ -240,14 +270,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
                   limit,
                 })
 
-          const promise = Promise.all([sessionReq, messagesReq])
-            .then(() => {})
-            .finally(() => {
-              inflight.delete(key)
-            })
-
-          inflight.set(key, promise)
-          return promise
+          return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
         },
         async diff(sessionID: string) {
           const directory = sdk.directory
@@ -256,19 +279,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           if (store.session_diff[sessionID] !== undefined) return
 
           const key = keyFor(directory, sessionID)
-          const pending = inflightDiff.get(key)
-          if (pending) return pending
-
-          const promise = retry(() => client.session.diff({ sessionID }))
-            .then((diff) => {
+          return runInflight(inflightDiff, key, () =>
+            retry(() => client.session.diff({ sessionID })).then((diff) => {
               setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
-            })
-            .finally(() => {
-              inflightDiff.delete(key)
-            })
-
-          inflightDiff.set(key, promise)
-          return promise
+            }),
+          )
         },
         async todo(sessionID: string) {
           const directory = sdk.directory
@@ -277,19 +292,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           if (store.todo[sessionID] !== undefined) return
 
           const key = keyFor(directory, sessionID)
-          const pending = inflightTodo.get(key)
-          if (pending) return pending
-
-          const promise = retry(() => client.session.todo({ sessionID }))
-            .then((todo) => {
+          return runInflight(inflightTodo, key, () =>
+            retry(() => client.session.todo({ sessionID })).then((todo) => {
               setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
-            })
-            .finally(() => {
-              inflightTodo.delete(key)
-            })
-
-          inflightTodo.set(key, promise)
-          return promise
+            }),
+          )
         },
         history: {
           more(sessionID: string) {
@@ -304,7 +311,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             const key = keyFor(sdk.directory, sessionID)
             return meta.loading[key] ?? false
           },
-          async loadMore(sessionID: string, count = chunk) {
+          async loadMore(sessionID: string, count = messagePageSize) {
             const directory = sdk.directory
             const client = sdk.client
             const [, setStore] = globalSync.child(directory)
@@ -312,7 +319,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             if (meta.loading[key]) return
             if (meta.complete[key]) return
 
-            const currentLimit = meta.limit[key] ?? chunk
+            const currentLimit = meta.limit[key] ?? messagePageSize
             await loadMessages({
               directory,
               client,

+ 37 - 30
packages/app/src/context/terminal.tsx

@@ -79,19 +79,38 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
     }),
   )
 
-  const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => {
-    const id = event.properties.id
-    if (!store.all.some((x) => x.id === id)) return
+  const pickNextTerminalNumber = () => {
+    const existingTitleNumbers = new Set(
+      store.all.flatMap((pty) => {
+        const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
+        if (direct !== undefined) return [direct]
+        const parsed = numberFromTitle(pty.title)
+        if (parsed === undefined) return []
+        return [parsed]
+      }),
+    )
+
+    return (
+      Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find(
+        (number) => !existingTitleNumbers.has(number),
+      ) ?? 1
+    )
+  }
+
+  const removeExited = (id: string) => {
+    const all = store.all
+    const index = all.findIndex((x) => x.id === id)
+    if (index === -1) return
+    const filtered = all.filter((x) => x.id !== id)
+    const active = store.active === id ? filtered[0]?.id : store.active
     batch(() => {
-      setStore(
-        "all",
-        store.all.filter((x) => x.id !== id),
-      )
-      if (store.active === id) {
-        const remaining = store.all.filter((x) => x.id !== id)
-        setStore("active", remaining[0]?.id)
-      }
+      setStore("all", filtered)
+      setStore("active", active)
     })
+  }
+
+  const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => {
+    removeExited(event.properties.id)
   })
   onCleanup(unsub)
 
@@ -117,7 +136,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
 
   return {
     ready,
-    all: createMemo(() => Object.values(store.all)),
+    all: createMemo(() => store.all),
     active: createMemo(() => store.active),
     clear() {
       batch(() => {
@@ -126,20 +145,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
       })
     },
     new() {
-      const existingTitleNumbers = new Set(
-        store.all.flatMap((pty) => {
-          const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
-          if (direct !== undefined) return [direct]
-          const parsed = numberFromTitle(pty.title)
-          if (parsed === undefined) return []
-          return [parsed]
-        }),
-      )
-
-      const nextNumber =
-        Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find(
-          (number) => !existingTitleNumbers.has(number),
-        ) ?? 1
+      const nextNumber = pickNextTerminalNumber()
 
       sdk.client.pty
         .create({ title: `Terminal ${nextNumber}` })
@@ -162,10 +168,8 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
         })
     },
     update(pty: Partial<LocalPTY> & { id: string }) {
-      const index = store.all.findIndex((x) => x.id === pty.id)
-      if (index !== -1) {
-        setStore("all", index, (existing) => ({ ...existing, ...pty }))
-      }
+      const previous = store.all.find((x) => x.id === pty.id)
+      if (previous) setStore("all", (all) => all.map((item) => (item.id === pty.id ? { ...item, ...pty } : item)))
       sdk.client.pty
         .update({
           ptyID: pty.id,
@@ -173,6 +177,9 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
           size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
         })
         .catch((error: unknown) => {
+          if (previous) {
+            setStore("all", (all) => all.map((item) => (item.id === pty.id ? previous : item)))
+          }
           console.error("Failed to update terminal", error)
         })
     },

+ 105 - 85
packages/app/src/entry.tsx

@@ -8,97 +8,117 @@ import pkg from "../package.json"
 
 const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
 
-const root = document.getElementById("root")
-if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
-  const locale = (() => {
-    if (typeof navigator !== "object") return "en" as const
-    const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
-    for (const language of languages) {
-      if (!language) continue
-      if (language.toLowerCase().startsWith("zh")) return "zh" as const
-    }
-    return "en" as const
-  })()
+const getLocale = () => {
+  if (typeof navigator !== "object") return "en" as const
+  const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
+  for (const language of languages) {
+    if (!language) continue
+    if (language.toLowerCase().startsWith("zh")) return "zh" as const
+  }
+  return "en" as const
+}
 
+const getRootNotFoundError = () => {
   const key = "error.dev.rootNotFound" as const
-  const message = locale === "zh" ? (zh[key] ?? en[key]) : en[key]
-  throw new Error(message)
+  const locale = getLocale()
+  return locale === "zh" ? (zh[key] ?? en[key]) : en[key]
+}
+
+const getStorage = (key: string) => {
+  if (typeof localStorage === "undefined") return null
+  try {
+    return localStorage.getItem(key)
+  } catch {
+    return null
+  }
+}
+
+const setStorage = (key: string, value: string | null) => {
+  if (typeof localStorage === "undefined") return
+  try {
+    if (value !== null) {
+      localStorage.setItem(key, value)
+      return
+    }
+    localStorage.removeItem(key)
+  } catch {
+    return
+  }
+}
+
+const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY)
+const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url)
+
+const notify: Platform["notify"] = async (title, description, href) => {
+  if (!("Notification" in window)) return
+
+  const permission =
+    Notification.permission === "default"
+      ? await Notification.requestPermission().catch(() => "denied")
+      : Notification.permission
+
+  if (permission !== "granted") return
+
+  const inView = document.visibilityState === "visible" && document.hasFocus()
+  if (inView) return
+
+  const notification = new Notification(title, {
+    body: description ?? "",
+    icon: "https://opencode.ai/favicon-96x96-v3.png",
+  })
+
+  notification.onclick = () => {
+    window.focus()
+    if (href) {
+      window.history.pushState(null, "", href)
+      window.dispatchEvent(new PopStateEvent("popstate"))
+    }
+    notification.close()
+  }
+}
+
+const openLink: Platform["openLink"] = (url) => {
+  window.open(url, "_blank")
+}
+
+const back: Platform["back"] = () => {
+  window.history.back()
+}
+
+const forward: Platform["forward"] = () => {
+  window.history.forward()
+}
+
+const restart: Platform["restart"] = async () => {
+  window.location.reload()
+}
+
+const root = document.getElementById("root")
+if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
+  throw new Error(getRootNotFoundError())
 }
 
 const platform: Platform = {
   platform: "web",
   version: pkg.version,
-  openLink(url: string) {
-    window.open(url, "_blank")
-  },
-  back() {
-    window.history.back()
-  },
-  forward() {
-    window.history.forward()
-  },
-  restart: async () => {
-    window.location.reload()
-  },
-  notify: async (title, description, href) => {
-    if (!("Notification" in window)) return
-
-    const permission =
-      Notification.permission === "default"
-        ? await Notification.requestPermission().catch(() => "denied")
-        : Notification.permission
-
-    if (permission !== "granted") return
-
-    const inView = document.visibilityState === "visible" && document.hasFocus()
-    if (inView) return
-
-    await Promise.resolve()
-      .then(() => {
-        const notification = new Notification(title, {
-          body: description ?? "",
-          icon: "https://opencode.ai/favicon-96x96-v3.png",
-        })
-        notification.onclick = () => {
-          window.focus()
-          if (href) {
-            window.history.pushState(null, "", href)
-            window.dispatchEvent(new PopStateEvent("popstate"))
-          }
-          notification.close()
-        }
-      })
-      .catch(() => undefined)
-  },
-  getDefaultServerUrl: () => {
-    if (typeof localStorage === "undefined") return null
-    try {
-      return localStorage.getItem(DEFAULT_SERVER_URL_KEY)
-    } catch {
-      return null
-    }
-  },
-  setDefaultServerUrl: (url) => {
-    if (typeof localStorage === "undefined") return
-    try {
-      if (url) {
-        localStorage.setItem(DEFAULT_SERVER_URL_KEY, url)
-        return
-      }
-      localStorage.removeItem(DEFAULT_SERVER_URL_KEY)
-    } catch {
-      return
-    }
-  },
+  openLink,
+  back,
+  forward,
+  restart,
+  notify,
+  getDefaultServerUrl: readDefaultServerUrl,
+  setDefaultServerUrl: writeDefaultServerUrl,
 }
 
-render(
-  () => (
-    <PlatformProvider value={platform}>
-      <AppBaseProviders>
-        <AppInterface />
-      </AppBaseProviders>
-    </PlatformProvider>
-  ),
-  root!,
-)
+if (root instanceof HTMLElement) {
+  render(
+    () => (
+      <PlatformProvider value={platform}>
+        <AppBaseProviders>
+          <AppInterface />
+        </AppBaseProviders>
+      </PlatformProvider>
+    ),
+    root,
+  )
+}

+ 10 - 0
packages/app/src/env.d.ts

@@ -1,3 +1,5 @@
+import "solid-js"
+
 interface ImportMetaEnv {
   readonly VITE_OPENCODE_SERVER_HOST: string
   readonly VITE_OPENCODE_SERVER_PORT: string
@@ -6,3 +8,11 @@ interface ImportMetaEnv {
 interface ImportMeta {
   readonly env: ImportMetaEnv
 }
+
+declare module "solid-js" {
+  namespace JSX {
+    interface Directives {
+      sortable: true
+    }
+  }
+}

+ 31 - 44
packages/app/src/pages/directory-layout.tsx

@@ -1,21 +1,47 @@
 import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
+import { createStore } from "solid-js/store"
 import { useNavigate, useParams } from "@solidjs/router"
 import { SDKProvider, useSDK } from "@/context/sdk"
 import { SyncProvider, useSync } from "@/context/sync"
 import { LocalProvider } from "@/context/local"
 
 import { DataProvider } from "@opencode-ai/ui/context"
-import { iife } from "@opencode-ai/util/iife"
 import type { QuestionAnswer } from "@opencode-ai/sdk/v2"
 import { decode64 } from "@/utils/base64"
 import { showToast } from "@opencode-ai/ui/toast"
 import { useLanguage } from "@/context/language"
 
+function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
+  const params = useParams()
+  const navigate = useNavigate()
+  const sync = useSync()
+  const sdk = useSDK()
+
+  return (
+    <DataProvider
+      data={sync.data}
+      directory={props.directory}
+      onPermissionRespond={(input: {
+        sessionID: string
+        permissionID: string
+        response: "once" | "always" | "reject"
+      }) => sdk.client.permission.respond(input)}
+      onQuestionReply={(input: { requestID: string; answers: QuestionAnswer[] }) => sdk.client.question.reply(input)}
+      onQuestionReject={(input: { requestID: string }) => sdk.client.question.reject(input)}
+      onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
+      onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
+      onSyncSession={(sessionID: string) => sync.session.sync(sessionID)}
+    >
+      <LocalProvider>{props.children}</LocalProvider>
+    </DataProvider>
+  )
+}
+
 export default function Layout(props: ParentProps) {
   const params = useParams()
   const navigate = useNavigate()
   const language = useLanguage()
-  let invalid = ""
+  const [store, setStore] = createStore({ invalid: "" })
   const directory = createMemo(() => {
     return decode64(params.dir) ?? ""
   })
@@ -23,8 +49,8 @@ export default function Layout(props: ParentProps) {
   createEffect(() => {
     if (!params.dir) return
     if (directory()) return
-    if (invalid === params.dir) return
-    invalid = params.dir
+    if (store.invalid === params.dir) return
+    setStore("invalid", params.dir)
     showToast({
       variant: "error",
       title: language.t("common.requestFailed"),
@@ -36,46 +62,7 @@ export default function Layout(props: ParentProps) {
     <Show when={directory()}>
       <SDKProvider directory={directory}>
         <SyncProvider>
-          {iife(() => {
-            const sync = useSync()
-            const sdk = useSDK()
-            const respond = (input: {
-              sessionID: string
-              permissionID: string
-              response: "once" | "always" | "reject"
-            }) => sdk.client.permission.respond(input)
-
-            const replyToQuestion = (input: { requestID: string; answers: QuestionAnswer[] }) =>
-              sdk.client.question.reply(input)
-
-            const rejectQuestion = (input: { requestID: string }) => sdk.client.question.reject(input)
-
-            const navigateToSession = (sessionID: string) => {
-              navigate(`/${params.dir}/session/${sessionID}`)
-            }
-
-            const sessionHref = (sessionID: string) => {
-              if (params.dir) return `/${params.dir}/session/${sessionID}`
-              return `/session/${sessionID}`
-            }
-
-            const syncSession = (sessionID: string) => sync.session.sync(sessionID)
-
-            return (
-              <DataProvider
-                data={sync.data}
-                directory={directory()}
-                onPermissionRespond={respond}
-                onQuestionReply={replyToQuestion}
-                onQuestionReject={rejectQuestion}
-                onNavigateToSession={navigateToSession}
-                onSessionHref={sessionHref}
-                onSyncSession={syncSession}
-              >
-                <LocalProvider>{props.children}</LocalProvider>
-              </DataProvider>
-            )
-          })}
+          <DirectoryDataProvider directory={directory()}>{props.children}</DirectoryDataProvider>
         </SyncProvider>
       </SDKProvider>
     </Show>

+ 39 - 12
packages/app/src/pages/error.tsx

@@ -13,6 +13,17 @@ export type InitError = {
 }
 
 type Translator = ReturnType<typeof useLanguage>["t"]
+const CHAIN_SEPARATOR = "\n" + "─".repeat(40) + "\n"
+
+function isIssue(value: unknown): value is { message: string; path: string[] } {
+  if (!value || typeof value !== "object") return false
+  if (!("message" in value) || !("path" in value)) return false
+  const message = (value as { message: unknown }).message
+  const path = (value as { path: unknown }).path
+  if (typeof message !== "string") return false
+  if (!Array.isArray(path)) return false
+  return path.every((part) => typeof part === "string")
+}
 
 function isInitError(error: unknown): error is InitError {
   return (
@@ -112,9 +123,7 @@ function formatInitError(error: InitError, t: Translator): string {
     }
     case "ConfigInvalidError": {
       const issues = Array.isArray(data.issues)
-        ? data.issues.map(
-            (issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."),
-          )
+        ? data.issues.filter(isIssue).map((issue) => "↳ " + issue.message + " " + issue.path.join("."))
         : []
       const message = typeof data.message === "string" ? data.message : ""
       const path = typeof data.path === "string" ? data.path : safeJson(data.path)
@@ -139,14 +148,14 @@ function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessag
   if (isInitError(error)) {
     const message = formatInitError(error, t)
     if (depth > 0 && parentMessage === message) return ""
-    const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
+    const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : ""
     return indent + `${error.name}\n${message}`
   }
 
   if (error instanceof Error) {
     const isDuplicate = depth > 0 && parentMessage === error.message
     const parts: string[] = []
-    const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
+    const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : ""
 
     const header = `${error.name}${error.message ? `: ${error.message}` : ""}`
     const stack = error.stack?.trim()
@@ -190,11 +199,11 @@ function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessag
 
   if (typeof error === "string") {
     if (depth > 0 && parentMessage === error) return ""
-    const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
+    const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : ""
     return indent + error
   }
 
-  const indent = depth > 0 ? `\n${"─".repeat(40)}\n${t("error.chain.causedBy")}\n` : ""
+  const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : ""
   return indent + safeJson(error)
 }
 
@@ -212,20 +221,35 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
   const [store, setStore] = createStore({
     checking: false,
     version: undefined as string | undefined,
+    actionError: undefined as string | undefined,
   })
 
   async function checkForUpdates() {
     if (!platform.checkUpdate) return
     setStore("checking", true)
-    const result = await platform.checkUpdate()
-    setStore("checking", false)
-    if (result.updateAvailable && result.version) setStore("version", result.version)
+    await platform
+      .checkUpdate()
+      .then((result) => {
+        setStore("actionError", undefined)
+        if (result.updateAvailable && result.version) setStore("version", result.version)
+      })
+      .catch((err) => {
+        setStore("actionError", formatError(err, language.t))
+      })
+      .finally(() => {
+        setStore("checking", false)
+      })
   }
 
   async function installUpdate() {
     if (!platform.update || !platform.restart) return
-    await platform.update()
-    await platform.restart()
+    await platform
+      .update()
+      .then(() => platform.restart!())
+      .then(() => setStore("actionError", undefined))
+      .catch((err) => {
+        setStore("actionError", formatError(err, language.t))
+      })
   }
 
   return (
@@ -266,6 +290,9 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
             </Show>
           </Show>
         </div>
+        <Show when={store.actionError}>
+          {(message) => <p class="text-xs text-text-danger-base text-center max-w-2xl">{message()}</p>}
+        </Show>
         <div class="flex flex-col items-center gap-2">
           <div class="flex items-center justify-center gap-1">
             {language.t("error.page.report.prefix")}

+ 9 - 5
packages/app/src/pages/home.tsx

@@ -30,6 +30,13 @@ export default function Home() {
       .slice(0, 5)
   })
 
+  const serverDotClass = createMemo(() => {
+    const healthy = server.healthy()
+    if (healthy === true) return "bg-icon-success-base"
+    if (healthy === false) return "bg-icon-critical-base"
+    return "bg-border-weak-base"
+  })
+
   function openProject(directory: string) {
     layout.projects.open(directory)
     server.projects.touch(directory)
@@ -73,9 +80,7 @@ export default function Home() {
         <div
           classList={{
             "size-2 rounded-full": true,
-            "bg-icon-success-base": server.healthy() === true,
-            "bg-icon-critical-base": server.healthy() === false,
-            "bg-border-weak-base": server.healthy() === undefined,
+            [serverDotClass()]: true,
           }}
         />
         {server.name}
@@ -115,8 +120,7 @@ export default function Home() {
               <div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
               <div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
             </div>
-            <div />
-            <Button class="px-3" onClick={chooseProject}>
+            <Button class="px-3 mt-1" onClick={chooseProject}>
               {language.t("command.project.open")}
             </Button>
           </div>

+ 184 - 204
packages/app/src/pages/layout.tsx

@@ -207,6 +207,18 @@ export default function Layout(props: ParentProps) {
   const setEditor = editor.setEditor
   const InlineEditor = editor.InlineEditor
 
+  const clearSidebarHoverState = () => {
+    if (layout.sidebar.opened()) return
+    setState("hoverSession", undefined)
+    setState("hoverProject", undefined)
+  }
+
+  const navigateWithSidebarReset = (href: string) => {
+    clearSidebarHoverState()
+    navigate(href)
+    layout.mobileSidebar.hide()
+  }
+
   function cycleTheme(direction = 1) {
     const ids = availableThemeEntries().map(([id]) => id)
     if (ids.length === 0) return
@@ -252,166 +264,167 @@ export default function Layout(props: ParentProps) {
     setLocale(next)
   }
 
-  onMount(() => {
-    if (!platform.checkUpdate || !platform.update || !platform.restart) return
-
-    let toastId: number | undefined
-    let interval: ReturnType<typeof setInterval> | undefined
-
-    async function pollUpdate() {
-      const { updateAvailable, version } = await platform.checkUpdate!()
-      if (updateAvailable && toastId === undefined) {
-        toastId = showToast({
-          persistent: true,
-          icon: "download",
-          title: language.t("toast.update.title"),
-          description: language.t("toast.update.description", { version: version ?? "" }),
-          actions: [
-            {
-              label: language.t("toast.update.action.installRestart"),
-              onClick: async () => {
-                await platform.update!()
-                await platform.restart!()
+  const useUpdatePolling = () =>
+    onMount(() => {
+      if (!platform.checkUpdate || !platform.update || !platform.restart) return
+
+      let toastId: number | undefined
+      let interval: ReturnType<typeof setInterval> | undefined
+
+      const pollUpdate = () =>
+        platform.checkUpdate!().then(({ updateAvailable, version }) => {
+          if (!updateAvailable) return
+          if (toastId !== undefined) return
+          toastId = showToast({
+            persistent: true,
+            icon: "download",
+            title: language.t("toast.update.title"),
+            description: language.t("toast.update.description", { version: version ?? "" }),
+            actions: [
+              {
+                label: language.t("toast.update.action.installRestart"),
+                onClick: async () => {
+                  await platform.update!()
+                  await platform.restart!()
+                },
               },
-            },
-            {
-              label: language.t("toast.update.action.notYet"),
-              onClick: "dismiss",
-            },
-          ],
+              {
+                label: language.t("toast.update.action.notYet"),
+                onClick: "dismiss",
+              },
+            ],
+          })
         })
-      }
-    }
 
-    createEffect(() => {
-      if (!settings.ready()) return
+      createEffect(() => {
+        if (!settings.ready()) return
 
-      if (!settings.updates.startup()) {
+        if (!settings.updates.startup()) {
+          if (interval === undefined) return
+          clearInterval(interval)
+          interval = undefined
+          return
+        }
+
+        if (interval !== undefined) return
+        void pollUpdate()
+        interval = setInterval(pollUpdate, 10 * 60 * 1000)
+      })
+
+      onCleanup(() => {
         if (interval === undefined) return
         clearInterval(interval)
-        interval = undefined
-        return
-      }
-
-      if (interval !== undefined) return
-      void pollUpdate()
-      interval = setInterval(pollUpdate, 10 * 60 * 1000)
+      })
     })
 
-    onCleanup(() => {
-      if (interval === undefined) return
-      clearInterval(interval)
-    })
-  })
+  const useSDKNotificationToasts = () =>
+    onMount(() => {
+      const toastBySession = new Map<string, number>()
+      const alertedAtBySession = new Map<string, number>()
+      const cooldownMs = 5000
 
-  onMount(() => {
-    const toastBySession = new Map<string, number>()
-    const alertedAtBySession = new Map<string, number>()
-    const cooldownMs = 5000
-
-    const unsub = globalSDK.event.listen((e) => {
-      if (e.details?.type === "worktree.ready") {
-        setBusy(e.name, false)
-        WorktreeState.ready(e.name)
-        return
+      const dismissSessionAlert = (sessionKey: string) => {
+        const toastId = toastBySession.get(sessionKey)
+        if (toastId === undefined) return
+        toaster.dismiss(toastId)
+        toastBySession.delete(sessionKey)
+        alertedAtBySession.delete(sessionKey)
       }
 
-      if (e.details?.type === "worktree.failed") {
-        setBusy(e.name, false)
-        WorktreeState.failed(e.name, e.details.properties?.message ?? language.t("common.requestFailed"))
-        return
-      }
+      const unsub = globalSDK.event.listen((e) => {
+        if (e.details?.type === "worktree.ready") {
+          setBusy(e.name, false)
+          WorktreeState.ready(e.name)
+          return
+        }
 
-      if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
-      const title =
-        e.details.type === "permission.asked"
-          ? language.t("notification.permission.title")
-          : language.t("notification.question.title")
-      const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const)
-      const directory = e.name
-      const props = e.details.properties
-      if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
-
-      const [store] = globalSync.child(directory, { bootstrap: false })
-      const session = store.session.find((s) => s.id === props.sessionID)
-      const sessionKey = `${directory}:${props.sessionID}`
-
-      const sessionTitle = session?.title ?? language.t("command.session.new")
-      const projectName = getFilename(directory)
-      const description =
-        e.details.type === "permission.asked"
-          ? language.t("notification.permission.description", { sessionTitle, projectName })
-          : language.t("notification.question.description", { sessionTitle, projectName })
-      const href = `/${base64Encode(directory)}/session/${props.sessionID}`
-
-      const now = Date.now()
-      const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
-      if (now - lastAlerted < cooldownMs) return
-      alertedAtBySession.set(sessionKey, now)
-
-      if (e.details.type === "permission.asked") {
-        playSound(soundSrc(settings.sounds.permissions()))
-        if (settings.notifications.permissions()) {
-          void platform.notify(title, description, href)
+        if (e.details?.type === "worktree.failed") {
+          setBusy(e.name, false)
+          WorktreeState.failed(e.name, e.details.properties?.message ?? language.t("common.requestFailed"))
+          return
         }
-      }
 
-      if (e.details.type === "question.asked") {
-        if (settings.notifications.agent()) {
-          void platform.notify(title, description, href)
+        if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
+        const title =
+          e.details.type === "permission.asked"
+            ? language.t("notification.permission.title")
+            : language.t("notification.question.title")
+        const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const)
+        const directory = e.name
+        const props = e.details.properties
+        if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
+
+        const [store] = globalSync.child(directory, { bootstrap: false })
+        const session = store.session.find((s) => s.id === props.sessionID)
+        const sessionKey = `${directory}:${props.sessionID}`
+
+        const sessionTitle = session?.title ?? language.t("command.session.new")
+        const projectName = getFilename(directory)
+        const description =
+          e.details.type === "permission.asked"
+            ? language.t("notification.permission.description", { sessionTitle, projectName })
+            : language.t("notification.question.description", { sessionTitle, projectName })
+        const href = `/${base64Encode(directory)}/session/${props.sessionID}`
+
+        const now = Date.now()
+        const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
+        if (now - lastAlerted < cooldownMs) return
+        alertedAtBySession.set(sessionKey, now)
+
+        if (e.details.type === "permission.asked") {
+          playSound(soundSrc(settings.sounds.permissions()))
+          if (settings.notifications.permissions()) {
+            void platform.notify(title, description, href)
+          }
         }
-      }
 
-      const currentSession = params.id
-      if (directory === currentDir() && props.sessionID === currentSession) return
-      if (directory === currentDir() && session?.parentID === currentSession) return
-
-      const existingToastId = toastBySession.get(sessionKey)
-      if (existingToastId !== undefined) toaster.dismiss(existingToastId)
-
-      const toastId = showToast({
-        persistent: true,
-        icon,
-        title,
-        description,
-        actions: [
-          {
-            label: language.t("notification.action.goToSession"),
-            onClick: () => navigate(href),
-          },
-          {
-            label: language.t("common.dismiss"),
-            onClick: "dismiss",
-          },
-        ],
+        if (e.details.type === "question.asked") {
+          if (settings.notifications.agent()) {
+            void platform.notify(title, description, href)
+          }
+        }
+
+        const currentSession = params.id
+        if (directory === currentDir() && props.sessionID === currentSession) return
+        if (directory === currentDir() && session?.parentID === currentSession) return
+
+        dismissSessionAlert(sessionKey)
+
+        const toastId = showToast({
+          persistent: true,
+          icon,
+          title,
+          description,
+          actions: [
+            {
+              label: language.t("notification.action.goToSession"),
+              onClick: () => navigate(href),
+            },
+            {
+              label: language.t("common.dismiss"),
+              onClick: "dismiss",
+            },
+          ],
+        })
+        toastBySession.set(sessionKey, toastId)
       })
-      toastBySession.set(sessionKey, toastId)
-    })
-    onCleanup(unsub)
-
-    createEffect(() => {
-      const currentSession = params.id
-      if (!currentDir() || !currentSession) return
-      const sessionKey = `${currentDir()}:${currentSession}`
-      const toastId = toastBySession.get(sessionKey)
-      if (toastId !== undefined) {
-        toaster.dismiss(toastId)
-        toastBySession.delete(sessionKey)
-        alertedAtBySession.delete(sessionKey)
-      }
-      const [store] = globalSync.child(currentDir(), { bootstrap: false })
-      const childSessions = store.session.filter((s) => s.parentID === currentSession)
-      for (const child of childSessions) {
-        const childKey = `${currentDir()}:${child.id}`
-        const childToastId = toastBySession.get(childKey)
-        if (childToastId !== undefined) {
-          toaster.dismiss(childToastId)
-          toastBySession.delete(childKey)
-          alertedAtBySession.delete(childKey)
+      onCleanup(unsub)
+
+      createEffect(() => {
+        const currentSession = params.id
+        if (!currentDir() || !currentSession) return
+        const sessionKey = `${currentDir()}:${currentSession}`
+        dismissSessionAlert(sessionKey)
+        const [store] = globalSync.child(currentDir(), { bootstrap: false })
+        const childSessions = store.session.filter((s) => s.parentID === currentSession)
+        for (const child of childSessions) {
+          dismissSessionAlert(`${currentDir()}:${child.id}`)
         }
-      }
+      })
     })
-  })
+
+  useUpdatePolling()
+  useSDKNotificationToasts()
 
   function scrollToSession(sessionId: string, sessionKey: string) {
     if (!scrollContainerRef) return
@@ -641,6 +654,21 @@ export default function Layout(props: ParentProps) {
     return created
   }
 
+  const mergeByID = <T extends { id: string }>(current: T[], incoming: T[]) => {
+    if (current.length === 0) {
+      return incoming.slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+    }
+
+    const map = new Map<string, T>()
+    for (const item of current) {
+      map.set(item.id, item)
+    }
+    for (const item of incoming) {
+      map.set(item.id, item)
+    }
+    return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+  }
+
   async function prefetchMessages(directory: string, sessionID: string, token: number) {
     const [store, setStore] = globalSync.child(directory, { bootstrap: false })
 
@@ -649,51 +677,24 @@ export default function Layout(props: ParentProps) {
         if (prefetchToken.value !== token) return
 
         const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
-        const next = items
-          .map((x) => x.info)
-          .filter((m) => !!m?.id)
-          .slice()
-          .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+        const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
+        const sorted = mergeByID([], next)
 
         const current = store.message[sessionID] ?? []
-        const merged = (() => {
-          if (current.length === 0) return next
-
-          const map = new Map<string, Message>()
-          for (const item of current) {
-            if (!item?.id) continue
-            map.set(item.id, item)
-          }
-          for (const item of next) {
-            map.set(item.id, item)
-          }
-          return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
-        })()
+        const merged = mergeByID(
+          current.filter((item): item is Message => !!item?.id),
+          sorted,
+        )
 
         batch(() => {
           setStore("message", sessionID, reconcile(merged, { key: "id" }))
 
           for (const message of items) {
             const currentParts = store.part[message.info.id] ?? []
-            const mergedParts = (() => {
-              if (currentParts.length === 0) {
-                return message.parts
-                  .filter((p) => !!p?.id)
-                  .slice()
-                  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
-              }
-
-              const map = new Map<string, (typeof currentParts)[number]>()
-              for (const item of currentParts) {
-                if (!item?.id) continue
-                map.set(item.id, item)
-              }
-              for (const item of message.parts) {
-                if (!item?.id) continue
-                map.set(item.id, item)
-              }
-              return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
-            })()
+            const mergedParts = mergeByID(
+              currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id),
+              message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id),
+            )
 
             setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
           }
@@ -1073,24 +1074,14 @@ export default function Layout(props: ParentProps) {
 
   function navigateToProject(directory: string | undefined) {
     if (!directory) return
-    if (!layout.sidebar.opened()) {
-      setState("hoverSession", undefined)
-      setState("hoverProject", undefined)
-    }
     server.projects.touch(directory)
     const lastSession = store.lastSession[directory]
-    navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
-    layout.mobileSidebar.hide()
+    navigateWithSidebarReset(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
   }
 
   function navigateToSession(session: Session | undefined) {
     if (!session) return
-    if (!layout.sidebar.opened()) {
-      setState("hoverSession", undefined)
-      setState("hoverProject", undefined)
-    }
-    navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
-    layout.mobileSidebar.hide()
+    navigateWithSidebarReset(`/${base64Encode(session.directory)}/session/${session.id}`)
   }
 
   function openProject(directory: string, navigate = true) {
@@ -1555,10 +1546,7 @@ export default function Layout(props: ParentProps) {
   }
 
   const createWorkspace = async (project: LocalProject) => {
-    if (!layout.sidebar.opened()) {
-      setState("hoverSession", undefined)
-      setState("hoverProject", undefined)
-    }
+    clearSidebarHoverState()
     const created = await globalSDK.client.worktree
       .create({ directory: project.worktree })
       .then((x) => x.data)
@@ -1595,8 +1583,7 @@ export default function Layout(props: ParentProps) {
     })
 
     globalSync.child(created.directory)
-    navigate(`/${base64Encode(created.directory)}/session`)
-    layout.mobileSidebar.hide()
+    navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)
   }
 
   const workspaceSidebarCtx: WorkspaceSidebarContext = {
@@ -1772,14 +1759,7 @@ export default function Layout(props: ParentProps) {
                             size="large"
                             icon="plus-small"
                             class="w-full"
-                            onClick={() => {
-                              if (!layout.sidebar.opened()) {
-                                setState("hoverSession", undefined)
-                                setState("hoverProject", undefined)
-                              }
-                              navigate(`/${base64Encode(p().worktree)}/session`)
-                              layout.mobileSidebar.hide()
-                            }}
+                            onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
                           >
                             {language.t("command.session.new")}
                           </Button>

+ 15 - 2
packages/app/src/pages/layout/inline-editor.tsx

@@ -1,8 +1,9 @@
 import { createStore } from "solid-js/store"
-import { Show, type Accessor } from "solid-js"
+import { onCleanup, Show, type Accessor } from "solid-js"
 import { InlineInput } from "@opencode-ai/ui/inline-input"
 
 export function createInlineEditorController() {
+  // This controller intentionally supports one active inline editor at a time.
   const [editor, setEditor] = createStore({
     active: "" as string,
     value: "",
@@ -47,6 +48,13 @@ export function createInlineEditorController() {
     stopPropagation?: boolean
     openOnDblClick?: boolean
   }) => {
+    let frame: number | undefined
+
+    onCleanup(() => {
+      if (frame === undefined) return
+      cancelAnimationFrame(frame)
+    })
+
     const isEditing = () => props.editing ?? editorOpen(props.id)
     const stopEvents = () => props.stopPropagation ?? false
     const allowDblClick = () => props.openOnDblClick ?? true
@@ -78,7 +86,12 @@ export function createInlineEditorController() {
       >
         <InlineInput
           ref={(el) => {
-            requestAnimationFrame(() => el.focus())
+            if (frame !== undefined) cancelAnimationFrame(frame)
+            frame = requestAnimationFrame(() => {
+              frame = undefined
+              if (!el.isConnected) return
+              el.focus()
+            })
           }}
           value={editorValue()}
           class={props.class}

+ 152 - 86
packages/app/src/pages/layout/sidebar-items.tsx

@@ -13,7 +13,7 @@ import { MessageNav } from "@opencode-ai/ui/message-nav"
 import { Spinner } from "@opencode-ai/ui/spinner"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { getFilename } from "@opencode-ai/util/path"
-import { type Message, type Session, type TextPart } from "@opencode-ai/sdk/v2/client"
+import { type Message, type Session, type TextPart, type UserMessage } from "@opencode-ai/sdk/v2/client"
 import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js"
 import { agentColor } from "@/utils/agent"
 
@@ -70,6 +70,116 @@ export type SessionItemProps = {
   archiveSession: (session: Session) => Promise<void>
 }
 
+const SessionRow = (props: {
+  session: Session
+  slug: string
+  mobile?: boolean
+  dense?: boolean
+  tint: Accessor<string | undefined>
+  isWorking: Accessor<boolean>
+  hasPermissions: Accessor<boolean>
+  hasError: Accessor<boolean>
+  unseenCount: Accessor<number>
+  setHoverSession: (id: string | undefined) => void
+  clearHoverProjectSoon: () => void
+  sidebarOpened: Accessor<boolean>
+  prefetchSession: (session: Session, priority?: "high" | "low") => void
+  scheduleHoverPrefetch: () => void
+  cancelHoverPrefetch: () => void
+}): 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"}`}
+    onPointerEnter={props.scheduleHoverPrefetch}
+    onPointerLeave={props.cancelHoverPrefetch}
+    onMouseEnter={props.scheduleHoverPrefetch}
+    onMouseLeave={props.cancelHoverPrefetch}
+    onFocus={() => props.prefetchSession(props.session, "high")}
+    onClick={() => {
+      props.setHoverSession(undefined)
+      if (props.sidebarOpened()) return
+      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>
+      <Show when={props.session.summary}>
+        {(summary) => (
+          <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
+            <DiffChanges changes={summary()} />
+          </div>
+        )}
+      </Show>
+    </div>
+  </A>
+)
+
+const SessionHoverPreview = (props: {
+  mobile?: boolean
+  nav: Accessor<HTMLElement | undefined>
+  hoverSession: Accessor<string | undefined>
+  session: Session
+  sidebarHovering: Accessor<boolean>
+  hoverReady: Accessor<boolean>
+  hoverMessages: Accessor<UserMessage[] | undefined>
+  language: ReturnType<typeof useLanguage>
+  isActive: Accessor<boolean>
+  slug: string
+  setHoverSession: (id: string | undefined) => void
+  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}
+    mount={!props.mobile ? props.nav() : undefined}
+    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>}
+    >
+      <div class="overflow-y-auto 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()
   const navigate = useNavigate()
@@ -113,7 +223,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
   })
 
   const hoverMessages = createMemo(() =>
-    sessionStore.message[props.session.id]?.filter((message) => message.role === "user"),
+    sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"),
   )
   const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
   const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
@@ -141,54 +251,24 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
     const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
     return text?.text
   }
-
   const item = (
-    <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"}`}
-      onPointerEnter={scheduleHoverPrefetch}
-      onPointerLeave={cancelHoverPrefetch}
-      onMouseEnter={scheduleHoverPrefetch}
-      onMouseLeave={cancelHoverPrefetch}
-      onFocus={() => props.prefetchSession(props.session, "high")}
-      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"
-          style={{ color: tint() ?? "var(--icon-interactive-base)" }}
-        >
-          <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
-            <Match when={isWorking()}>
-              <Spinner class="size-[15px]" />
-            </Match>
-            <Match when={hasPermissions()}>
-              <div class="size-1.5 rounded-full bg-surface-warning-strong" />
-            </Match>
-            <Match when={hasError()}>
-              <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
-            </Match>
-            <Match when={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>
-        <Show when={props.session.summary}>
-          {(summary) => (
-            <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
-              <DiffChanges changes={summary()} />
-            </div>
-          )}
-        </Show>
-      </div>
-    </A>
+    <SessionRow
+      session={props.session}
+      slug={props.slug}
+      mobile={props.mobile}
+      dense={props.dense}
+      tint={tint}
+      isWorking={isWorking}
+      hasPermissions={hasPermissions}
+      hasError={hasError}
+      unseenCount={unseenCount}
+      setHoverSession={props.setHoverSession}
+      clearHoverProjectSoon={props.clearHoverProjectSoon}
+      sidebarOpened={layout.sidebar.opened}
+      prefetchSession={props.prefetchSession}
+      scheduleHoverPrefetch={scheduleHoverPrefetch}
+      cancelHoverPrefetch={cancelHoverPrefetch}
+    />
   )
 
   return (
@@ -205,44 +285,30 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
           </Tooltip>
         }
       >
-        <HoverCard
-          openDelay={1000}
-          closeDelay={props.sidebarHovering() ? 600 : 0}
-          placement="right-start"
-          gutter={16}
-          shift={-2}
+        <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}`)
+              return
+            }
+            window.history.replaceState(null, "", `#message-${message.id}`)
+            window.dispatchEvent(new HashChangeEvent("hashchange"))
+          }}
           trigger={item}
-          mount={!props.mobile ? props.nav() : undefined}
-          open={props.hoverSession() === props.session.id}
-          onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
-        >
-          <Show
-            when={hoverReady()}
-            fallback={<div class="text-12-regular text-text-weak">{language.t("session.messages.loading")}</div>}
-          >
-            <div class="overflow-y-auto max-h-72 h-full">
-              <MessageNav
-                messages={hoverMessages() ?? []}
-                current={undefined}
-                getLabel={messageLabel}
-                onMessageSelect={(message) => {
-                  if (!isActive()) {
-                    layout.pendingMessage.set(
-                      `${base64Encode(props.session.directory)}/${props.session.id}`,
-                      message.id,
-                    )
-                    navigate(`${props.slug}/session/${props.session.id}`)
-                    return
-                  }
-                  window.history.replaceState(null, "", `#message-${message.id}`)
-                  window.dispatchEvent(new HashChangeEvent("hashchange"))
-                }}
-                size="normal"
-                class="w-60"
-              />
-            </div>
-          </Show>
-        </HoverCard>
+        />
       </Show>
       <div
         class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}

+ 227 - 154
packages/app/src/pages/layout/sidebar-project.tsx

@@ -51,6 +51,195 @@ export const ProjectDragOverlay = (props: {
   )
 }
 
+const ProjectTile = (props: {
+  project: LocalProject
+  mobile?: boolean
+  nav: Accessor<HTMLElement | undefined>
+  sidebarHovering: Accessor<boolean>
+  selected: Accessor<boolean>
+  active: Accessor<boolean>
+  overlay: Accessor<boolean>
+  onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
+  onProjectMouseLeave: (worktree: string) => void
+  onProjectFocus: (worktree: string) => void
+  navigateToProject: (directory: string) => void
+  showEditProjectDialog: (project: LocalProject) => void
+  toggleProjectWorkspaces: (project: LocalProject) => void
+  workspacesEnabled: (project: LocalProject) => boolean
+  closeProject: (directory: string) => void
+  setMenu: (value: boolean) => void
+  setOpen: (value: boolean) => void
+  language: ReturnType<typeof useLanguage>
+}): JSX.Element => (
+  <ContextMenu
+    modal={!props.sidebarHovering()}
+    onOpenChange={(value) => {
+      props.setMenu(value)
+      if (value) props.setOpen(false)
+    }}
+  >
+    <ContextMenu.Trigger
+      as="button"
+      type="button"
+      aria-label={displayName(props.project)}
+      data-action="project-switch"
+      data-project={base64Encode(props.project.worktree)}
+      classList={{
+        "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
+        "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": props.selected(),
+        "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
+          !props.selected() && !props.active(),
+        "bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
+      }}
+      onMouseEnter={(event: MouseEvent) => {
+        if (!props.overlay()) return
+        props.onProjectMouseEnter(props.project.worktree, event)
+      }}
+      onMouseLeave={() => {
+        if (!props.overlay()) return
+        props.onProjectMouseLeave(props.project.worktree)
+      }}
+      onFocus={() => {
+        if (!props.overlay()) return
+        props.onProjectFocus(props.project.worktree)
+      }}
+      onClick={() => props.navigateToProject(props.project.worktree)}
+      onBlur={() => props.setOpen(false)}
+    >
+      <ProjectIcon project={props.project} notify />
+    </ContextMenu.Trigger>
+    <ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
+      <ContextMenu.Content>
+        <ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
+          <ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
+        </ContextMenu.Item>
+        <ContextMenu.Item
+          data-action="project-workspaces-toggle"
+          data-project={base64Encode(props.project.worktree)}
+          disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)}
+          onSelect={() => props.toggleProjectWorkspaces(props.project)}
+        >
+          <ContextMenu.ItemLabel>
+            {props.workspacesEnabled(props.project)
+              ? props.language.t("sidebar.workspaces.disable")
+              : props.language.t("sidebar.workspaces.enable")}
+          </ContextMenu.ItemLabel>
+        </ContextMenu.Item>
+        <ContextMenu.Separator />
+        <ContextMenu.Item
+          data-action="project-close-menu"
+          data-project={base64Encode(props.project.worktree)}
+          onSelect={() => props.closeProject(props.project.worktree)}
+        >
+          <ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel>
+        </ContextMenu.Item>
+      </ContextMenu.Content>
+    </ContextMenu.Portal>
+  </ContextMenu>
+)
+
+const ProjectPreviewPanel = (props: {
+  project: LocalProject
+  mobile?: boolean
+  selected: Accessor<boolean>
+  workspaceEnabled: Accessor<boolean>
+  workspaces: Accessor<string[]>
+  label: (directory: string) => string
+  projectSessions: Accessor<ReturnType<typeof sortedRootSessions>>
+  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 => (
+  <div class="-m-3 p-2 flex flex-col w-72">
+    <div class="px-4 pt-2 pb-1 flex items-center gap-2">
+      <div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
+      <Tooltip value={props.language.t("common.close")} placement="top" gutter={6}>
+        <IconButton
+          icon="circle-x"
+          variant="ghost"
+          class="shrink-0"
+          data-action="project-close-hover"
+          data-project={base64Encode(props.project.worktree)}
+          aria-label={props.language.t("common.close")}
+          onClick={(event) => {
+            event.stopPropagation()
+            props.setOpen(false)
+            props.ctx.closeProject(props.project.worktree)
+          }}
+        />
+      </Tooltip>
+    </div>
+    <div class="px-4 pb-2 text-12-medium text-text-weak">{props.language.t("sidebar.project.recentSessions")}</div>
+    <div class="px-2 pb-2 flex flex-col gap-2">
+      <Show
+        when={props.workspaceEnabled()}
+        fallback={
+          <For each={props.projectSessions()}>
+            {(session) => (
+              <SessionItem
+                {...props.ctx.sessionProps}
+                session={session}
+                slug={base64Encode(props.project.worktree)}
+                dense
+                mobile={props.mobile}
+                popover={false}
+                children={props.projectChildren()}
+              />
+            )}
+          </For>
+        }
+      >
+        <For each={props.workspaces()}>
+          {(directory) => {
+            const sessions = createMemo(() => props.workspaceSessions(directory))
+            const children = createMemo(() => props.workspaceChildren(directory))
+            return (
+              <div class="flex flex-col gap-1">
+                <div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
+                  <div class="shrink-0 size-6 flex items-center justify-center">
+                    <Icon name="branch" size="small" class="text-icon-base" />
+                  </div>
+                  <span class="truncate text-14-medium text-text-base">{props.label(directory)}</span>
+                </div>
+                <For each={sessions()}>
+                  {(session) => (
+                    <SessionItem
+                      {...props.ctx.sessionProps}
+                      session={session}
+                      slug={base64Encode(directory)}
+                      dense
+                      mobile={props.mobile}
+                      popover={false}
+                      children={children()}
+                    />
+                  )}
+                </For>
+              </div>
+            )
+          }}
+        </For>
+      </Show>
+    </div>
+    <div class="px-2 py-2 border-t border-border-weak-base">
+      <Button
+        variant="ghost"
+        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)
+          if (props.selected()) return
+          props.ctx.navigateToProject(props.project.worktree)
+        }}
+      >
+        {props.language.t("sidebar.project.viewAllSessions")}
+      </Button>
+    </div>
+  </div>
+)
+
 export const SortableProject = (props: {
   project: LocalProject
   mobile?: boolean
@@ -105,177 +294,61 @@ export const SortableProject = (props: {
     const [data] = globalSync.child(directory, { bootstrap: false })
     return childMapByParent(data.session)
   }
-
-  const Trigger = () => (
-    <ContextMenu
-      modal={!props.ctx.sidebarHovering()}
-      onOpenChange={(value) => {
-        setMenu(value)
-        if (value) setOpen(false)
-      }}
-    >
-      <ContextMenu.Trigger
-        as="button"
-        type="button"
-        aria-label={displayName(props.project)}
-        data-action="project-switch"
-        data-project={base64Encode(props.project.worktree)}
-        classList={{
-          "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
-          "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
-          "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
-            !selected() && !active(),
-          "bg-surface-base-hover border border-border-weak-base": !selected() && active(),
-        }}
-        onMouseEnter={(event: MouseEvent) => {
-          if (!overlay()) return
-          props.ctx.onProjectMouseEnter(props.project.worktree, event)
-        }}
-        onMouseLeave={() => {
-          if (!overlay()) return
-          props.ctx.onProjectMouseLeave(props.project.worktree)
-        }}
-        onFocus={() => {
-          if (!overlay()) return
-          props.ctx.onProjectFocus(props.project.worktree)
-        }}
-        onClick={() => props.ctx.navigateToProject(props.project.worktree)}
-        onBlur={() => setOpen(false)}
-      >
-        <ProjectIcon project={props.project} notify />
-      </ContextMenu.Trigger>
-      <ContextMenu.Portal mount={!props.mobile ? props.ctx.nav() : undefined}>
-        <ContextMenu.Content>
-          <ContextMenu.Item onSelect={() => props.ctx.showEditProjectDialog(props.project)}>
-            <ContextMenu.ItemLabel>{language.t("common.edit")}</ContextMenu.ItemLabel>
-          </ContextMenu.Item>
-          <ContextMenu.Item
-            data-action="project-workspaces-toggle"
-            data-project={base64Encode(props.project.worktree)}
-            disabled={props.project.vcs !== "git" && !props.ctx.workspacesEnabled(props.project)}
-            onSelect={() => props.ctx.toggleProjectWorkspaces(props.project)}
-          >
-            <ContextMenu.ItemLabel>
-              {props.ctx.workspacesEnabled(props.project)
-                ? language.t("sidebar.workspaces.disable")
-                : language.t("sidebar.workspaces.enable")}
-            </ContextMenu.ItemLabel>
-          </ContextMenu.Item>
-          <ContextMenu.Separator />
-          <ContextMenu.Item
-            data-action="project-close-menu"
-            data-project={base64Encode(props.project.worktree)}
-            onSelect={() => props.ctx.closeProject(props.project.worktree)}
-          >
-            <ContextMenu.ItemLabel>{language.t("common.close")}</ContextMenu.ItemLabel>
-          </ContextMenu.Item>
-        </ContextMenu.Content>
-      </ContextMenu.Portal>
-    </ContextMenu>
+  const trigger = (
+    <ProjectTile
+      project={props.project}
+      mobile={props.mobile}
+      nav={props.ctx.nav}
+      sidebarHovering={props.ctx.sidebarHovering}
+      selected={selected}
+      active={active}
+      overlay={overlay}
+      onProjectMouseEnter={props.ctx.onProjectMouseEnter}
+      onProjectMouseLeave={props.ctx.onProjectMouseLeave}
+      onProjectFocus={props.ctx.onProjectFocus}
+      navigateToProject={props.ctx.navigateToProject}
+      showEditProjectDialog={props.ctx.showEditProjectDialog}
+      toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
+      workspacesEnabled={props.ctx.workspacesEnabled}
+      closeProject={props.ctx.closeProject}
+      setMenu={setMenu}
+      setOpen={setOpen}
+      language={language}
+    />
   )
 
   return (
     // @ts-ignore
     <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
-      <Show when={preview()} fallback={<Trigger />}>
+      <Show when={preview()} fallback={trigger}>
         <HoverCard
           open={open() && !menu()}
           openDelay={0}
           closeDelay={0}
           placement="right-start"
           gutter={6}
-          trigger={<Trigger />}
+          trigger={trigger}
           onOpenChange={(value) => {
             if (menu()) return
             setOpen(value)
             if (value) props.ctx.setHoverSession(undefined)
           }}
         >
-          <div class="-m-3 p-2 flex flex-col w-72">
-            <div class="px-4 pt-2 pb-1 flex items-center gap-2">
-              <div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
-              <Tooltip value={language.t("common.close")} placement="top" gutter={6}>
-                <IconButton
-                  icon="circle-x"
-                  variant="ghost"
-                  class="shrink-0"
-                  data-action="project-close-hover"
-                  data-project={base64Encode(props.project.worktree)}
-                  aria-label={language.t("common.close")}
-                  onClick={(event) => {
-                    event.stopPropagation()
-                    setOpen(false)
-                    props.ctx.closeProject(props.project.worktree)
-                  }}
-                />
-              </Tooltip>
-            </div>
-            <div class="px-4 pb-2 text-12-medium text-text-weak">{language.t("sidebar.project.recentSessions")}</div>
-            <div class="px-2 pb-2 flex flex-col gap-2">
-              <Show
-                when={workspaceEnabled()}
-                fallback={
-                  <For each={projectSessions()}>
-                    {(session) => (
-                      <SessionItem
-                        {...props.ctx.sessionProps}
-                        session={session}
-                        slug={base64Encode(props.project.worktree)}
-                        dense
-                        mobile={props.mobile}
-                        popover={false}
-                        children={projectChildren()}
-                      />
-                    )}
-                  </For>
-                }
-              >
-                <For each={workspaces()}>
-                  {(directory) => {
-                    const sessions = createMemo(() => workspaceSessions(directory))
-                    const children = createMemo(() => workspaceChildren(directory))
-                    return (
-                      <div class="flex flex-col gap-1">
-                        <div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
-                          <div class="shrink-0 size-6 flex items-center justify-center">
-                            <Icon name="branch" size="small" class="text-icon-base" />
-                          </div>
-                          <span class="truncate text-14-medium text-text-base">{label(directory)}</span>
-                        </div>
-                        <For each={sessions()}>
-                          {(session) => (
-                            <SessionItem
-                              {...props.ctx.sessionProps}
-                              session={session}
-                              slug={base64Encode(directory)}
-                              dense
-                              mobile={props.mobile}
-                              popover={false}
-                              children={children()}
-                            />
-                          )}
-                        </For>
-                      </div>
-                    )
-                  }}
-                </For>
-              </Show>
-            </div>
-            <div class="px-2 py-2 border-t border-border-weak-base">
-              <Button
-                variant="ghost"
-                class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
-                onClick={() => {
-                  props.ctx.openSidebar()
-                  setOpen(false)
-                  if (selected()) return
-                  props.ctx.navigateToProject(props.project.worktree)
-                }}
-              >
-                {language.t("sidebar.project.viewAllSessions")}
-              </Button>
-            </div>
-          </div>
+          <ProjectPreviewPanel
+            project={props.project}
+            mobile={props.mobile}
+            selected={selected}
+            workspaceEnabled={workspaceEnabled}
+            workspaces={workspaces}
+            label={label}
+            projectSessions={projectSessions}
+            projectChildren={projectChildren}
+            workspaceSessions={workspaceSessions}
+            workspaceChildren={workspaceChildren}
+            setOpen={setOpen}
+            ctx={props.ctx}
+            language={language}
+          />
         </HoverCard>
       </Show>
     </div>

+ 4 - 7
packages/app/src/pages/layout/sidebar-shell.tsx

@@ -34,6 +34,7 @@ export const SidebarContent = (props: {
   renderPanel: () => JSX.Element
 }): JSX.Element => {
   const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
+  const placement = () => (props.mobile ? "bottom" : "right")
 
   return (
     <div class="flex h-full w-full overflow-hidden">
@@ -55,7 +56,7 @@ export const SidebarContent = (props: {
                 <For each={props.projects()}>{(project) => props.renderProject(project)}</For>
               </SortableProvider>
               <Tooltip
-                placement={props.mobile ? "bottom" : "right"}
+                placement={placement()}
                 value={
                   <div class="flex items-center gap-2">
                     <span>{props.openProjectLabel}</span>
@@ -78,11 +79,7 @@ export const SidebarContent = (props: {
           </DragDropProvider>
         </div>
         <div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
-          <TooltipKeybind
-            placement={props.mobile ? "bottom" : "right"}
-            title={props.settingsLabel()}
-            keybind={props.settingsKeybind() ?? ""}
-          >
+          <TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}>
             <IconButton
               icon="settings-gear"
               variant="ghost"
@@ -91,7 +88,7 @@ export const SidebarContent = (props: {
               aria-label={props.settingsLabel()}
             />
           </TooltipKeybind>
-          <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.helpLabel()}>
+          <Tooltip placement={placement()} value={props.helpLabel()}>
             <IconButton
               icon="help"
               variant="ghost"

+ 278 - 167
packages/app/src/pages/layout/sidebar-workspace.tsx

@@ -82,6 +82,222 @@ export const WorkspaceDragOverlay = (props: {
   )
 }
 
+const WorkspaceHeader = (props: {
+  local: Accessor<boolean>
+  busy: Accessor<boolean>
+  open: Accessor<boolean>
+  directory: string
+  language: ReturnType<typeof useLanguage>
+  branch: Accessor<string | undefined>
+  workspaceValue: Accessor<string>
+  workspaceEditActive: Accessor<boolean>
+  InlineEditor: WorkspaceSidebarContext["InlineEditor"]
+  renameWorkspace: WorkspaceSidebarContext["renameWorkspace"]
+  setEditor: WorkspaceSidebarContext["setEditor"]
+  projectId?: string
+}): JSX.Element => (
+  <div class="flex items-center gap-1 min-w-0 flex-1">
+    <div class="flex items-center justify-center shrink-0 size-6">
+      <Show when={props.busy()} fallback={<Icon name="branch" size="small" />}>
+        <Spinner class="size-[15px]" />
+      </Show>
+    </div>
+    <span class="text-14-medium text-text-base shrink-0">
+      {props.local() ? props.language.t("workspace.type.local") : props.language.t("workspace.type.sandbox")} :
+    </span>
+    <Show
+      when={!props.local()}
+      fallback={
+        <span class="text-14-medium text-text-base min-w-0 truncate">
+          {props.branch() ?? getFilename(props.directory)}
+        </span>
+      }
+    >
+      <props.InlineEditor
+        id={`workspace:${props.directory}`}
+        value={props.workspaceValue}
+        onSave={(next) => {
+          const trimmed = next.trim()
+          if (!trimmed) return
+          props.renameWorkspace(props.directory, trimmed, props.projectId, props.branch())
+          props.setEditor("value", props.workspaceValue())
+        }}
+        class="text-14-medium text-text-base min-w-0 truncate"
+        displayClass="text-14-medium text-text-base min-w-0 truncate"
+        editing={props.workspaceEditActive()}
+        stopPropagation={false}
+        openOnDblClick={false}
+      />
+    </Show>
+    <div class="flex items-center justify-center shrink-0 overflow-hidden w-0 opacity-0 transition-all duration-200 group-hover/workspace:w-3.5 group-hover/workspace:opacity-100 group-focus-within/workspace:w-3.5 group-focus-within/workspace:opacity-100">
+      <Icon name={props.open() ? "chevron-down" : "chevron-right"} size="small" class="text-icon-base" />
+    </div>
+  </div>
+)
+
+const WorkspaceActions = (props: {
+  directory: string
+  local: Accessor<boolean>
+  busy: Accessor<boolean>
+  menuOpen: Accessor<boolean>
+  pendingRename: Accessor<boolean>
+  setMenuOpen: (open: boolean) => void
+  setPendingRename: (value: boolean) => void
+  sidebarHovering: Accessor<boolean>
+  mobile?: boolean
+  nav: Accessor<HTMLElement | undefined>
+  touch: Accessor<boolean>
+  language: ReturnType<typeof useLanguage>
+  workspaceValue: Accessor<string>
+  openEditor: WorkspaceSidebarContext["openEditor"]
+  showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
+  showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
+  root: string
+  setHoverSession: WorkspaceSidebarContext["setHoverSession"]
+  clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
+  navigateToNewSession: () => void
+}): JSX.Element => (
+  <div
+    class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
+    classList={{
+      "opacity-100 pointer-events-auto": props.menuOpen(),
+      "opacity-0 pointer-events-none": !props.menuOpen(),
+      "group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
+      "group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
+    }}
+  >
+    <DropdownMenu
+      modal={!props.sidebarHovering()}
+      open={props.menuOpen()}
+      onOpenChange={(open) => props.setMenuOpen(open)}
+    >
+      <Tooltip value={props.language.t("common.moreOptions")} placement="top">
+        <DropdownMenu.Trigger
+          as={IconButton}
+          icon="dot-grid"
+          variant="ghost"
+          class="size-6 rounded-md"
+          data-action="workspace-menu"
+          data-workspace={base64Encode(props.directory)}
+          aria-label={props.language.t("common.moreOptions")}
+        />
+      </Tooltip>
+      <DropdownMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
+        <DropdownMenu.Content
+          onCloseAutoFocus={(event) => {
+            if (!props.pendingRename()) return
+            event.preventDefault()
+            props.setPendingRename(false)
+            props.openEditor(`workspace:${props.directory}`, props.workspaceValue())
+          }}
+        >
+          <DropdownMenu.Item
+            disabled={props.local()}
+            onSelect={() => {
+              props.setPendingRename(true)
+              props.setMenuOpen(false)
+            }}
+          >
+            <DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel>
+          </DropdownMenu.Item>
+          <DropdownMenu.Item
+            disabled={props.local() || props.busy()}
+            onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
+          >
+            <DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
+          </DropdownMenu.Item>
+          <DropdownMenu.Item
+            disabled={props.local() || props.busy()}
+            onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)}
+          >
+            <DropdownMenu.ItemLabel>{props.language.t("common.delete")}</DropdownMenu.ItemLabel>
+          </DropdownMenu.Item>
+        </DropdownMenu.Content>
+      </DropdownMenu.Portal>
+    </DropdownMenu>
+    <Show when={!props.touch()}>
+      <Tooltip value={props.language.t("command.session.new")} placement="top">
+        <IconButton
+          icon="plus-small"
+          variant="ghost"
+          class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto"
+          data-action="workspace-new-session"
+          data-workspace={base64Encode(props.directory)}
+          aria-label={props.language.t("command.session.new")}
+          onClick={(event) => {
+            event.preventDefault()
+            event.stopPropagation()
+            props.setHoverSession(undefined)
+            props.clearHoverProjectSoon()
+            props.navigateToNewSession()
+          }}
+        />
+      </Tooltip>
+    </Show>
+  </div>
+)
+
+const WorkspaceSessionList = (props: {
+  slug: Accessor<string>
+  mobile?: boolean
+  ctx: WorkspaceSidebarContext
+  showNew: Accessor<boolean>
+  loading: Accessor<boolean>
+  sessions: Accessor<Session[]>
+  children: Accessor<Map<string, string[]>>
+  hasMore: Accessor<boolean>
+  loadMore: () => Promise<void>
+  language: ReturnType<typeof useLanguage>
+}): JSX.Element => (
+  <nav class="flex flex-col gap-1 px-2">
+    <Show when={props.showNew()}>
+      <NewSessionItem
+        slug={props.slug()}
+        mobile={props.mobile}
+        sidebarExpanded={props.ctx.sidebarExpanded}
+        clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
+        setHoverSession={props.ctx.setHoverSession}
+      />
+    </Show>
+    <Show when={props.loading()}>
+      <SessionSkeleton />
+    </Show>
+    <For each={props.sessions()}>
+      {(session) => (
+        <SessionItem
+          session={session}
+          slug={props.slug()}
+          mobile={props.mobile}
+          children={props.children()}
+          sidebarExpanded={props.ctx.sidebarExpanded}
+          sidebarHovering={props.ctx.sidebarHovering}
+          nav={props.ctx.nav}
+          hoverSession={props.ctx.hoverSession}
+          setHoverSession={props.ctx.setHoverSession}
+          clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
+          prefetchSession={props.ctx.prefetchSession}
+          archiveSession={props.ctx.archiveSession}
+        />
+      )}
+    </For>
+    <Show when={props.hasMore()}>
+      <div class="relative w-full py-1">
+        <Button
+          variant="ghost"
+          class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
+          size="large"
+          onClick={(e: MouseEvent) => {
+            props.loadMore()
+            ;(e.currentTarget as HTMLButtonElement).blur()
+          }}
+        >
+          {props.language.t("common.loadMore")}
+        </Button>
+      </div>
+    </Show>
+  </nav>
+)
+
 export const SortableWorkspace = (props: {
   ctx: WorkspaceSidebarContext
   directory: string
@@ -135,46 +351,6 @@ export const SortableWorkspace = (props: {
     globalSync.child(props.directory, { bootstrap: true })
   })
 
-  const header = () => (
-    <div class="flex items-center gap-1 min-w-0 flex-1">
-      <div class="flex items-center justify-center shrink-0 size-6">
-        <Show when={busy()} fallback={<Icon name="branch" size="small" />}>
-          <Spinner class="size-[15px]" />
-        </Show>
-      </div>
-      <span class="text-14-medium text-text-base shrink-0">
-        {local() ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")} :
-      </span>
-      <Show
-        when={!local()}
-        fallback={
-          <span class="text-14-medium text-text-base min-w-0 truncate">
-            {workspaceStore.vcs?.branch ?? getFilename(props.directory)}
-          </span>
-        }
-      >
-        <props.ctx.InlineEditor
-          id={`workspace:${props.directory}`}
-          value={workspaceValue}
-          onSave={(next) => {
-            const trimmed = next.trim()
-            if (!trimmed) return
-            props.ctx.renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch)
-            props.ctx.setEditor("value", workspaceValue())
-          }}
-          class="text-14-medium text-text-base min-w-0 truncate"
-          displayClass="text-14-medium text-text-base min-w-0 truncate"
-          editing={workspaceEditActive()}
-          stopPropagation={false}
-          openOnDblClick={false}
-        />
-      </Show>
-      <div class="flex items-center justify-center shrink-0 overflow-hidden w-0 opacity-0 transition-all duration-200 group-hover/workspace:w-3.5 group-hover/workspace:opacity-100 group-focus-within/workspace:w-3.5 group-focus-within/workspace:opacity-100">
-        <Icon name={open() ? "chevron-down" : "chevron-right"} size="small" class="text-icon-base" />
-      </div>
-    </div>
-  )
-
   return (
     <div
       // @ts-ignore
@@ -202,7 +378,20 @@ export const SortableWorkspace = (props: {
                     data-action="workspace-toggle"
                     data-workspace={base64Encode(props.directory)}
                   >
-                    {header()}
+                    <WorkspaceHeader
+                      local={local}
+                      busy={busy}
+                      open={open}
+                      directory={props.directory}
+                      language={language}
+                      branch={() => workspaceStore.vcs?.branch}
+                      workspaceValue={workspaceValue}
+                      workspaceEditActive={workspaceEditActive}
+                      InlineEditor={props.ctx.InlineEditor}
+                      renameWorkspace={props.ctx.renameWorkspace}
+                      setEditor={props.ctx.setEditor}
+                      projectId={props.project.id}
+                    />
                   </Collapsible.Trigger>
                 }
               >
@@ -211,139 +400,61 @@ export const SortableWorkspace = (props: {
                     menu.open ? "pr-16" : "pr-2"
                   } group-hover/workspace:pr-16 group-focus-within/workspace:pr-16`}
                 >
-                  {header()}
+                  <WorkspaceHeader
+                    local={local}
+                    busy={busy}
+                    open={open}
+                    directory={props.directory}
+                    language={language}
+                    branch={() => workspaceStore.vcs?.branch}
+                    workspaceValue={workspaceValue}
+                    workspaceEditActive={workspaceEditActive}
+                    InlineEditor={props.ctx.InlineEditor}
+                    renameWorkspace={props.ctx.renameWorkspace}
+                    setEditor={props.ctx.setEditor}
+                    projectId={props.project.id}
+                  />
                 </div>
               </Show>
-              <div
-                class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
-                classList={{
-                  "opacity-100 pointer-events-auto": menu.open,
-                  "opacity-0 pointer-events-none": !menu.open,
-                  "group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
-                  "group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
-                }}
-              >
-                <DropdownMenu
-                  modal={!props.ctx.sidebarHovering()}
-                  open={menu.open}
-                  onOpenChange={(open) => setMenu("open", open)}
-                >
-                  <Tooltip value={language.t("common.moreOptions")} placement="top">
-                    <DropdownMenu.Trigger
-                      as={IconButton}
-                      icon="dot-grid"
-                      variant="ghost"
-                      class="size-6 rounded-md"
-                      data-action="workspace-menu"
-                      data-workspace={base64Encode(props.directory)}
-                      aria-label={language.t("common.moreOptions")}
-                    />
-                  </Tooltip>
-                  <DropdownMenu.Portal mount={!props.mobile ? props.ctx.nav() : undefined}>
-                    <DropdownMenu.Content
-                      onCloseAutoFocus={(event) => {
-                        if (!menu.pendingRename) return
-                        event.preventDefault()
-                        setMenu("pendingRename", false)
-                        props.ctx.openEditor(`workspace:${props.directory}`, workspaceValue())
-                      }}
-                    >
-                      <DropdownMenu.Item
-                        disabled={local()}
-                        onSelect={() => {
-                          setMenu("pendingRename", true)
-                          setMenu("open", false)
-                        }}
-                      >
-                        <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
-                      </DropdownMenu.Item>
-                      <DropdownMenu.Item
-                        disabled={local() || busy()}
-                        onSelect={() => props.ctx.showResetWorkspaceDialog(props.project.worktree, props.directory)}
-                      >
-                        <DropdownMenu.ItemLabel>{language.t("common.reset")}</DropdownMenu.ItemLabel>
-                      </DropdownMenu.Item>
-                      <DropdownMenu.Item
-                        disabled={local() || busy()}
-                        onSelect={() => props.ctx.showDeleteWorkspaceDialog(props.project.worktree, props.directory)}
-                      >
-                        <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
-                      </DropdownMenu.Item>
-                    </DropdownMenu.Content>
-                  </DropdownMenu.Portal>
-                </DropdownMenu>
-                <Show when={!touch()}>
-                  <Tooltip value={language.t("command.session.new")} placement="top">
-                    <IconButton
-                      icon="plus-small"
-                      variant="ghost"
-                      class="size-6 rounded-md opacity-0 pointer-events-none group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto"
-                      data-action="workspace-new-session"
-                      data-workspace={base64Encode(props.directory)}
-                      aria-label={language.t("command.session.new")}
-                      onClick={(event) => {
-                        event.preventDefault()
-                        event.stopPropagation()
-                        props.ctx.setHoverSession(undefined)
-                        props.ctx.clearHoverProjectSoon()
-                        navigate(`/${slug()}/session`)
-                      }}
-                    />
-                  </Tooltip>
-                </Show>
-              </div>
+              <WorkspaceActions
+                directory={props.directory}
+                local={local}
+                busy={busy}
+                menuOpen={() => menu.open}
+                pendingRename={() => menu.pendingRename}
+                setMenuOpen={(open) => setMenu("open", open)}
+                setPendingRename={(value) => setMenu("pendingRename", value)}
+                sidebarHovering={props.ctx.sidebarHovering}
+                mobile={props.mobile}
+                nav={props.ctx.nav}
+                touch={touch}
+                language={language}
+                workspaceValue={workspaceValue}
+                openEditor={props.ctx.openEditor}
+                showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
+                showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
+                root={props.project.worktree}
+                setHoverSession={props.ctx.setHoverSession}
+                clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
+                navigateToNewSession={() => navigate(`/${slug()}/session`)}
+              />
             </div>
           </div>
         </div>
 
         <Collapsible.Content>
-          <nav class="flex flex-col gap-1 px-2">
-            <Show when={showNew()}>
-              <NewSessionItem
-                slug={slug()}
-                mobile={props.mobile}
-                sidebarExpanded={props.ctx.sidebarExpanded}
-                clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
-                setHoverSession={props.ctx.setHoverSession}
-              />
-            </Show>
-            <Show when={loading()}>
-              <SessionSkeleton />
-            </Show>
-            <For each={sessions()}>
-              {(session) => (
-                <SessionItem
-                  session={session}
-                  slug={slug()}
-                  mobile={props.mobile}
-                  children={children()}
-                  sidebarExpanded={props.ctx.sidebarExpanded}
-                  sidebarHovering={props.ctx.sidebarHovering}
-                  nav={props.ctx.nav}
-                  hoverSession={props.ctx.hoverSession}
-                  setHoverSession={props.ctx.setHoverSession}
-                  clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
-                  prefetchSession={props.ctx.prefetchSession}
-                  archiveSession={props.ctx.archiveSession}
-                />
-              )}
-            </For>
-            <Show when={hasMore()}>
-              <div class="relative w-full py-1">
-                <Button
-                  variant="ghost"
-                  class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
-                  size="large"
-                  onClick={(e: MouseEvent) => {
-                    loadMore()
-                    ;(e.currentTarget as HTMLButtonElement).blur()
-                  }}
-                >
-                  {language.t("common.loadMore")}
-                </Button>
-              </div>
-            </Show>
-          </nav>
+          <WorkspaceSessionList
+            slug={slug}
+            mobile={props.mobile}
+            ctx={props.ctx}
+            showNew={showNew}
+            loading={loading}
+            sessions={sessions}
+            children={children}
+            hasMore={hasMore}
+            loadMore={loadMore}
+            language={language}
+          />
         </Collapsible.Content>
       </Collapsible>
     </div>

+ 34 - 35
packages/app/src/pages/session.tsx

@@ -394,6 +394,19 @@ export default function Page() {
       })
   }
 
+  const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
+    if (params.id !== sessionID) return
+    if (parentID) {
+      navigate(`/${params.dir}/session/${parentID}`)
+      return
+    }
+    if (nextSessionID) {
+      navigate(`/${params.dir}/session/${nextSessionID}`)
+      return
+    }
+    navigate(`/${params.dir}/session`)
+  }
+
   async function archiveSession(sessionID: string) {
     const session = sync.session.get(sessionID)
     if (!session) return
@@ -411,17 +424,7 @@ export default function Page() {
             if (index !== -1) draft.session.splice(index, 1)
           }),
         )
-
-        if (params.id !== sessionID) return
-        if (session.parentID) {
-          navigate(`/${params.dir}/session/${session.parentID}`)
-          return
-        }
-        if (nextSession) {
-          navigate(`/${params.dir}/session/${nextSession.id}`)
-          return
-        }
-        navigate(`/${params.dir}/session`)
+        navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
       })
       .catch((err) => {
         showToast({
@@ -487,16 +490,7 @@ export default function Page() {
       }),
     )
 
-    if (params.id !== sessionID) return true
-    if (session.parentID) {
-      navigate(`/${params.dir}/session/${session.parentID}`)
-      return true
-    }
-    if (nextSession) {
-      navigate(`/${params.dir}/session/${nextSession.id}`)
-      return true
-    }
-    navigate(`/${params.dir}/session`)
+    navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
     return true
   }
 
@@ -1532,15 +1526,18 @@ export default function Page() {
   createEffect(() => {
     if (!file.ready()) return
     setSessionHandoff(sessionKey(), {
-      files: Object.fromEntries(
-        tabs()
-          .all()
-          .flatMap((tab) => {
-            const path = file.pathFromTab(tab)
-            if (!path) return []
-            return [[path, file.selectedLines(path) ?? null] as const]
-          }),
-      ),
+      files: tabs()
+        .all()
+        .reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
+          const path = file.pathFromTab(tab)
+          if (!path) return acc
+          const selected = file.selectedLines(path)
+          acc[path] =
+            selected && typeof selected === "object" && "start" in selected && "end" in selected
+              ? (selected as SelectedLineRange)
+              : null
+          return acc
+        }, {}),
     })
   })
 
@@ -1557,6 +1554,7 @@ export default function Page() {
       <div class="flex-1 min-h-0 flex flex-col md:flex-row">
         <SessionMobileTabs
           open={!isDesktop() && !!params.id}
+          mobileTab={store.mobileTab}
           hasReview={hasReview()}
           reviewCount={reviewCount()}
           onSession={() => setStore("mobileTab", "session")}
@@ -1719,7 +1717,6 @@ export default function Page() {
           dialog={dialog}
           file={file}
           comments={comments}
-          sync={sync}
           hasReview={hasReview()}
           reviewCount={reviewCount()}
           reviewTab={reviewTab()}
@@ -1731,10 +1728,12 @@ export default function Page() {
           openTab={openTab}
           showAllFiles={showAllFiles}
           reviewPanel={reviewPanel}
-          messages={messages as () => unknown[]}
-          visibleUserMessages={visibleUserMessages as () => unknown[]}
-          view={view}
-          info={info as () => unknown}
+          vm={{
+            messages,
+            visibleUserMessages,
+            view,
+            info,
+          }}
           handoffFiles={() => handoff.session.get(sessionKey())?.files}
           codeComponent={codeComponent}
           addCommentToContext={addCommentToContext}

+ 36 - 58
packages/app/src/pages/session/file-tabs.tsx

@@ -12,6 +12,13 @@ import { useFile, type SelectedLineRange } from "@/context/file"
 import { useComments } from "@/context/comments"
 import { useLanguage } from "@/context/language"
 
+const formatCommentLabel = (range: SelectedLineRange) => {
+  const start = Math.min(range.start, range.end)
+  const end = Math.max(range.start, range.end)
+  if (start === end) return `line ${start}`
+  return `lines ${start}-${end}`
+}
+
 export function FileTabContent(props: {
   tab: string
   activeTab: () => string
@@ -76,7 +83,6 @@ export function FileTabContent(props: {
     showToast({
       variant: "error",
       title: props.language.t("toast.file.loadFailed.title"),
-      description: "Invalid base64 content.",
     })
   })
   const svgPreviewUrl = createMemo(() => {
@@ -116,34 +122,6 @@ export function FileTabContent(props: {
     draftTop: undefined as number | undefined,
   })
 
-  const openedComment = () => note.openedComment
-  const setOpenedComment = (
-    value: typeof note.openedComment | ((value: typeof note.openedComment) => typeof note.openedComment),
-  ) => setNote("openedComment", value)
-
-  const commenting = () => note.commenting
-  const setCommenting = (value: typeof note.commenting | ((value: typeof note.commenting) => typeof note.commenting)) =>
-    setNote("commenting", value)
-
-  const draft = () => note.draft
-  const setDraft = (value: typeof note.draft | ((value: typeof note.draft) => typeof note.draft)) =>
-    setNote("draft", value)
-
-  const positions = () => note.positions
-  const setPositions = (value: typeof note.positions | ((value: typeof note.positions) => typeof note.positions)) =>
-    setNote("positions", value)
-
-  const draftTop = () => note.draftTop
-  const setDraftTop = (value: typeof note.draftTop | ((value: typeof note.draftTop) => typeof note.draftTop)) =>
-    setNote("draftTop", value)
-
-  const commentLabel = (range: SelectedLineRange) => {
-    const start = Math.min(range.start, range.end)
-    const end = Math.max(range.start, range.end)
-    if (start === end) return `line ${start}`
-    return `lines ${start}-${end}`
-  }
-
   const getRoot = () => {
     const el = wrap
     if (!el) return
@@ -174,8 +152,8 @@ export function FileTabContent(props: {
     const el = wrap
     const root = getRoot()
     if (!el || !root) {
-      setPositions({})
-      setDraftTop(undefined)
+      setNote("positions", {})
+      setNote("draftTop", undefined)
       return
     }
 
@@ -186,21 +164,21 @@ export function FileTabContent(props: {
       next[comment.id] = markerTop(el, marker)
     }
 
-    setPositions(next)
+    setNote("positions", next)
 
-    const range = commenting()
+    const range = note.commenting
     if (!range) {
-      setDraftTop(undefined)
+      setNote("draftTop", undefined)
       return
     }
 
     const marker = findMarker(root, range)
     if (!marker) {
-      setDraftTop(undefined)
+      setNote("draftTop", undefined)
       return
     }
 
-    setDraftTop(markerTop(el, marker))
+    setNote("draftTop", markerTop(el, marker))
   }
 
   const scheduleComments = () => {
@@ -213,10 +191,10 @@ export function FileTabContent(props: {
   })
 
   createEffect(() => {
-    const range = commenting()
+    const range = note.commenting
     scheduleComments()
     if (!range) return
-    setDraft("")
+    setNote("draft", "")
   })
 
   createEffect(() => {
@@ -229,8 +207,8 @@ export function FileTabContent(props: {
     const target = fileComments().find((comment) => comment.id === focus.id)
     if (!target) return
 
-    setOpenedComment(target.id)
-    setCommenting(null)
+    setNote("openedComment", target.id)
+    setNote("commenting", null)
     props.file.setSelectedLines(p, target.selection)
     requestAnimationFrame(() => props.comments.clearFocus())
   })
@@ -390,16 +368,16 @@ export function FileTabContent(props: {
           const p = path()
           if (!p) return
           props.file.setSelectedLines(p, range)
-          if (!range) setCommenting(null)
+          if (!range) setNote("commenting", null)
         }}
         onLineSelectionEnd={(range: SelectedLineRange | null) => {
           if (!range) {
-            setCommenting(null)
+            setNote("commenting", null)
             return
           }
 
-          setOpenedComment(null)
-          setCommenting(range)
+          setNote("openedComment", null)
+          setNote("commenting", range)
         }}
         overflow="scroll"
         class="select-text"
@@ -408,10 +386,10 @@ export function FileTabContent(props: {
         {(comment) => (
           <LineCommentView
             id={comment.id}
-            top={positions()[comment.id]}
-            open={openedComment() === comment.id}
+            top={note.positions[comment.id]}
+            open={note.openedComment === comment.id}
             comment={comment.comment}
-            selection={commentLabel(comment.selection)}
+            selection={formatCommentLabel(comment.selection)}
             onMouseEnter={() => {
               const p = path()
               if (!p) return
@@ -420,22 +398,22 @@ export function FileTabContent(props: {
             onClick={() => {
               const p = path()
               if (!p) return
-              setCommenting(null)
-              setOpenedComment((current) => (current === comment.id ? null : comment.id))
+              setNote("commenting", null)
+              setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
               props.file.setSelectedLines(p, comment.selection)
             }}
           />
         )}
       </For>
-      <Show when={commenting()}>
+      <Show when={note.commenting}>
         {(range) => (
-          <Show when={draftTop() !== undefined}>
+          <Show when={note.draftTop !== undefined}>
             <LineCommentEditor
-              top={draftTop()}
-              value={draft()}
-              selection={commentLabel(range())}
-              onInput={(value) => setDraft(value)}
-              onCancel={() => setCommenting(null)}
+              top={note.draftTop}
+              value={note.draft}
+              selection={formatCommentLabel(range())}
+              onInput={(value) => setNote("draft", value)}
+              onCancel={() => setNote("commenting", null)}
               onSubmit={(value) => {
                 const p = path()
                 if (!p) return
@@ -445,7 +423,7 @@ export function FileTabContent(props: {
                   comment: value,
                   origin: "file",
                 })
-                setCommenting(null)
+                setNote("commenting", null)
               }}
               onPopoverFocusOut={(e: FocusEvent) => {
                 const current = e.currentTarget as HTMLDivElement
@@ -454,7 +432,7 @@ export function FileTabContent(props: {
 
                 setTimeout(() => {
                   if (!document.activeElement || !current.contains(document.activeElement)) {
-                    setCommenting(null)
+                    setNote("commenting", null)
                   }
                 }, 0)
               }}

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

@@ -9,6 +9,37 @@ import { SessionTurn } from "@opencode-ai/ui/session-turn"
 import type { UserMessage } from "@opencode-ai/sdk/v2"
 import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
 
+const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
+  const current = target instanceof Element ? target : undefined
+  const nested = current?.closest("[data-scrollable]")
+  if (!nested || nested === root) return root
+  if (!(nested instanceof HTMLElement)) return root
+  return nested
+}
+
+const markBoundaryGesture = (input: {
+  root: HTMLDivElement
+  target: EventTarget | null
+  delta: number
+  onMarkScrollGesture: (target?: EventTarget | null) => void
+}) => {
+  const target = boundaryTarget(input.root, input.target)
+  if (target === input.root) {
+    input.onMarkScrollGesture(input.root)
+    return
+  }
+  if (
+    shouldMarkBoundaryGesture({
+      delta: input.delta,
+      scrollTop: target.scrollTop,
+      scrollHeight: target.scrollHeight,
+      clientHeight: target.clientHeight,
+    })
+  ) {
+    input.onMarkScrollGesture(input.root)
+  }
+}
+
 export function MessageTimeline(props: {
   mobileChanges: boolean
   mobileFallback: JSX.Element
@@ -86,35 +117,13 @@ export function MessageTimeline(props: {
           ref={props.setScrollRef}
           onWheel={(e) => {
             const root = e.currentTarget
-            const target = e.target instanceof Element ? e.target : undefined
-            const nested = target?.closest("[data-scrollable]")
-            if (!nested || nested === root) {
-              props.onMarkScrollGesture(root)
-              return
-            }
-
-            if (!(nested instanceof HTMLElement)) {
-              props.onMarkScrollGesture(root)
-              return
-            }
-
             const delta = normalizeWheelDelta({
               deltaY: e.deltaY,
               deltaMode: e.deltaMode,
               rootHeight: root.clientHeight,
             })
             if (!delta) return
-
-            if (
-              shouldMarkBoundaryGesture({
-                delta,
-                scrollTop: nested.scrollTop,
-                scrollHeight: nested.scrollHeight,
-                clientHeight: nested.clientHeight,
-              })
-            ) {
-              props.onMarkScrollGesture(root)
-            }
+            markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
           }}
           onTouchStart={(e) => {
             touchGesture = e.touches[0]?.clientY
@@ -129,28 +138,7 @@ export function MessageTimeline(props: {
             if (!delta) return
 
             const root = e.currentTarget
-            const target = e.target instanceof Element ? e.target : undefined
-            const nested = target?.closest("[data-scrollable]")
-            if (!nested || nested === root) {
-              props.onMarkScrollGesture(root)
-              return
-            }
-
-            if (!(nested instanceof HTMLElement)) {
-              props.onMarkScrollGesture(root)
-              return
-            }
-
-            if (
-              shouldMarkBoundaryGesture({
-                delta,
-                scrollTop: nested.scrollTop,
-                scrollHeight: nested.scrollHeight,
-                clientHeight: nested.clientHeight,
-              })
-            ) {
-              props.onMarkScrollGesture(root)
-            }
+            markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
           }}
           onTouchEnd={() => {
             touchGesture = undefined

+ 9 - 5
packages/app/src/pages/session/review-tab.tsx

@@ -1,4 +1,5 @@
-import { createEffect, on, onCleanup, createSignal, type JSX } from "solid-js"
+import { createEffect, on, onCleanup, type JSX } from "solid-js"
+import { createStore } from "solid-js/store"
 import type { FileDiff } from "@opencode-ai/sdk/v2"
 import { SessionReview } from "@opencode-ai/ui/session-review"
 import type { SelectedLineRange } from "@/context/file"
@@ -30,7 +31,7 @@ export interface SessionReviewTabProps {
 }
 
 export function StickyAddButton(props: { children: JSX.Element }) {
-  const [stuck, setStuck] = createSignal(false)
+  const [state, setState] = createStore({ stuck: false })
   let button: HTMLDivElement | undefined
 
   createEffect(() => {
@@ -43,7 +44,7 @@ export function StickyAddButton(props: { children: JSX.Element }) {
     const handler = () => {
       const rect = node.getBoundingClientRect()
       const scrollRect = scroll.getBoundingClientRect()
-      setStuck(rect.right >= scrollRect.right && scroll.scrollWidth > scroll.clientWidth)
+      setState("stuck", rect.right >= scrollRect.right && scroll.scrollWidth > scroll.clientWidth)
     }
 
     scroll.addEventListener("scroll", handler, { passive: true })
@@ -60,7 +61,7 @@ export function StickyAddButton(props: { children: JSX.Element }) {
     <div
       ref={button}
       class="bg-background-base h-full shrink-0 sticky right-0 z-10 flex items-center justify-center border-b border-border-weak-base px-3"
-      classList={{ "border-l": stuck() }}
+      classList={{ "border-l": state.stuck }}
     >
       {props.children}
     </div>
@@ -78,7 +79,10 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
     return sdk.client.file
       .read({ path })
       .then((x) => x.data)
-      .catch(() => undefined)
+      .catch((error) => {
+        console.debug("[session-review] failed to read file", { path, error })
+        return undefined
+      })
   }
 
   const restoreScroll = () => {

+ 6 - 8
packages/app/src/pages/session/session-mobile-tabs.tsx

@@ -1,8 +1,9 @@
-import { Match, Show, Switch } from "solid-js"
+import { Show } from "solid-js"
 import { Tabs } from "@opencode-ai/ui/tabs"
 
 export function SessionMobileTabs(props: {
   open: boolean
+  mobileTab: "session" | "changes"
   hasReview: boolean
   reviewCount: number
   onSession: () => void
@@ -11,7 +12,7 @@ export function SessionMobileTabs(props: {
 }) {
   return (
     <Show when={props.open}>
-      <Tabs class="h-auto">
+      <Tabs value={props.mobileTab} class="h-auto">
         <Tabs.List>
           <Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }} onClick={props.onSession}>
             {props.t("session.tab.session")}
@@ -22,12 +23,9 @@ export function SessionMobileTabs(props: {
             classes={{ button: "w-full" }}
             onClick={props.onChanges}
           >
-            <Switch>
-              <Match when={props.hasReview}>
-                {props.t("session.review.filesChanged", { count: props.reviewCount })}
-              </Match>
-              <Match when={true}>{props.t("session.review.change.other")}</Match>
-            </Switch>
+            {props.hasReview
+              ? props.t("session.review.filesChanged", { count: props.reviewCount })
+              : props.t("session.review.change.other")}
           </Tabs.Trigger>
         </Tabs.List>
       </Tabs>

+ 4 - 5
packages/app/src/pages/session/session-prompt-dock.tsx

@@ -1,15 +1,14 @@
-import { For, Show, type ComponentProps } from "solid-js"
+import { For, Show } from "solid-js"
+import type { QuestionRequest } from "@opencode-ai/sdk/v2"
 import { Button } from "@opencode-ai/ui/button"
 import { BasicTool } from "@opencode-ai/ui/basic-tool"
 import { PromptInput } from "@/components/prompt-input"
 import { QuestionDock } from "@/components/question-dock"
 import { questionSubtitle } from "@/pages/session/session-prompt-helpers"
 
-const questionDockRequest = (value: unknown) => value as ComponentProps<typeof QuestionDock>["request"]
-
 export function SessionPromptDock(props: {
   centered: boolean
-  questionRequest: () => { questions: unknown[] } | undefined
+  questionRequest: () => QuestionRequest | undefined
   permissionRequest: () => { patterns: string[]; permission: string } | undefined
   blocked: boolean
   promptReady: boolean
@@ -48,7 +47,7 @@ export function SessionPromptDock(props: {
                     subtitle,
                   }}
                 />
-                <QuestionDock request={questionDockRequest(req)} />
+                <QuestionDock request={req} />
               </div>
             )
           }}

+ 14 - 10
packages/app/src/pages/session/session-side-panel.tsx

@@ -21,6 +21,14 @@ import { useFile, type SelectedLineRange } from "@/context/file"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 import { useSync } from "@/context/sync"
+import type { Message, UserMessage } from "@opencode-ai/sdk/v2/client"
+
+type SessionSidePanelViewModel = {
+  messages: () => Message[]
+  visibleUserMessages: () => UserMessage[]
+  view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
+  info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
+}
 
 export function SessionSidePanel(props: {
   open: boolean
@@ -31,7 +39,6 @@ export function SessionSidePanel(props: {
   dialog: ReturnType<typeof useDialog>
   file: ReturnType<typeof useFile>
   comments: ReturnType<typeof useComments>
-  sync: ReturnType<typeof useSync>
   hasReview: boolean
   reviewCount: number
   reviewTab: boolean
@@ -43,10 +50,7 @@ export function SessionSidePanel(props: {
   openTab: (value: string) => void
   showAllFiles: () => void
   reviewPanel: () => JSX.Element
-  messages: () => unknown[]
-  visibleUserMessages: () => unknown[]
-  view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
-  info: () => unknown
+  vm: SessionSidePanelViewModel
   handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
   codeComponent: NonNullable<ValidComponent>
   addCommentToContext: (input: {
@@ -187,10 +191,10 @@ export function SessionSidePanel(props: {
                         <Show when={props.activeTab() === "context"}>
                           <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
                             <SessionContextTab
-                              messages={props.messages as never}
-                              visibleUserMessages={props.visibleUserMessages as never}
-                              view={props.view as never}
-                              info={props.info as never}
+                              messages={props.vm.messages}
+                              visibleUserMessages={props.vm.visibleUserMessages}
+                              view={props.vm.view}
+                              info={props.vm.info}
                             />
                           </div>
                         </Show>
@@ -203,7 +207,7 @@ export function SessionSidePanel(props: {
                           tab={tab}
                           activeTab={props.activeTab}
                           tabs={props.tabs}
-                          view={props.view}
+                          view={props.vm.view}
                           handoffFiles={props.handoffFiles}
                           file={props.file}
                           comments={props.comments}

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

@@ -1,4 +1,4 @@
-import { createMemo, For, Show } from "solid-js"
+import { For, Show } from "solid-js"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -141,9 +141,8 @@ export function TerminalPanel(props: {
             <DragOverlay>
               <Show when={props.activeTerminalDraggable()}>
                 {(draggedId) => {
-                  const pty = createMemo(() => props.terminal.all().find((t: LocalPTY) => t.id === draggedId()))
                   return (
-                    <Show when={pty()}>
+                    <Show when={props.terminal.all().find((t: LocalPTY) => t.id === draggedId())}>
                       {(t) => (
                         <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
                           {terminalTabLabel({

+ 72 - 80
packages/app/src/pages/session/use-session-commands.tsx

@@ -1,8 +1,8 @@
 import { createMemo } from "solid-js"
 import { useNavigate, useParams } from "@solidjs/router"
-import { useCommand } from "@/context/command"
+import { useCommand, type CommandOption } from "@/context/command"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { useFile, selectionFromLines, type FileSelection } from "@/context/file"
+import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 import { useLocal } from "@/context/local"
@@ -22,7 +22,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
 import { combineCommandSections } from "@/pages/session/helpers"
 import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
 
-export const useSessionCommands = (input: {
+export type SessionCommandContext = {
   command: ReturnType<typeof useCommand>
   dialog: ReturnType<typeof useDialog>
   file: ReturnType<typeof useFile>
@@ -49,32 +49,48 @@ export const useSessionCommands = (input: {
   setActiveMessage: (message: UserMessage | undefined) => void
   addSelectionToContext: (path: string, selection: FileSelection) => void
   focusInput: () => void
-}) => {
+}
+
+const withCategory = (category: string) => {
+  return (option: Omit<CommandOption, "category">): CommandOption => ({
+    ...option,
+    category,
+  })
+}
+
+export const useSessionCommands = (input: SessionCommandContext) => {
+  const sessionCommand = withCategory(input.language.t("command.category.session"))
+  const fileCommand = withCategory(input.language.t("command.category.file"))
+  const contextCommand = withCategory(input.language.t("command.category.context"))
+  const viewCommand = withCategory(input.language.t("command.category.view"))
+  const terminalCommand = withCategory(input.language.t("command.category.terminal"))
+  const modelCommand = withCategory(input.language.t("command.category.model"))
+  const mcpCommand = withCategory(input.language.t("command.category.mcp"))
+  const agentCommand = withCategory(input.language.t("command.category.agent"))
+  const permissionsCommand = withCategory(input.language.t("command.category.permissions"))
+
   const sessionCommands = createMemo(() => [
-    {
+    sessionCommand({
       id: "session.new",
       title: input.language.t("command.session.new"),
-      category: input.language.t("command.category.session"),
       keybind: "mod+shift+s",
       slash: "new",
       onSelect: () => input.navigate(`/${input.params.dir}/session`),
-    },
+    }),
   ])
 
   const fileCommands = createMemo(() => [
-    {
+    fileCommand({
       id: "file.open",
       title: input.language.t("command.file.open"),
       description: input.language.t("palette.search.placeholder"),
-      category: input.language.t("command.category.file"),
       keybind: "mod+p",
       slash: "open",
       onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />),
-    },
-    {
+    }),
+    fileCommand({
       id: "tab.close",
       title: input.language.t("command.tab.close"),
-      category: input.language.t("command.category.file"),
       keybind: "mod+w",
       disabled: !input.tabs().active(),
       onSelect: () => {
@@ -82,15 +98,14 @@ export const useSessionCommands = (input: {
         if (!active) return
         input.tabs().close(active)
       },
-    },
+    }),
   ])
 
   const contextCommands = createMemo(() => [
-    {
+    contextCommand({
       id: "context.addSelection",
       title: input.language.t("command.context.addSelection"),
       description: input.language.t("command.context.addSelection.description"),
-      category: input.language.t("command.category.context"),
       keybind: "mod+shift+l",
       disabled: !canAddSelectionContext({
         active: input.tabs().active(),
@@ -103,7 +118,7 @@ export const useSessionCommands = (input: {
         const path = input.file.pathFromTab(active)
         if (!path) return
 
-        const range = input.file.selectedLines(path)
+        const range = input.file.selectedLines(path) as SelectedLineRange | null | undefined
         if (!range) {
           showToast({
             title: input.language.t("toast.context.noLineSelection.title"),
@@ -114,58 +129,49 @@ export const useSessionCommands = (input: {
 
         input.addSelectionToContext(path, selectionFromLines(range))
       },
-    },
+    }),
   ])
 
   const viewCommands = createMemo(() => [
-    {
+    viewCommand({
       id: "terminal.toggle",
       title: input.language.t("command.terminal.toggle"),
-      description: "",
-      category: input.language.t("command.category.view"),
       keybind: "ctrl+`",
       slash: "terminal",
       onSelect: () => input.view().terminal.toggle(),
-    },
-    {
+    }),
+    viewCommand({
       id: "review.toggle",
       title: input.language.t("command.review.toggle"),
-      description: "",
-      category: input.language.t("command.category.view"),
       keybind: "mod+shift+r",
       onSelect: () => input.view().reviewPanel.toggle(),
-    },
-    {
+    }),
+    viewCommand({
       id: "fileTree.toggle",
       title: input.language.t("command.fileTree.toggle"),
-      description: "",
-      category: input.language.t("command.category.view"),
       keybind: "mod+\\",
       onSelect: () => input.layout.fileTree.toggle(),
-    },
-    {
+    }),
+    viewCommand({
       id: "input.focus",
       title: input.language.t("command.input.focus"),
-      category: input.language.t("command.category.view"),
       keybind: "ctrl+l",
       onSelect: () => input.focusInput(),
-    },
-    {
+    }),
+    terminalCommand({
       id: "terminal.new",
       title: input.language.t("command.terminal.new"),
       description: input.language.t("command.terminal.new.description"),
-      category: input.language.t("command.category.terminal"),
       keybind: "ctrl+alt+t",
       onSelect: () => {
         if (input.terminal.all().length > 0) input.terminal.new()
         input.view().terminal.open()
       },
-    },
-    {
+    }),
+    viewCommand({
       id: "steps.toggle",
       title: input.language.t("command.steps.toggle"),
       description: input.language.t("command.steps.toggle.description"),
-      category: input.language.t("command.category.view"),
       keybind: "mod+e",
       slash: "steps",
       disabled: !input.params.id,
@@ -174,86 +180,78 @@ export const useSessionCommands = (input: {
         if (!msg) return
         input.setExpanded(msg.id, (open: boolean | undefined) => !open)
       },
-    },
+    }),
   ])
 
   const messageCommands = createMemo(() => [
-    {
+    sessionCommand({
       id: "message.previous",
       title: input.language.t("command.message.previous"),
       description: input.language.t("command.message.previous.description"),
-      category: input.language.t("command.category.session"),
       keybind: "mod+arrowup",
       disabled: !input.params.id,
       onSelect: () => input.navigateMessageByOffset(-1),
-    },
-    {
+    }),
+    sessionCommand({
       id: "message.next",
       title: input.language.t("command.message.next"),
       description: input.language.t("command.message.next.description"),
-      category: input.language.t("command.category.session"),
       keybind: "mod+arrowdown",
       disabled: !input.params.id,
       onSelect: () => input.navigateMessageByOffset(1),
-    },
+    }),
   ])
 
   const agentCommands = createMemo(() => [
-    {
+    modelCommand({
       id: "model.choose",
       title: input.language.t("command.model.choose"),
       description: input.language.t("command.model.choose.description"),
-      category: input.language.t("command.category.model"),
       keybind: "mod+'",
       slash: "model",
       onSelect: () => input.dialog.show(() => <DialogSelectModel />),
-    },
-    {
+    }),
+    mcpCommand({
       id: "mcp.toggle",
       title: input.language.t("command.mcp.toggle"),
       description: input.language.t("command.mcp.toggle.description"),
-      category: input.language.t("command.category.mcp"),
       keybind: "mod+;",
       slash: "mcp",
       onSelect: () => input.dialog.show(() => <DialogSelectMcp />),
-    },
-    {
+    }),
+    agentCommand({
       id: "agent.cycle",
       title: input.language.t("command.agent.cycle"),
       description: input.language.t("command.agent.cycle.description"),
-      category: input.language.t("command.category.agent"),
       keybind: "mod+.",
       slash: "agent",
       onSelect: () => input.local.agent.move(1),
-    },
-    {
+    }),
+    agentCommand({
       id: "agent.cycle.reverse",
       title: input.language.t("command.agent.cycle.reverse"),
       description: input.language.t("command.agent.cycle.reverse.description"),
-      category: input.language.t("command.category.agent"),
       keybind: "shift+mod+.",
       onSelect: () => input.local.agent.move(-1),
-    },
-    {
+    }),
+    modelCommand({
       id: "model.variant.cycle",
       title: input.language.t("command.model.variant.cycle"),
       description: input.language.t("command.model.variant.cycle.description"),
-      category: input.language.t("command.category.model"),
       keybind: "shift+mod+d",
       onSelect: () => {
         input.local.model.variant.cycle()
       },
-    },
+    }),
   ])
 
   const permissionCommands = createMemo(() => [
-    {
+    permissionsCommand({
       id: "permissions.autoaccept",
       title:
         input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory)
           ? input.language.t("command.permissions.autoaccept.disable")
           : input.language.t("command.permissions.autoaccept.enable"),
-      category: input.language.t("command.category.permissions"),
       keybind: "mod+shift+a",
       disabled: !input.params.id || !input.permission.permissionsEnabled(),
       onSelect: () => {
@@ -269,15 +267,14 @@ export const useSessionCommands = (input: {
             : input.language.t("toast.permissions.autoaccept.off.description"),
         })
       },
-    },
+    }),
   ])
 
   const sessionActionCommands = createMemo(() => [
-    {
+    sessionCommand({
       id: "session.undo",
       title: input.language.t("command.session.undo"),
       description: input.language.t("command.session.undo.description"),
-      category: input.language.t("command.category.session"),
       slash: "undo",
       disabled: !input.params.id || input.visibleUserMessages().length === 0,
       onSelect: async () => {
@@ -298,12 +295,11 @@ export const useSessionCommands = (input: {
         const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id)
         input.setActiveMessage(priorMessage)
       },
-    },
-    {
+    }),
+    sessionCommand({
       id: "session.redo",
       title: input.language.t("command.session.redo"),
       description: input.language.t("command.session.redo.description"),
-      category: input.language.t("command.category.session"),
       slash: "redo",
       disabled: !input.params.id || !input.info()?.revert?.messageID,
       onSelect: async () => {
@@ -323,12 +319,11 @@ export const useSessionCommands = (input: {
         const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id)
         input.setActiveMessage(priorMsg)
       },
-    },
-    {
+    }),
+    sessionCommand({
       id: "session.compact",
       title: input.language.t("command.session.compact"),
       description: input.language.t("command.session.compact.description"),
-      category: input.language.t("command.category.session"),
       slash: "compact",
       disabled: !input.params.id || input.visibleUserMessages().length === 0,
       onSelect: async () => {
@@ -348,22 +343,21 @@ export const useSessionCommands = (input: {
           providerID: model.provider.id,
         })
       },
-    },
-    {
+    }),
+    sessionCommand({
       id: "session.fork",
       title: input.language.t("command.session.fork"),
       description: input.language.t("command.session.fork.description"),
-      category: input.language.t("command.category.session"),
       slash: "fork",
       disabled: !input.params.id || input.visibleUserMessages().length === 0,
       onSelect: () => input.dialog.show(() => <DialogFork />),
-    },
+    }),
   ])
 
   const shareCommands = createMemo(() => {
     if (input.sync.data.config.share === "disabled") return []
     return [
-      {
+      sessionCommand({
         id: "session.share",
         title: input.info()?.share?.url
           ? input.language.t("session.share.copy.copyLink")
@@ -371,7 +365,6 @@ export const useSessionCommands = (input: {
         description: input.info()?.share?.url
           ? input.language.t("toast.session.share.success.description")
           : input.language.t("command.session.share.description"),
-        category: input.language.t("command.category.session"),
         slash: "share",
         disabled: !input.params.id,
         onSelect: async () => {
@@ -441,12 +434,11 @@ export const useSessionCommands = (input: {
 
           await copy(url, false)
         },
-      },
-      {
+      }),
+      sessionCommand({
         id: "session.unshare",
         title: input.language.t("command.session.unshare"),
         description: input.language.t("command.session.unshare.description"),
-        category: input.language.t("command.category.session"),
         slash: "unshare",
         disabled: !input.params.id || !input.info()?.share?.url,
         onSelect: async () => {
@@ -468,7 +460,7 @@ export const useSessionCommands = (input: {
               }),
             )
         },
-      },
+      }),
     ]
   })
 

+ 36 - 42
packages/app/src/utils/solid-dnd.tsx

@@ -1,55 +1,49 @@
 import { useDragDropContext } from "@thisbeyond/solid-dnd"
-import { JSXElement } from "solid-js"
 import type { Transformer } from "@thisbeyond/solid-dnd"
+import { createRoot, onCleanup, type JSXElement } from "solid-js"
+
+type DragEvent = { draggable?: { id?: unknown } }
+
+const isDragEvent = (event: unknown): event is DragEvent => {
+  if (typeof event !== "object" || event === null) return false
+  return "draggable" in event
+}
 
 export const getDraggableId = (event: unknown): string | undefined => {
-  if (typeof event !== "object" || event === null) return undefined
-  if (!("draggable" in event)) return undefined
-  const draggable = (event as { draggable?: { id?: unknown } }).draggable
+  if (!isDragEvent(event)) return undefined
+  const draggable = event.draggable
   if (!draggable) return undefined
   return typeof draggable.id === "string" ? draggable.id : undefined
 }
 
-export const ConstrainDragXAxis = (): JSXElement => {
-  const context = useDragDropContext()
-  if (!context) return <></>
-  const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
-  const transformer: Transformer = {
-    id: "constrain-x-axis",
-    order: 100,
-    callback: (transform) => ({ ...transform, x: 0 }),
-  }
-  onDragStart((event) => {
-    const id = getDraggableId(event)
-    if (!id) return
-    addTransformer("draggables", id, transformer)
-  })
-  onDragEnd((event) => {
-    const id = getDraggableId(event)
-    if (!id) return
-    removeTransformer("draggables", id, transformer.id)
-  })
-  return <></>
-}
+const createTransformer = (id: string, axis: "x" | "y"): Transformer => ({
+  id,
+  order: 100,
+  callback: (transform) => (axis === "x" ? { ...transform, x: 0 } : { ...transform, y: 0 }),
+})
 
-export const ConstrainDragYAxis = (): JSXElement => {
+const createAxisConstraint = (axis: "x" | "y", transformerId: string) => (): JSXElement => {
   const context = useDragDropContext()
-  if (!context) return <></>
+  if (!context) return null
   const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
-  const transformer: Transformer = {
-    id: "constrain-y-axis",
-    order: 100,
-    callback: (transform) => ({ ...transform, y: 0 }),
-  }
-  onDragStart((event) => {
-    const id = getDraggableId(event)
-    if (!id) return
-    addTransformer("draggables", id, transformer)
-  })
-  onDragEnd((event) => {
-    const id = getDraggableId(event)
-    if (!id) return
-    removeTransformer("draggables", id, transformer.id)
+  const transformer = createTransformer(transformerId, axis)
+  const dispose = createRoot((dispose) => {
+    onDragStart((event) => {
+      const id = getDraggableId(event)
+      if (!id) return
+      addTransformer("draggables", id, transformer)
+    })
+    onDragEnd((event) => {
+      const id = getDraggableId(event)
+      if (!id) return
+      removeTransformer("draggables", id, transformer.id)
+    })
+    return dispose
   })
-  return <></>
+  onCleanup(dispose)
+  return null
 }
+
+export const ConstrainDragXAxis = createAxisConstraint("x", "constrain-x-axis")
+
+export const ConstrainDragYAxis = createAxisConstraint("y", "constrain-y-axis")