Jelajahi Sumber

chore: refactoring and tests (#12468)

Adam 2 bulan lalu
induk
melakukan
a4bc883595
39 mengubah file dengan 3798 tambahan dan 1488 penghapusan
  1. 45 88
      .github/workflows/test.yml
  2. 2 1
      packages/app/package.json
  3. 77 0
      packages/app/src/components/file-tree.test.ts
  4. 50 10
      packages/app/src/components/file-tree.tsx
  5. 53 857
      packages/app/src/components/prompt-input.tsx
  6. 132 0
      packages/app/src/components/prompt-input/attachments.ts
  7. 51 0
      packages/app/src/components/prompt-input/editor-dom.test.ts
  8. 135 0
      packages/app/src/components/prompt-input/editor-dom.ts
  9. 69 0
      packages/app/src/components/prompt-input/history.test.ts
  10. 160 0
      packages/app/src/components/prompt-input/history.ts
  11. 587 0
      packages/app/src/components/prompt-input/submit.ts
  12. 7 24
      packages/app/src/components/session-context-usage.tsx
  13. 93 0
      packages/app/src/components/session/session-context-metrics.test.ts
  14. 94 0
      packages/app/src/components/session/session-context-metrics.ts
  15. 7 40
      packages/app/src/components/session/session-context-tab.tsx
  16. 111 0
      packages/app/src/context/comments.test.ts
  17. 86 66
      packages/app/src/context/comments.tsx
  18. 85 0
      packages/app/src/context/file-content-eviction-accounting.test.ts
  19. 83 60
      packages/app/src/context/file.tsx
  20. 69 0
      packages/app/src/context/layout.test.ts
  21. 49 45
      packages/app/src/context/layout.tsx
  22. 38 0
      packages/app/src/context/terminal.test.ts
  23. 17 7
      packages/app/src/context/terminal.tsx
  24. 213 290
      packages/app/src/pages/session.tsx
  25. 61 0
      packages/app/src/pages/session/helpers.test.ts
  26. 38 0
      packages/app/src/pages/session/helpers.ts
  27. 127 0
      packages/app/src/pages/session/scroll-spy.test.ts
  28. 274 0
      packages/app/src/pages/session/scroll-spy.ts
  29. 69 0
      packages/app/src/utils/scoped-cache.test.ts
  30. 104 0
      packages/app/src/utils/scoped-cache.ts
  31. 105 0
      specs/09-session-page-hot-paths.md
  32. 99 0
      specs/10-file-content-eviction-accounting.md
  33. 92 0
      specs/11-layout-view-tabs-reactivity.md
  34. 96 0
      specs/12-session-context-metrics-shared.md
  35. 88 0
      specs/13-file-tree-fetch-discipline.md
  36. 87 0
      specs/14-comments-aggregation-index.md
  37. 104 0
      specs/15-prompt-input-modularization.md
  38. 82 0
      specs/16-terminal-cache-key-clarity.md
  39. 59 0
      specs/parallel-agent-plan.md

+ 45 - 88
.github/workflows/test.yml

@@ -7,8 +7,32 @@ on:
   pull_request:
   pull_request:
   workflow_dispatch:
   workflow_dispatch:
 jobs:
 jobs:
-  test:
-    name: test (${{ matrix.settings.name }})
+  unit:
+    name: unit (linux)
+    runs-on: blacksmith-4vcpu-ubuntu-2404
+    defaults:
+      run:
+        shell: bash
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+        with:
+          token: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Setup Bun
+        uses: ./.github/actions/setup-bun
+
+      - name: Configure git identity
+        run: |
+          git config --global user.email "[email protected]"
+          git config --global user.name "opencode"
+
+      - name: Run unit tests
+        run: bun turbo test
+
+  e2e:
+    name: e2e (${{ matrix.settings.name }})
+    needs: unit
     strategy:
     strategy:
       fail-fast: false
       fail-fast: false
       matrix:
       matrix:
@@ -16,17 +40,12 @@ jobs:
           - name: linux
           - name: linux
             host: blacksmith-4vcpu-ubuntu-2404
             host: blacksmith-4vcpu-ubuntu-2404
             playwright: bunx playwright install --with-deps
             playwright: bunx playwright install --with-deps
-            workdir: .
-            command: |
-              git config --global user.email "[email protected]"
-              git config --global user.name "opencode"
-              bun turbo test
           - name: windows
           - name: windows
             host: blacksmith-4vcpu-windows-2025
             host: blacksmith-4vcpu-windows-2025
             playwright: bunx playwright install
             playwright: bunx playwright install
-            workdir: packages/app
-            command: bun test:e2e:local
     runs-on: ${{ matrix.settings.host }}
     runs-on: ${{ matrix.settings.host }}
+    env:
+      PLAYWRIGHT_BROWSERS_PATH: 0
     defaults:
     defaults:
       run:
       run:
         shell: bash
         shell: bash
@@ -43,87 +62,10 @@ jobs:
         working-directory: packages/app
         working-directory: packages/app
         run: ${{ matrix.settings.playwright }}
         run: ${{ matrix.settings.playwright }}
 
 
-      - name: Set OS-specific paths
-        run: |
-          if [ "${{ runner.os }}" = "Windows" ]; then
-            printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}\\opencode-e2e" >> "$GITHUB_ENV"
-            printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}\\opencode-e2e\\home" >> "$GITHUB_ENV"
-            printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}\\opencode-e2e\\share" >> "$GITHUB_ENV"
-            printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}\\opencode-e2e\\cache" >> "$GITHUB_ENV"
-            printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}\\opencode-e2e\\config" >> "$GITHUB_ENV"
-            printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}\\opencode-e2e\\state" >> "$GITHUB_ENV"
-          else
-            printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}/opencode-e2e" >> "$GITHUB_ENV"
-            printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}/opencode-e2e/home" >> "$GITHUB_ENV"
-            printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}/opencode-e2e/share" >> "$GITHUB_ENV"
-            printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}/opencode-e2e/cache" >> "$GITHUB_ENV"
-            printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}/opencode-e2e/config" >> "$GITHUB_ENV"
-            printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}/opencode-e2e/state" >> "$GITHUB_ENV"
-          fi
-
-      - name: Seed opencode data
-        if: matrix.settings.name != 'windows'
-        working-directory: packages/opencode
-        run: bun script/seed-e2e.ts
-        env:
-          OPENCODE_DISABLE_SHARE: "true"
-          OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
-          OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
-          OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
-          OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
-          XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
-          XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
-          XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
-          XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
-          OPENCODE_E2E_PROJECT_DIR: ${{ github.workspace }}
-          OPENCODE_E2E_SESSION_TITLE: "E2E Session"
-          OPENCODE_E2E_MESSAGE: "Seeded for UI e2e"
-          OPENCODE_E2E_MODEL: "opencode/gpt-5-nano"
-
-      - name: Run opencode server
-        if: matrix.settings.name != 'windows'
-        working-directory: packages/opencode
-        run: bun dev -- --print-logs --log-level WARN serve --port 4096 --hostname 127.0.0.1 &
-        env:
-          OPENCODE_DISABLE_SHARE: "true"
-          OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
-          OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
-          OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
-          OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
-          XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
-          XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
-          XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
-          XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
-          OPENCODE_CLIENT: "app"
-
-      - name: Wait for opencode server
-        if: matrix.settings.name != 'windows'
-        run: |
-          for i in {1..120}; do
-            curl -fsS "http://127.0.0.1:4096/global/health" > /dev/null && exit 0
-            sleep 1
-          done
-          exit 1
-
-      - name: run
-        working-directory: ${{ matrix.settings.workdir }}
-        run: ${{ matrix.settings.command }}
+      - name: Run app e2e tests
+        run: bun --cwd packages/app test:e2e:local
         env:
         env:
           CI: true
           CI: true
-          OPENCODE_DISABLE_SHARE: "true"
-          OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
-          OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
-          OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
-          OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
-          XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
-          XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
-          XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
-          XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
-          PLAYWRIGHT_SERVER_HOST: "127.0.0.1"
-          PLAYWRIGHT_SERVER_PORT: "4096"
-          VITE_OPENCODE_SERVER_HOST: "127.0.0.1"
-          VITE_OPENCODE_SERVER_PORT: "4096"
-          OPENCODE_CLIENT: "app"
         timeout-minutes: 30
         timeout-minutes: 30
 
 
       - name: Upload Playwright artifacts
       - name: Upload Playwright artifacts
@@ -136,3 +78,18 @@ jobs:
           path: |
           path: |
             packages/app/e2e/test-results
             packages/app/e2e/test-results
             packages/app/e2e/playwright-report
             packages/app/e2e/playwright-report
+
+  required:
+    name: test (linux)
+    runs-on: blacksmith-4vcpu-ubuntu-2404
+    needs:
+      - unit
+      - e2e
+    if: always()
+    steps:
+      - name: Verify upstream test jobs passed
+        run: |
+          echo "unit=${{ needs.unit.result }}"
+          echo "e2e=${{ needs.e2e.result }}"
+          test "${{ needs.unit.result }}" = "success"
+          test "${{ needs.e2e.result }}" = "success"

+ 2 - 1
packages/app/package.json

@@ -14,7 +14,8 @@
     "dev": "vite",
     "dev": "vite",
     "build": "vite build",
     "build": "vite build",
     "serve": "vite preview",
     "serve": "vite preview",
-    "test": "playwright test",
+    "test": "bun run test:unit",
+    "test:unit": "bun test ./src",
     "test:e2e": "playwright test",
     "test:e2e": "playwright test",
     "test:e2e:local": "bun script/e2e-local.ts",
     "test:e2e:local": "bun script/e2e-local.ts",
     "test:e2e:ui": "playwright test --ui",
     "test:e2e:ui": "playwright test --ui",

+ 77 - 0
packages/app/src/components/file-tree.test.ts

@@ -0,0 +1,77 @@
+import { beforeAll, describe, expect, mock, test } from "bun:test"
+
+let shouldListRoot: typeof import("./file-tree").shouldListRoot
+let shouldListExpanded: typeof import("./file-tree").shouldListExpanded
+let dirsToExpand: typeof import("./file-tree").dirsToExpand
+
+beforeAll(async () => {
+  mock.module("@solidjs/router", () => ({
+    useParams: () => ({}),
+  }))
+  mock.module("@/context/file", () => ({
+    useFile: () => ({
+      tree: {
+        state: () => undefined,
+        list: () => Promise.resolve(),
+        children: () => [],
+        expand: () => {},
+        collapse: () => {},
+      },
+    }),
+  }))
+  mock.module("@opencode-ai/ui/collapsible", () => ({
+    Collapsible: {
+      Trigger: (props: { children?: unknown }) => props.children,
+      Content: (props: { children?: unknown }) => props.children,
+    },
+  }))
+  mock.module("@opencode-ai/ui/file-icon", () => ({ FileIcon: () => null }))
+  mock.module("@opencode-ai/ui/icon", () => ({ Icon: () => null }))
+  mock.module("@opencode-ai/ui/tooltip", () => ({ Tooltip: (props: { children?: unknown }) => props.children }))
+  const mod = await import("./file-tree")
+  shouldListRoot = mod.shouldListRoot
+  shouldListExpanded = mod.shouldListExpanded
+  dirsToExpand = mod.dirsToExpand
+})
+
+describe("file tree fetch discipline", () => {
+  test("root lists on mount unless already loaded or loading", () => {
+    expect(shouldListRoot({ level: 0 })).toBe(true)
+    expect(shouldListRoot({ level: 0, dir: { loaded: true } })).toBe(false)
+    expect(shouldListRoot({ level: 0, dir: { loading: true } })).toBe(false)
+    expect(shouldListRoot({ level: 1 })).toBe(false)
+  })
+
+  test("nested dirs list only when expanded and stale", () => {
+    expect(shouldListExpanded({ level: 1 })).toBe(false)
+    expect(shouldListExpanded({ level: 1, dir: { expanded: false } })).toBe(false)
+    expect(shouldListExpanded({ level: 1, dir: { expanded: true } })).toBe(true)
+    expect(shouldListExpanded({ level: 1, dir: { expanded: true, loaded: true } })).toBe(false)
+    expect(shouldListExpanded({ level: 1, dir: { expanded: true, loading: true } })).toBe(false)
+    expect(shouldListExpanded({ level: 0, dir: { expanded: true } })).toBe(false)
+  })
+
+  test("allowed auto-expand picks only collapsed dirs", () => {
+    const expanded = new Set<string>()
+    const filter = { dirs: new Set(["src", "src/components"]) }
+
+    const first = dirsToExpand({
+      level: 0,
+      filter,
+      expanded: (dir) => expanded.has(dir),
+    })
+
+    expect(first).toEqual(["src", "src/components"])
+
+    for (const dir of first) expanded.add(dir)
+
+    const second = dirsToExpand({
+      level: 0,
+      filter,
+      expanded: (dir) => expanded.has(dir),
+    })
+
+    expect(second).toEqual([])
+    expect(dirsToExpand({ level: 1, filter, expanded: () => false })).toEqual([])
+  })
+})

+ 50 - 10
packages/app/src/components/file-tree.tsx

@@ -8,6 +8,7 @@ import {
   createMemo,
   createMemo,
   For,
   For,
   Match,
   Match,
+  on,
   Show,
   Show,
   splitProps,
   splitProps,
   Switch,
   Switch,
@@ -25,6 +26,34 @@ type Filter = {
   dirs: Set<string>
   dirs: Set<string>
 }
 }
 
 
+export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) {
+  if (input.level !== 0) return false
+  if (input.dir?.loaded) return false
+  if (input.dir?.loading) return false
+  return true
+}
+
+export function shouldListExpanded(input: {
+  level: number
+  dir?: { expanded?: boolean; loaded?: boolean; loading?: boolean }
+}) {
+  if (input.level === 0) return false
+  if (!input.dir?.expanded) return false
+  if (input.dir.loaded) return false
+  if (input.dir.loading) return false
+  return true
+}
+
+export function dirsToExpand(input: {
+  level: number
+  filter?: { dirs: Set<string> }
+  expanded: (dir: string) => boolean
+}) {
+  if (input.level !== 0) return []
+  if (!input.filter) return []
+  return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
+}
+
 export default function FileTree(props: {
 export default function FileTree(props: {
   path: string
   path: string
   class?: string
   class?: string
@@ -111,19 +140,30 @@ export default function FileTree(props: {
 
 
   createEffect(() => {
   createEffect(() => {
     const current = filter()
     const current = filter()
-    if (!current) return
-    if (level !== 0) return
-
-    for (const dir of current.dirs) {
-      const expanded = untrack(() => file.tree.state(dir)?.expanded) ?? false
-      if (expanded) continue
-      file.tree.expand(dir)
-    }
+    const dirs = dirsToExpand({
+      level,
+      filter: current,
+      expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
+    })
+    for (const dir of dirs) file.tree.expand(dir)
   })
   })
 
 
+  createEffect(
+    on(
+      () => props.path,
+      (path) => {
+        const dir = untrack(() => file.tree.state(path))
+        if (!shouldListRoot({ level, dir })) return
+        void file.tree.list(path)
+      },
+      { defer: false },
+    ),
+  )
+
   createEffect(() => {
   createEffect(() => {
-    const path = props.path
-    untrack(() => void file.tree.list(path))
+    const dir = file.tree.state(props.path)
+    if (!shouldListExpanded({ level, dir })) return
+    void file.tree.list(props.path)
   })
   })
 
 
   const nodes = createMemo(() => {
   const nodes = createMemo(() => {

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

@@ -1,21 +1,9 @@
 import { useFilteredList } from "@opencode-ai/ui/hooks"
 import { useFilteredList } from "@opencode-ai/ui/hooks"
-import {
-  createEffect,
-  on,
-  Component,
-  Show,
-  For,
-  onMount,
-  onCleanup,
-  Switch,
-  Match,
-  createMemo,
-  createSignal,
-} from "solid-js"
-import { createStore, produce } from "solid-js/store"
+import { createEffect, on, Component, Show, For, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
+import { createStore } from "solid-js/store"
 import { createFocusSignal } from "@solid-primitives/active-element"
 import { createFocusSignal } from "@solid-primitives/active-element"
 import { useLocal } from "@/context/local"
 import { useLocal } from "@/context/local"
-import { useFile, type FileSelection } from "@/context/file"
+import { useFile } from "@/context/file"
 import {
 import {
   ContentPart,
   ContentPart,
   DEFAULT_PROMPT,
   DEFAULT_PROMPT,
@@ -28,7 +16,7 @@ import {
 } from "@/context/prompt"
 } from "@/context/prompt"
 import { useLayout } from "@/context/layout"
 import { useLayout } from "@/context/layout"
 import { useSDK } from "@/context/sdk"
 import { useSDK } from "@/context/sdk"
-import { useNavigate, useParams } from "@solidjs/router"
+import { useParams } from "@solidjs/router"
 import { useSync } from "@/context/sync"
 import { useSync } from "@/context/sync"
 import { useComments } from "@/context/comments"
 import { useComments } from "@/context/comments"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
@@ -47,27 +35,13 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid
 import { useProviders } from "@/hooks/use-providers"
 import { useProviders } from "@/hooks/use-providers"
 import { useCommand } from "@/context/command"
 import { useCommand } from "@/context/command"
 import { Persist, persisted } from "@/utils/persist"
 import { Persist, persisted } from "@/utils/persist"
-import { Identifier } from "@/utils/id"
-import { Worktree as WorktreeState } from "@/utils/worktree"
 import { SessionContextUsage } from "@/components/session-context-usage"
 import { SessionContextUsage } from "@/components/session-context-usage"
 import { usePermission } from "@/context/permission"
 import { usePermission } from "@/context/permission"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
-import { useGlobalSync } from "@/context/global-sync"
-import { usePlatform } from "@/context/platform"
-import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client"
-import { Binary } from "@opencode-ai/util/binary"
-import { showToast } from "@opencode-ai/ui/toast"
-import { base64Encode } from "@opencode-ai/util/encode"
-
-const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
-const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
-
-type PendingPrompt = {
-  abort: AbortController
-  cleanup: VoidFunction
-}
-
-const pending = new Map<string, PendingPrompt>()
+import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
+import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
+import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history"
+import { createPromptSubmit } from "./prompt-input/submit"
 
 
 interface PromptInputProps {
 interface PromptInputProps {
   class?: string
   class?: string
@@ -116,11 +90,8 @@ interface SlashCommand {
 }
 }
 
 
 export const PromptInput: Component<PromptInputProps> = (props) => {
 export const PromptInput: Component<PromptInputProps> = (props) => {
-  const navigate = useNavigate()
   const sdk = useSDK()
   const sdk = useSDK()
   const sync = useSync()
   const sync = useSync()
-  const globalSync = useGlobalSync()
-  const platform = usePlatform()
   const local = useLocal()
   const local = useLocal()
   const files = useFile()
   const files = useFile()
   const prompt = usePrompt()
   const prompt = usePrompt()
@@ -272,20 +243,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }),
     }),
   )
   )
 
 
-  const clonePromptParts = (prompt: Prompt): Prompt =>
-    prompt.map((part) => {
-      if (part.type === "text") return { ...part }
-      if (part.type === "image") return { ...part }
-      if (part.type === "agent") return { ...part }
-      return {
-        ...part,
-        selection: part.selection ? { ...part.selection } : undefined,
-      }
-    })
-
-  const promptLength = (prompt: Prompt) =>
-    prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
-
   const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
   const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
     const length = position === "start" ? 0 : promptLength(p)
     const length = position === "start" ? 0 : promptLength(p)
     setStore("applyingHistory", true)
     setStore("applyingHistory", true)
@@ -329,110 +286,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const [composing, setComposing] = createSignal(false)
   const [composing, setComposing] = createSignal(false)
   const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
   const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
 
 
-  const addImageAttachment = async (file: File) => {
-    if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
-
-    const reader = new FileReader()
-    reader.onload = () => {
-      const dataUrl = reader.result as string
-      const attachment: ImageAttachmentPart = {
-        type: "image",
-        id: crypto.randomUUID(),
-        filename: file.name,
-        mime: file.type,
-        dataUrl,
-      }
-      const cursorPosition = prompt.cursor() ?? getCursorPosition(editorRef)
-      prompt.set([...prompt.current(), attachment], cursorPosition)
-    }
-    reader.readAsDataURL(file)
-  }
-
-  const removeImageAttachment = (id: string) => {
-    const current = prompt.current()
-    const next = current.filter((part) => part.type !== "image" || part.id !== id)
-    prompt.set(next, prompt.cursor())
-  }
-
-  const handlePaste = async (event: ClipboardEvent) => {
-    if (!isFocused()) return
-    const clipboardData = event.clipboardData
-    if (!clipboardData) return
-
-    event.preventDefault()
-    event.stopPropagation()
-
-    const items = Array.from(clipboardData.items)
-    const fileItems = items.filter((item) => item.kind === "file")
-    const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
-
-    if (imageItems.length > 0) {
-      for (const item of imageItems) {
-        const file = item.getAsFile()
-        if (file) await addImageAttachment(file)
-      }
-      return
-    }
-
-    if (fileItems.length > 0) {
-      showToast({
-        title: language.t("prompt.toast.pasteUnsupported.title"),
-        description: language.t("prompt.toast.pasteUnsupported.description"),
-      })
-      return
-    }
-
-    const plainText = clipboardData.getData("text/plain") ?? ""
-    if (!plainText) return
-    addPart({ type: "text", content: plainText, start: 0, end: 0 })
-  }
-
-  const handleGlobalDragOver = (event: DragEvent) => {
-    if (dialog.active) return
-
-    event.preventDefault()
-    const hasFiles = event.dataTransfer?.types.includes("Files")
-    if (hasFiles) {
-      setStore("dragging", true)
-    }
-  }
-
-  const handleGlobalDragLeave = (event: DragEvent) => {
-    if (dialog.active) return
-
-    // relatedTarget is null when leaving the document window
-    if (!event.relatedTarget) {
-      setStore("dragging", false)
-    }
-  }
-
-  const handleGlobalDrop = async (event: DragEvent) => {
-    if (dialog.active) return
-
-    event.preventDefault()
-    setStore("dragging", false)
-
-    const dropped = event.dataTransfer?.files
-    if (!dropped) return
-
-    for (const file of Array.from(dropped)) {
-      if (ACCEPTED_FILE_TYPES.includes(file.type)) {
-        await addImageAttachment(file)
-      }
-    }
-  }
-
-  onMount(() => {
-    document.addEventListener("dragover", handleGlobalDragOver)
-    document.addEventListener("dragleave", handleGlobalDragLeave)
-    document.addEventListener("drop", handleGlobalDrop)
-  })
-  onCleanup(() => {
-    document.removeEventListener("dragover", handleGlobalDragOver)
-    document.removeEventListener("dragleave", handleGlobalDragLeave)
-    document.removeEventListener("drop", handleGlobalDrop)
-  })
-
   createEffect(() => {
   createEffect(() => {
     if (!isFocused()) setStore("popover", null)
     if (!isFocused()) setStore("popover", null)
   })
   })
@@ -826,36 +679,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     queueScroll()
     queueScroll()
   }
   }
 
 
-  const setRangeEdge = (range: Range, edge: "start" | "end", offset: number) => {
-    let remaining = offset
-    const nodes = Array.from(editorRef.childNodes)
-
-    for (const node of nodes) {
-      const length = getNodeLength(node)
-      const isText = node.nodeType === Node.TEXT_NODE
-      const isPill =
-        node.nodeType === Node.ELEMENT_NODE &&
-        ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
-      const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
-
-      if (isText && remaining <= length) {
-        if (edge === "start") range.setStart(node, remaining)
-        if (edge === "end") range.setEnd(node, remaining)
-        return
-      }
-
-      if ((isPill || isBreak) && remaining <= length) {
-        if (edge === "start" && remaining === 0) range.setStartBefore(node)
-        if (edge === "start" && remaining > 0) range.setStartAfter(node)
-        if (edge === "end" && remaining === 0) range.setEndBefore(node)
-        if (edge === "end" && remaining > 0) range.setEndAfter(node)
-        return
-      }
-
-      remaining -= length
-    }
-  }
-
   const addPart = (part: ContentPart) => {
   const addPart = (part: ContentPart) => {
     const selection = window.getSelection()
     const selection = window.getSelection()
     if (!selection || selection.rangeCount === 0) return
     if (!selection || selection.rangeCount === 0) return
@@ -873,8 +696,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
 
       if (atMatch) {
       if (atMatch) {
         const start = atMatch.index ?? cursorPosition - atMatch[0].length
         const start = atMatch.index ?? cursorPosition - atMatch[0].length
-        setRangeEdge(range, "start", start)
-        setRangeEdge(range, "end", cursorPosition)
+        setRangeEdge(editorRef, range, "start", start)
+        setRangeEdge(editorRef, range, "end", cursorPosition)
       }
       }
 
 
       range.deleteContents()
       range.deleteContents()
@@ -913,81 +736,57 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     setStore("popover", null)
     setStore("popover", null)
   }
   }
 
 
-  const abort = async () => {
-    const sessionID = params.id
-    if (!sessionID) return Promise.resolve()
-    const queued = pending.get(sessionID)
-    if (queued) {
-      queued.abort.abort()
-      queued.cleanup()
-      pending.delete(sessionID)
-      return Promise.resolve()
-    }
-    return sdk.client.session
-      .abort({
-        sessionID,
-      })
-      .catch(() => {})
-  }
-
   const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
   const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
-    const text = prompt
-      .map((p) => ("content" in p ? p.content : ""))
-      .join("")
-      .trim()
-    const hasImages = prompt.some((part) => part.type === "image")
-    if (!text && !hasImages) return
-
-    const entry = clonePromptParts(prompt)
     const currentHistory = mode === "shell" ? shellHistory : history
     const currentHistory = mode === "shell" ? shellHistory : history
     const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
     const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
-    const lastEntry = currentHistory.entries[0]
-    if (lastEntry && isPromptEqual(lastEntry, entry)) return
-
-    setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
+    const next = prependHistoryEntry(currentHistory.entries, prompt)
+    if (next === currentHistory.entries) return
+    setCurrentHistory("entries", next)
   }
   }
 
 
   const navigateHistory = (direction: "up" | "down") => {
   const navigateHistory = (direction: "up" | "down") => {
-    const entries = store.mode === "shell" ? shellHistory.entries : history.entries
-    const current = store.historyIndex
-
-    if (direction === "up") {
-      if (entries.length === 0) return false
-      if (current === -1) {
-        setStore("savedPrompt", clonePromptParts(prompt.current()))
-        setStore("historyIndex", 0)
-        applyHistoryPrompt(entries[0], "start")
-        return true
-      }
-      if (current < entries.length - 1) {
-        const next = current + 1
-        setStore("historyIndex", next)
-        applyHistoryPrompt(entries[next], "start")
-        return true
-      }
-      return false
-    }
+    const result = navigatePromptHistory({
+      direction,
+      entries: store.mode === "shell" ? shellHistory.entries : history.entries,
+      historyIndex: store.historyIndex,
+      currentPrompt: prompt.current(),
+      savedPrompt: store.savedPrompt,
+    })
+    if (!result.handled) return false
+    setStore("historyIndex", result.historyIndex)
+    setStore("savedPrompt", result.savedPrompt)
+    applyHistoryPrompt(result.prompt, result.cursor)
+    return true
+  }
 
 
-    if (current > 0) {
-      const next = current - 1
-      setStore("historyIndex", next)
-      applyHistoryPrompt(entries[next], "end")
-      return true
-    }
-    if (current === 0) {
-      setStore("historyIndex", -1)
-      const saved = store.savedPrompt
-      if (saved) {
-        applyHistoryPrompt(saved, "end")
-        setStore("savedPrompt", null)
-        return true
-      }
-      applyHistoryPrompt(DEFAULT_PROMPT, "end")
-      return true
-    }
+  const { addImageAttachment, removeImageAttachment, handlePaste } = createPromptAttachments({
+    editor: () => editorRef,
+    isFocused,
+    isDialogActive: () => !!dialog.active,
+    setDragging: (value) => setStore("dragging", value),
+    addPart,
+  })
 
 
-    return false
-  }
+  const { abort, handleSubmit } = createPromptSubmit({
+    info,
+    imageAttachments,
+    commentCount,
+    mode: () => store.mode,
+    working,
+    editor: () => editorRef,
+    queueScroll,
+    promptLength,
+    addToHistory,
+    resetHistoryNavigation: () => {
+      setStore("historyIndex", -1)
+      setStore("savedPrompt", null)
+    },
+    setMode: (mode) => setStore("mode", mode),
+    setPopover: (popover) => setStore("popover", popover),
+    newSessionWorktree: props.newSessionWorktree,
+    onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
+    onSubmit: props.onSubmit,
+  })
 
 
   const handleKeyDown = (event: KeyboardEvent) => {
   const handleKeyDown = (event: KeyboardEvent) => {
     if (event.key === "Backspace") {
     if (event.key === "Backspace") {
@@ -1127,503 +926,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
     }
   }
   }
 
 
-  const handleSubmit = async (event: Event) => {
-    event.preventDefault()
-
-    const currentPrompt = prompt.current()
-    const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
-    const images = imageAttachments().slice()
-    const mode = store.mode
-
-    if (text.trim().length === 0 && images.length === 0 && commentCount() === 0) {
-      if (working()) abort()
-      return
-    }
-
-    const currentModel = local.model.current()
-    const currentAgent = local.agent.current()
-    if (!currentModel || !currentAgent) {
-      showToast({
-        title: language.t("prompt.toast.modelAgentRequired.title"),
-        description: language.t("prompt.toast.modelAgentRequired.description"),
-      })
-      return
-    }
-
-    const errorMessage = (err: unknown) => {
-      if (err && typeof err === "object" && "data" in err) {
-        const data = (err as { data?: { message?: string } }).data
-        if (data?.message) return data.message
-      }
-      if (err instanceof Error) return err.message
-      return language.t("common.requestFailed")
-    }
-
-    addToHistory(currentPrompt, mode)
-    setStore("historyIndex", -1)
-    setStore("savedPrompt", null)
-
-    const projectDirectory = sdk.directory
-    const isNewSession = !params.id
-    const worktreeSelection = props.newSessionWorktree ?? "main"
-
-    let sessionDirectory = projectDirectory
-    let client = sdk.client
-
-    if (isNewSession) {
-      if (worktreeSelection === "create") {
-        const createdWorktree = await client.worktree
-          .create({ directory: projectDirectory })
-          .then((x) => x.data)
-          .catch((err) => {
-            showToast({
-              title: language.t("prompt.toast.worktreeCreateFailed.title"),
-              description: errorMessage(err),
-            })
-            return undefined
-          })
-
-        if (!createdWorktree?.directory) {
-          showToast({
-            title: language.t("prompt.toast.worktreeCreateFailed.title"),
-            description: language.t("common.requestFailed"),
-          })
-          return
-        }
-        WorktreeState.pending(createdWorktree.directory)
-        sessionDirectory = createdWorktree.directory
-      }
-
-      if (worktreeSelection !== "main" && worktreeSelection !== "create") {
-        sessionDirectory = worktreeSelection
-      }
-
-      if (sessionDirectory !== projectDirectory) {
-        client = createOpencodeClient({
-          baseUrl: sdk.url,
-          fetch: platform.fetch,
-          directory: sessionDirectory,
-          throwOnError: true,
-        })
-        globalSync.child(sessionDirectory)
-      }
-
-      props.onNewSessionWorktreeReset?.()
-    }
-
-    let session = info()
-    if (!session && isNewSession) {
-      session = await client.session
-        .create()
-        .then((x) => x.data ?? undefined)
-        .catch((err) => {
-          showToast({
-            title: language.t("prompt.toast.sessionCreateFailed.title"),
-            description: errorMessage(err),
-          })
-          return undefined
-        })
-      if (session) {
-        layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
-        navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
-      }
-    }
-    if (!session) return
-
-    props.onSubmit?.()
-
-    const model = {
-      modelID: currentModel.id,
-      providerID: currentModel.provider.id,
-    }
-    const agent = currentAgent.name
-    const variant = local.model.variant.current()
-
-    const clearInput = () => {
-      prompt.reset()
-      setStore("mode", "normal")
-      setStore("popover", null)
-    }
-
-    const restoreInput = () => {
-      prompt.set(currentPrompt, promptLength(currentPrompt))
-      setStore("mode", mode)
-      setStore("popover", null)
-      requestAnimationFrame(() => {
-        editorRef.focus()
-        setCursorPosition(editorRef, promptLength(currentPrompt))
-        queueScroll()
-      })
-    }
-
-    if (mode === "shell") {
-      clearInput()
-      client.session
-        .shell({
-          sessionID: session.id,
-          agent,
-          model,
-          command: text,
-        })
-        .catch((err) => {
-          showToast({
-            title: language.t("prompt.toast.shellSendFailed.title"),
-            description: errorMessage(err),
-          })
-          restoreInput()
-        })
-      return
-    }
-
-    if (text.startsWith("/")) {
-      const [cmdName, ...args] = text.split(" ")
-      const commandName = cmdName.slice(1)
-      const customCommand = sync.data.command.find((c) => c.name === commandName)
-      if (customCommand) {
-        clearInput()
-        client.session
-          .command({
-            sessionID: session.id,
-            command: commandName,
-            arguments: args.join(" "),
-            agent,
-            model: `${model.providerID}/${model.modelID}`,
-            variant,
-            parts: images.map((attachment) => ({
-              id: Identifier.ascending("part"),
-              type: "file" as const,
-              mime: attachment.mime,
-              url: attachment.dataUrl,
-              filename: attachment.filename,
-            })),
-          })
-          .catch((err) => {
-            showToast({
-              title: language.t("prompt.toast.commandSendFailed.title"),
-              description: errorMessage(err),
-            })
-            restoreInput()
-          })
-        return
-      }
-    }
-
-    const toAbsolutePath = (path: string) =>
-      path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/")
-
-    const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[]
-    const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
-
-    const fileAttachmentParts = fileAttachments.map((attachment) => {
-      const absolute = toAbsolutePath(attachment.path)
-      const query = attachment.selection
-        ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
-        : ""
-      return {
-        id: Identifier.ascending("part"),
-        type: "file" as const,
-        mime: "text/plain",
-        url: `file://${absolute}${query}`,
-        filename: getFilename(attachment.path),
-        source: {
-          type: "file" as const,
-          text: {
-            value: attachment.content,
-            start: attachment.start,
-            end: attachment.end,
-          },
-          path: absolute,
-        },
-      }
-    })
-
-    const agentAttachmentParts = agentAttachments.map((attachment) => ({
-      id: Identifier.ascending("part"),
-      type: "agent" as const,
-      name: attachment.name,
-      source: {
-        value: attachment.content,
-        start: attachment.start,
-        end: attachment.end,
-      },
-    }))
-
-    const usedUrls = new Set(fileAttachmentParts.map((part) => part.url))
-
-    const context = prompt.context.items().slice()
-
-    const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
-
-    const contextParts: Array<
-      | {
-          id: string
-          type: "text"
-          text: string
-          synthetic?: boolean
-        }
-      | {
-          id: string
-          type: "file"
-          mime: string
-          url: string
-          filename?: string
-        }
-    > = []
-
-    const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
-      const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
-      const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
-      const range =
-        start === undefined || end === undefined
-          ? "this file"
-          : start === end
-            ? `line ${start}`
-            : `lines ${start} through ${end}`
-
-      return `The user made the following comment regarding ${range} of ${path}: ${comment}`
-    }
-
-    const addContextFile = (input: { path: string; selection?: FileSelection; comment?: string }) => {
-      const absolute = toAbsolutePath(input.path)
-      const query = input.selection ? `?start=${input.selection.startLine}&end=${input.selection.endLine}` : ""
-      const url = `file://${absolute}${query}`
-
-      const comment = input.comment?.trim()
-      if (!comment && usedUrls.has(url)) return
-      usedUrls.add(url)
-
-      if (comment) {
-        contextParts.push({
-          id: Identifier.ascending("part"),
-          type: "text",
-          text: commentNote(input.path, input.selection, comment),
-          synthetic: true,
-        })
-      }
-
-      contextParts.push({
-        id: Identifier.ascending("part"),
-        type: "file",
-        mime: "text/plain",
-        url,
-        filename: getFilename(input.path),
-      })
-    }
-
-    for (const item of context) {
-      if (item.type !== "file") continue
-      addContextFile({ path: item.path, selection: item.selection, comment: item.comment })
-    }
-
-    const imageAttachmentParts = images.map((attachment) => ({
-      id: Identifier.ascending("part"),
-      type: "file" as const,
-      mime: attachment.mime,
-      url: attachment.dataUrl,
-      filename: attachment.filename,
-    }))
-
-    const messageID = Identifier.ascending("message")
-    const textPart = {
-      id: Identifier.ascending("part"),
-      type: "text" as const,
-      text,
-    }
-    const requestParts = [
-      textPart,
-      ...fileAttachmentParts,
-      ...contextParts,
-      ...agentAttachmentParts,
-      ...imageAttachmentParts,
-    ]
-
-    const optimisticParts = requestParts.map((part) => ({
-      ...part,
-      sessionID: session.id,
-      messageID,
-    })) as unknown as Part[]
-
-    const optimisticMessage: Message = {
-      id: messageID,
-      sessionID: session.id,
-      role: "user",
-      time: { created: Date.now() },
-      agent,
-      model,
-    }
-
-    const addOptimisticMessage = () => {
-      if (sessionDirectory === projectDirectory) {
-        sync.set(
-          produce((draft) => {
-            const messages = draft.message[session.id]
-            if (!messages) {
-              draft.message[session.id] = [optimisticMessage]
-            } else {
-              const result = Binary.search(messages, messageID, (m) => m.id)
-              messages.splice(result.index, 0, optimisticMessage)
-            }
-            draft.part[messageID] = optimisticParts
-              .filter((p) => !!p?.id)
-              .slice()
-              .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
-          }),
-        )
-        return
-      }
-
-      globalSync.child(sessionDirectory)[1](
-        produce((draft) => {
-          const messages = draft.message[session.id]
-          if (!messages) {
-            draft.message[session.id] = [optimisticMessage]
-          } else {
-            const result = Binary.search(messages, messageID, (m) => m.id)
-            messages.splice(result.index, 0, optimisticMessage)
-          }
-          draft.part[messageID] = optimisticParts
-            .filter((p) => !!p?.id)
-            .slice()
-            .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
-        }),
-      )
-    }
-
-    const removeOptimisticMessage = () => {
-      if (sessionDirectory === projectDirectory) {
-        sync.set(
-          produce((draft) => {
-            const messages = draft.message[session.id]
-            if (messages) {
-              const result = Binary.search(messages, messageID, (m) => m.id)
-              if (result.found) messages.splice(result.index, 1)
-            }
-            delete draft.part[messageID]
-          }),
-        )
-        return
-      }
-
-      globalSync.child(sessionDirectory)[1](
-        produce((draft) => {
-          const messages = draft.message[session.id]
-          if (messages) {
-            const result = Binary.search(messages, messageID, (m) => m.id)
-            if (result.found) messages.splice(result.index, 1)
-          }
-          delete draft.part[messageID]
-        }),
-      )
-    }
-
-    for (const item of commentItems) {
-      prompt.context.remove(item.key)
-    }
-
-    clearInput()
-    addOptimisticMessage()
-
-    const waitForWorktree = async () => {
-      const worktree = WorktreeState.get(sessionDirectory)
-      if (!worktree || worktree.status !== "pending") return true
-
-      if (sessionDirectory === projectDirectory) {
-        sync.set("session_status", session.id, { type: "busy" })
-      }
-
-      const controller = new AbortController()
-
-      const cleanup = () => {
-        if (sessionDirectory === projectDirectory) {
-          sync.set("session_status", session.id, { type: "idle" })
-        }
-        removeOptimisticMessage()
-        for (const item of commentItems) {
-          prompt.context.add({
-            type: "file",
-            path: item.path,
-            selection: item.selection,
-            comment: item.comment,
-            commentID: item.commentID,
-            commentOrigin: item.commentOrigin,
-            preview: item.preview,
-          })
-        }
-        restoreInput()
-      }
-
-      pending.set(session.id, { abort: controller, cleanup })
-
-      const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
-        if (controller.signal.aborted) {
-          resolve({ status: "failed", message: "aborted" })
-          return
-        }
-        controller.signal.addEventListener(
-          "abort",
-          () => {
-            resolve({ status: "failed", message: "aborted" })
-          },
-          { once: true },
-        )
-      })
-
-      const timeoutMs = 5 * 60 * 1000
-      const timer = { id: undefined as number | undefined }
-      const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
-        timer.id = window.setTimeout(() => {
-          resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
-        }, timeoutMs)
-      })
-
-      const result = await Promise.race([WorktreeState.wait(sessionDirectory), abort, timeout]).finally(() => {
-        if (timer.id === undefined) return
-        clearTimeout(timer.id)
-      })
-      pending.delete(session.id)
-      if (controller.signal.aborted) return false
-      if (result.status === "failed") throw new Error(result.message)
-      return true
-    }
-
-    const send = async () => {
-      const ok = await waitForWorktree()
-      if (!ok) return
-      await client.session.prompt({
-        sessionID: session.id,
-        agent,
-        model,
-        messageID,
-        parts: requestParts,
-        variant,
-      })
-    }
-
-    void send().catch((err) => {
-      pending.delete(session.id)
-      if (sessionDirectory === projectDirectory) {
-        sync.set("session_status", session.id, { type: "idle" })
-      }
-      showToast({
-        title: language.t("prompt.toast.promptSendFailed.title"),
-        description: errorMessage(err),
-      })
-      removeOptimisticMessage()
-      for (const item of commentItems) {
-        prompt.context.add({
-          type: "file",
-          path: item.path,
-          selection: item.selection,
-          comment: item.comment,
-          commentID: item.commentID,
-          commentOrigin: item.commentOrigin,
-          preview: item.preview,
-        })
-      }
-      restoreInput()
-    })
-  }
-
   return (
   return (
     <div class="relative size-full _max-h-[320px] flex flex-col gap-3">
     <div class="relative size-full _max-h-[320px] flex flex-col gap-3">
       <Show when={store.popover}>
       <Show when={store.popover}>
@@ -2087,109 +1389,3 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     </div>
     </div>
   )
   )
 }
 }
-
-function createTextFragment(content: string): DocumentFragment {
-  const fragment = document.createDocumentFragment()
-  const segments = content.split("\n")
-  segments.forEach((segment, index) => {
-    if (segment) {
-      fragment.appendChild(document.createTextNode(segment))
-    } else if (segments.length > 1) {
-      fragment.appendChild(document.createTextNode("\u200B"))
-    }
-    if (index < segments.length - 1) {
-      fragment.appendChild(document.createElement("br"))
-    }
-  })
-  return fragment
-}
-
-function getNodeLength(node: Node): number {
-  if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
-  return (node.textContent ?? "").replace(/\u200B/g, "").length
-}
-
-function getTextLength(node: Node): number {
-  if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
-  if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
-  let length = 0
-  for (const child of Array.from(node.childNodes)) {
-    length += getTextLength(child)
-  }
-  return length
-}
-
-function getCursorPosition(parent: HTMLElement): number {
-  const selection = window.getSelection()
-  if (!selection || selection.rangeCount === 0) return 0
-  const range = selection.getRangeAt(0)
-  if (!parent.contains(range.startContainer)) return 0
-  const preCaretRange = range.cloneRange()
-  preCaretRange.selectNodeContents(parent)
-  preCaretRange.setEnd(range.startContainer, range.startOffset)
-  return getTextLength(preCaretRange.cloneContents())
-}
-
-function setCursorPosition(parent: HTMLElement, position: number) {
-  let remaining = position
-  let node = parent.firstChild
-  while (node) {
-    const length = getNodeLength(node)
-    const isText = node.nodeType === Node.TEXT_NODE
-    const isPill =
-      node.nodeType === Node.ELEMENT_NODE &&
-      ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
-    const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
-
-    if (isText && remaining <= length) {
-      const range = document.createRange()
-      const selection = window.getSelection()
-      range.setStart(node, remaining)
-      range.collapse(true)
-      selection?.removeAllRanges()
-      selection?.addRange(range)
-      return
-    }
-
-    if ((isPill || isBreak) && remaining <= length) {
-      const range = document.createRange()
-      const selection = window.getSelection()
-      if (remaining === 0) {
-        range.setStartBefore(node)
-      }
-      if (remaining > 0 && isPill) {
-        range.setStartAfter(node)
-      }
-      if (remaining > 0 && isBreak) {
-        const next = node.nextSibling
-        if (next && next.nodeType === Node.TEXT_NODE) {
-          range.setStart(next, 0)
-        }
-        if (!next || next.nodeType !== Node.TEXT_NODE) {
-          range.setStartAfter(node)
-        }
-      }
-      range.collapse(true)
-      selection?.removeAllRanges()
-      selection?.addRange(range)
-      return
-    }
-
-    remaining -= length
-    node = node.nextSibling
-  }
-
-  const fallbackRange = document.createRange()
-  const fallbackSelection = window.getSelection()
-  const last = parent.lastChild
-  if (last && last.nodeType === Node.TEXT_NODE) {
-    const len = last.textContent ? last.textContent.length : 0
-    fallbackRange.setStart(last, len)
-  }
-  if (!last || last.nodeType !== Node.TEXT_NODE) {
-    fallbackRange.selectNodeContents(parent)
-  }
-  fallbackRange.collapse(false)
-  fallbackSelection?.removeAllRanges()
-  fallbackSelection?.addRange(fallbackRange)
-}

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

@@ -0,0 +1,132 @@
+import { onCleanup, onMount } from "solid-js"
+import { showToast } from "@opencode-ai/ui/toast"
+import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
+import { useLanguage } from "@/context/language"
+import { getCursorPosition } from "./editor-dom"
+
+export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
+export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
+
+type PromptAttachmentsInput = {
+  editor: () => HTMLDivElement | undefined
+  isFocused: () => boolean
+  isDialogActive: () => boolean
+  setDragging: (value: boolean) => void
+  addPart: (part: ContentPart) => void
+}
+
+export function createPromptAttachments(input: PromptAttachmentsInput) {
+  const prompt = usePrompt()
+  const language = useLanguage()
+
+  const addImageAttachment = async (file: File) => {
+    if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
+
+    const reader = new FileReader()
+    reader.onload = () => {
+      const editor = input.editor()
+      if (!editor) return
+      const dataUrl = reader.result as string
+      const attachment: ImageAttachmentPart = {
+        type: "image",
+        id: crypto.randomUUID(),
+        filename: file.name,
+        mime: file.type,
+        dataUrl,
+      }
+      const cursorPosition = prompt.cursor() ?? getCursorPosition(editor)
+      prompt.set([...prompt.current(), attachment], cursorPosition)
+    }
+    reader.readAsDataURL(file)
+  }
+
+  const removeImageAttachment = (id: string) => {
+    const current = prompt.current()
+    const next = current.filter((part) => part.type !== "image" || part.id !== id)
+    prompt.set(next, prompt.cursor())
+  }
+
+  const handlePaste = async (event: ClipboardEvent) => {
+    if (!input.isFocused()) return
+    const clipboardData = event.clipboardData
+    if (!clipboardData) return
+
+    event.preventDefault()
+    event.stopPropagation()
+
+    const items = Array.from(clipboardData.items)
+    const fileItems = items.filter((item) => item.kind === "file")
+    const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
+
+    if (imageItems.length > 0) {
+      for (const item of imageItems) {
+        const file = item.getAsFile()
+        if (file) await addImageAttachment(file)
+      }
+      return
+    }
+
+    if (fileItems.length > 0) {
+      showToast({
+        title: language.t("prompt.toast.pasteUnsupported.title"),
+        description: language.t("prompt.toast.pasteUnsupported.description"),
+      })
+      return
+    }
+
+    const plainText = clipboardData.getData("text/plain") ?? ""
+    if (!plainText) return
+    input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
+  }
+
+  const handleGlobalDragOver = (event: DragEvent) => {
+    if (input.isDialogActive()) return
+
+    event.preventDefault()
+    const hasFiles = event.dataTransfer?.types.includes("Files")
+    if (hasFiles) {
+      input.setDragging(true)
+    }
+  }
+
+  const handleGlobalDragLeave = (event: DragEvent) => {
+    if (input.isDialogActive()) return
+    if (!event.relatedTarget) {
+      input.setDragging(false)
+    }
+  }
+
+  const handleGlobalDrop = async (event: DragEvent) => {
+    if (input.isDialogActive()) return
+
+    event.preventDefault()
+    input.setDragging(false)
+
+    const dropped = event.dataTransfer?.files
+    if (!dropped) return
+
+    for (const file of Array.from(dropped)) {
+      if (ACCEPTED_FILE_TYPES.includes(file.type)) {
+        await addImageAttachment(file)
+      }
+    }
+  }
+
+  onMount(() => {
+    document.addEventListener("dragover", handleGlobalDragOver)
+    document.addEventListener("dragleave", handleGlobalDragLeave)
+    document.addEventListener("drop", handleGlobalDrop)
+  })
+
+  onCleanup(() => {
+    document.removeEventListener("dragover", handleGlobalDragOver)
+    document.removeEventListener("dragleave", handleGlobalDragLeave)
+    document.removeEventListener("drop", handleGlobalDrop)
+  })
+
+  return {
+    addImageAttachment,
+    removeImageAttachment,
+    handlePaste,
+  }
+}

+ 51 - 0
packages/app/src/components/prompt-input/editor-dom.test.ts

@@ -0,0 +1,51 @@
+import { describe, expect, test } from "bun:test"
+import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
+
+describe("prompt-input editor dom", () => {
+  test("createTextFragment preserves newlines with br and zero-width placeholders", () => {
+    const fragment = createTextFragment("foo\n\nbar")
+    const container = document.createElement("div")
+    container.appendChild(fragment)
+
+    expect(container.childNodes.length).toBe(5)
+    expect(container.childNodes[0]?.textContent).toBe("foo")
+    expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
+    expect(container.childNodes[2]?.textContent).toBe("\u200B")
+    expect((container.childNodes[3] as HTMLElement).tagName).toBe("BR")
+    expect(container.childNodes[4]?.textContent).toBe("bar")
+  })
+
+  test("length helpers treat breaks as one char and ignore zero-width chars", () => {
+    const container = document.createElement("div")
+    container.appendChild(document.createTextNode("ab\u200B"))
+    container.appendChild(document.createElement("br"))
+    container.appendChild(document.createTextNode("cd"))
+
+    expect(getNodeLength(container.childNodes[0]!)).toBe(2)
+    expect(getNodeLength(container.childNodes[1]!)).toBe(1)
+    expect(getTextLength(container)).toBe(5)
+  })
+
+  test("setCursorPosition and getCursorPosition round-trip with pills and breaks", () => {
+    const container = document.createElement("div")
+    const pill = document.createElement("span")
+    pill.dataset.type = "file"
+    pill.textContent = "@file"
+    container.appendChild(document.createTextNode("ab"))
+    container.appendChild(pill)
+    container.appendChild(document.createElement("br"))
+    container.appendChild(document.createTextNode("cd"))
+    document.body.appendChild(container)
+
+    setCursorPosition(container, 2)
+    expect(getCursorPosition(container)).toBe(2)
+
+    setCursorPosition(container, 7)
+    expect(getCursorPosition(container)).toBe(7)
+
+    setCursorPosition(container, 8)
+    expect(getCursorPosition(container)).toBe(8)
+
+    container.remove()
+  })
+})

+ 135 - 0
packages/app/src/components/prompt-input/editor-dom.ts

@@ -0,0 +1,135 @@
+export function createTextFragment(content: string): DocumentFragment {
+  const fragment = document.createDocumentFragment()
+  const segments = content.split("\n")
+  segments.forEach((segment, index) => {
+    if (segment) {
+      fragment.appendChild(document.createTextNode(segment))
+    } else if (segments.length > 1) {
+      fragment.appendChild(document.createTextNode("\u200B"))
+    }
+    if (index < segments.length - 1) {
+      fragment.appendChild(document.createElement("br"))
+    }
+  })
+  return fragment
+}
+
+export function getNodeLength(node: Node): number {
+  if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
+  return (node.textContent ?? "").replace(/\u200B/g, "").length
+}
+
+export function getTextLength(node: Node): number {
+  if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
+  if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
+  let length = 0
+  for (const child of Array.from(node.childNodes)) {
+    length += getTextLength(child)
+  }
+  return length
+}
+
+export function getCursorPosition(parent: HTMLElement): number {
+  const selection = window.getSelection()
+  if (!selection || selection.rangeCount === 0) return 0
+  const range = selection.getRangeAt(0)
+  if (!parent.contains(range.startContainer)) return 0
+  const preCaretRange = range.cloneRange()
+  preCaretRange.selectNodeContents(parent)
+  preCaretRange.setEnd(range.startContainer, range.startOffset)
+  return getTextLength(preCaretRange.cloneContents())
+}
+
+export function setCursorPosition(parent: HTMLElement, position: number) {
+  let remaining = position
+  let node = parent.firstChild
+  while (node) {
+    const length = getNodeLength(node)
+    const isText = node.nodeType === Node.TEXT_NODE
+    const isPill =
+      node.nodeType === Node.ELEMENT_NODE &&
+      ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
+    const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
+
+    if (isText && remaining <= length) {
+      const range = document.createRange()
+      const selection = window.getSelection()
+      range.setStart(node, remaining)
+      range.collapse(true)
+      selection?.removeAllRanges()
+      selection?.addRange(range)
+      return
+    }
+
+    if ((isPill || isBreak) && remaining <= length) {
+      const range = document.createRange()
+      const selection = window.getSelection()
+      if (remaining === 0) {
+        range.setStartBefore(node)
+      }
+      if (remaining > 0 && isPill) {
+        range.setStartAfter(node)
+      }
+      if (remaining > 0 && isBreak) {
+        const next = node.nextSibling
+        if (next && next.nodeType === Node.TEXT_NODE) {
+          range.setStart(next, 0)
+        }
+        if (!next || next.nodeType !== Node.TEXT_NODE) {
+          range.setStartAfter(node)
+        }
+      }
+      range.collapse(true)
+      selection?.removeAllRanges()
+      selection?.addRange(range)
+      return
+    }
+
+    remaining -= length
+    node = node.nextSibling
+  }
+
+  const fallbackRange = document.createRange()
+  const fallbackSelection = window.getSelection()
+  const last = parent.lastChild
+  if (last && last.nodeType === Node.TEXT_NODE) {
+    const len = last.textContent ? last.textContent.length : 0
+    fallbackRange.setStart(last, len)
+  }
+  if (!last || last.nodeType !== Node.TEXT_NODE) {
+    fallbackRange.selectNodeContents(parent)
+  }
+  fallbackRange.collapse(false)
+  fallbackSelection?.removeAllRanges()
+  fallbackSelection?.addRange(fallbackRange)
+}
+
+export function setRangeEdge(parent: HTMLElement, range: Range, edge: "start" | "end", offset: number) {
+  let remaining = offset
+  const nodes = Array.from(parent.childNodes)
+
+  for (const node of nodes) {
+    const length = getNodeLength(node)
+    const isText = node.nodeType === Node.TEXT_NODE
+    const isPill =
+      node.nodeType === Node.ELEMENT_NODE &&
+      ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
+    const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
+
+    if (isText && remaining <= length) {
+      if (edge === "start") range.setStart(node, remaining)
+      if (edge === "end") range.setEnd(node, remaining)
+      return
+    }
+
+    if ((isPill || isBreak) && remaining <= length) {
+      if (edge === "start" && remaining === 0) range.setStartBefore(node)
+      if (edge === "start" && remaining > 0) range.setStartAfter(node)
+      if (edge === "end" && remaining === 0) range.setEndBefore(node)
+      if (edge === "end" && remaining > 0) range.setEndAfter(node)
+      return
+    }
+
+    remaining -= length
+  }
+}

+ 69 - 0
packages/app/src/components/prompt-input/history.test.ts

@@ -0,0 +1,69 @@
+import { describe, expect, test } from "bun:test"
+import type { Prompt } from "@/context/prompt"
+import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history"
+
+const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
+
+const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
+
+describe("prompt-input history", () => {
+  test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => {
+    const first = prependHistoryEntry([], DEFAULT_PROMPT)
+    expect(first).toEqual([])
+
+    const withOne = prependHistoryEntry([], text("hello"))
+    expect(withOne).toHaveLength(1)
+
+    const deduped = prependHistoryEntry(withOne, text("hello"))
+    expect(deduped).toBe(withOne)
+  })
+
+  test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
+    const entries = [text("third"), text("second"), text("first")]
+    const up = navigatePromptHistory({
+      direction: "up",
+      entries,
+      historyIndex: -1,
+      currentPrompt: text("draft"),
+      savedPrompt: null,
+    })
+    expect(up.handled).toBe(true)
+    if (!up.handled) throw new Error("expected handled")
+    expect(up.historyIndex).toBe(0)
+    expect(up.cursor).toBe("start")
+
+    const down = navigatePromptHistory({
+      direction: "down",
+      entries,
+      historyIndex: up.historyIndex,
+      currentPrompt: text("ignored"),
+      savedPrompt: up.savedPrompt,
+    })
+    expect(down.handled).toBe(true)
+    if (!down.handled) throw new Error("expected handled")
+    expect(down.historyIndex).toBe(-1)
+    expect(down.prompt[0]?.type === "text" ? down.prompt[0].content : "").toBe("draft")
+  })
+
+  test("helpers clone prompt and count text content length", () => {
+    const original: Prompt = [
+      { type: "text", content: "one", start: 0, end: 3 },
+      {
+        type: "file",
+        path: "src/a.ts",
+        content: "@src/a.ts",
+        start: 3,
+        end: 12,
+        selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
+      },
+      { type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" },
+    ]
+    const copy = clonePromptParts(original)
+    expect(copy).not.toBe(original)
+    expect(promptLength(copy)).toBe(12)
+    if (copy[1]?.type !== "file") throw new Error("expected file")
+    copy[1].selection!.startLine = 9
+    if (original[1]?.type !== "file") throw new Error("expected file")
+    expect(original[1].selection?.startLine).toBe(1)
+  })
+})

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

@@ -0,0 +1,160 @@
+import type { Prompt } from "@/context/prompt"
+
+const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
+
+export const MAX_HISTORY = 100
+
+export function clonePromptParts(prompt: Prompt): Prompt {
+  return prompt.map((part) => {
+    if (part.type === "text") return { ...part }
+    if (part.type === "image") return { ...part }
+    if (part.type === "agent") return { ...part }
+    return {
+      ...part,
+      selection: part.selection ? { ...part.selection } : undefined,
+    }
+  })
+}
+
+export function promptLength(prompt: Prompt) {
+  return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
+}
+
+export function prependHistoryEntry(entries: Prompt[], prompt: Prompt, max = MAX_HISTORY) {
+  const text = prompt
+    .map((part) => ("content" in part ? part.content : ""))
+    .join("")
+    .trim()
+  const hasImages = prompt.some((part) => part.type === "image")
+  if (!text && !hasImages) return entries
+
+  const entry = clonePromptParts(prompt)
+  const last = entries[0]
+  if (last && isPromptEqual(last, entry)) return entries
+  return [entry, ...entries].slice(0, max)
+}
+
+function isPromptEqual(promptA: Prompt, promptB: Prompt) {
+  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.type === "text" ? partB.content : "")) return false
+    if (partA.type === "file") {
+      if (partA.path !== (partB.type === "file" ? partB.path : "")) return false
+      const a = partA.selection
+      const b = partB.type === "file" ? partB.selection : undefined
+      const sameSelection =
+        (!a && !b) ||
+        (!!a &&
+          !!b &&
+          a.startLine === b.startLine &&
+          a.startChar === b.startChar &&
+          a.endLine === b.endLine &&
+          a.endChar === b.endChar)
+      if (!sameSelection) return false
+    }
+    if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false
+    if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
+  }
+  return true
+}
+
+type HistoryNavInput = {
+  direction: "up" | "down"
+  entries: Prompt[]
+  historyIndex: number
+  currentPrompt: Prompt
+  savedPrompt: Prompt | null
+}
+
+type HistoryNavResult =
+  | {
+      handled: false
+      historyIndex: number
+      savedPrompt: Prompt | null
+    }
+  | {
+      handled: true
+      historyIndex: number
+      savedPrompt: Prompt | null
+      prompt: Prompt
+      cursor: "start" | "end"
+    }
+
+export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult {
+  if (input.direction === "up") {
+    if (input.entries.length === 0) {
+      return {
+        handled: false,
+        historyIndex: input.historyIndex,
+        savedPrompt: input.savedPrompt,
+      }
+    }
+
+    if (input.historyIndex === -1) {
+      return {
+        handled: true,
+        historyIndex: 0,
+        savedPrompt: clonePromptParts(input.currentPrompt),
+        prompt: input.entries[0],
+        cursor: "start",
+      }
+    }
+
+    if (input.historyIndex < input.entries.length - 1) {
+      const next = input.historyIndex + 1
+      return {
+        handled: true,
+        historyIndex: next,
+        savedPrompt: input.savedPrompt,
+        prompt: input.entries[next],
+        cursor: "start",
+      }
+    }
+
+    return {
+      handled: false,
+      historyIndex: input.historyIndex,
+      savedPrompt: input.savedPrompt,
+    }
+  }
+
+  if (input.historyIndex > 0) {
+    const next = input.historyIndex - 1
+    return {
+      handled: true,
+      historyIndex: next,
+      savedPrompt: input.savedPrompt,
+      prompt: input.entries[next],
+      cursor: "end",
+    }
+  }
+
+  if (input.historyIndex === 0) {
+    if (input.savedPrompt) {
+      return {
+        handled: true,
+        historyIndex: -1,
+        savedPrompt: null,
+        prompt: input.savedPrompt,
+        cursor: "end",
+      }
+    }
+
+    return {
+      handled: true,
+      historyIndex: -1,
+      savedPrompt: null,
+      prompt: DEFAULT_PROMPT,
+      cursor: "end",
+    }
+  }
+
+  return {
+    handled: false,
+    historyIndex: input.historyIndex,
+    savedPrompt: input.savedPrompt,
+  }
+}

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

@@ -0,0 +1,587 @@
+import { Accessor } from "solid-js"
+import { produce } from "solid-js/store"
+import { useNavigate, useParams } from "@solidjs/router"
+import { getFilename } from "@opencode-ai/util/path"
+import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client"
+import { Binary } from "@opencode-ai/util/binary"
+import { showToast } from "@opencode-ai/ui/toast"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { useLocal } from "@/context/local"
+import {
+  usePrompt,
+  type AgentPart,
+  type FileAttachmentPart,
+  type ImageAttachmentPart,
+  type Prompt,
+} from "@/context/prompt"
+import { useLayout } from "@/context/layout"
+import { useSDK } from "@/context/sdk"
+import { useSync } from "@/context/sync"
+import { useGlobalSync } from "@/context/global-sync"
+import { usePlatform } from "@/context/platform"
+import { useLanguage } from "@/context/language"
+import { Identifier } from "@/utils/id"
+import { Worktree as WorktreeState } from "@/utils/worktree"
+import type { FileSelection } from "@/context/file"
+import { setCursorPosition } from "./editor-dom"
+
+type PendingPrompt = {
+  abort: AbortController
+  cleanup: VoidFunction
+}
+
+const pending = new Map<string, PendingPrompt>()
+
+type PromptSubmitInput = {
+  info: Accessor<{ id: string } | undefined>
+  imageAttachments: Accessor<ImageAttachmentPart[]>
+  commentCount: Accessor<number>
+  mode: Accessor<"normal" | "shell">
+  working: Accessor<boolean>
+  editor: () => HTMLDivElement | undefined
+  queueScroll: () => void
+  promptLength: (prompt: Prompt) => number
+  addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
+  resetHistoryNavigation: () => void
+  setMode: (mode: "normal" | "shell") => void
+  setPopover: (popover: "at" | "slash" | null) => void
+  newSessionWorktree?: string
+  onNewSessionWorktreeReset?: () => void
+  onSubmit?: () => void
+}
+
+type CommentItem = {
+  path: string
+  selection?: FileSelection
+  comment?: string
+  commentID?: string
+  commentOrigin?: "review" | "file"
+  preview?: string
+}
+
+export function createPromptSubmit(input: PromptSubmitInput) {
+  const navigate = useNavigate()
+  const sdk = useSDK()
+  const sync = useSync()
+  const globalSync = useGlobalSync()
+  const platform = usePlatform()
+  const local = useLocal()
+  const prompt = usePrompt()
+  const layout = useLayout()
+  const language = useLanguage()
+  const params = useParams()
+
+  const errorMessage = (err: unknown) => {
+    if (err && typeof err === "object" && "data" in err) {
+      const data = (err as { data?: { message?: string } }).data
+      if (data?.message) return data.message
+    }
+    if (err instanceof Error) return err.message
+    return language.t("common.requestFailed")
+  }
+
+  const abort = async () => {
+    const sessionID = params.id
+    if (!sessionID) return Promise.resolve()
+    const queued = pending.get(sessionID)
+    if (queued) {
+      queued.abort.abort()
+      queued.cleanup()
+      pending.delete(sessionID)
+      return Promise.resolve()
+    }
+    return sdk.client.session
+      .abort({
+        sessionID,
+      })
+      .catch(() => {})
+  }
+
+  const restoreCommentItems = (items: CommentItem[]) => {
+    for (const item of items) {
+      prompt.context.add({
+        type: "file",
+        path: item.path,
+        selection: item.selection,
+        comment: item.comment,
+        commentID: item.commentID,
+        commentOrigin: item.commentOrigin,
+        preview: item.preview,
+      })
+    }
+  }
+
+  const removeCommentItems = (items: { key: string }[]) => {
+    for (const item of items) {
+      prompt.context.remove(item.key)
+    }
+  }
+
+  const handleSubmit = async (event: Event) => {
+    event.preventDefault()
+
+    const currentPrompt = prompt.current()
+    const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
+    const images = input.imageAttachments().slice()
+    const mode = input.mode()
+
+    if (text.trim().length === 0 && images.length === 0 && input.commentCount() === 0) {
+      if (input.working()) abort()
+      return
+    }
+
+    const currentModel = local.model.current()
+    const currentAgent = local.agent.current()
+    if (!currentModel || !currentAgent) {
+      showToast({
+        title: language.t("prompt.toast.modelAgentRequired.title"),
+        description: language.t("prompt.toast.modelAgentRequired.description"),
+      })
+      return
+    }
+
+    input.addToHistory(currentPrompt, mode)
+    input.resetHistoryNavigation()
+
+    const projectDirectory = sdk.directory
+    const isNewSession = !params.id
+    const worktreeSelection = input.newSessionWorktree ?? "main"
+
+    let sessionDirectory = projectDirectory
+    let client = sdk.client
+
+    if (isNewSession) {
+      if (worktreeSelection === "create") {
+        const createdWorktree = await client.worktree
+          .create({ directory: projectDirectory })
+          .then((x) => x.data)
+          .catch((err) => {
+            showToast({
+              title: language.t("prompt.toast.worktreeCreateFailed.title"),
+              description: errorMessage(err),
+            })
+            return undefined
+          })
+
+        if (!createdWorktree?.directory) {
+          showToast({
+            title: language.t("prompt.toast.worktreeCreateFailed.title"),
+            description: language.t("common.requestFailed"),
+          })
+          return
+        }
+        WorktreeState.pending(createdWorktree.directory)
+        sessionDirectory = createdWorktree.directory
+      }
+
+      if (worktreeSelection !== "main" && worktreeSelection !== "create") {
+        sessionDirectory = worktreeSelection
+      }
+
+      if (sessionDirectory !== projectDirectory) {
+        client = createOpencodeClient({
+          baseUrl: sdk.url,
+          fetch: platform.fetch,
+          directory: sessionDirectory,
+          throwOnError: true,
+        })
+        globalSync.child(sessionDirectory)
+      }
+
+      input.onNewSessionWorktreeReset?.()
+    }
+
+    let session = input.info()
+    if (!session && isNewSession) {
+      session = await client.session
+        .create()
+        .then((x) => x.data ?? undefined)
+        .catch((err) => {
+          showToast({
+            title: language.t("prompt.toast.sessionCreateFailed.title"),
+            description: errorMessage(err),
+          })
+          return undefined
+        })
+      if (session) {
+        layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
+        navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
+      }
+    }
+    if (!session) return
+
+    input.onSubmit?.()
+
+    const model = {
+      modelID: currentModel.id,
+      providerID: currentModel.provider.id,
+    }
+    const agent = currentAgent.name
+    const variant = local.model.variant.current()
+
+    const clearInput = () => {
+      prompt.reset()
+      input.setMode("normal")
+      input.setPopover(null)
+    }
+
+    const restoreInput = () => {
+      prompt.set(currentPrompt, input.promptLength(currentPrompt))
+      input.setMode(mode)
+      input.setPopover(null)
+      requestAnimationFrame(() => {
+        const editor = input.editor()
+        if (!editor) return
+        editor.focus()
+        setCursorPosition(editor, input.promptLength(currentPrompt))
+        input.queueScroll()
+      })
+    }
+
+    if (mode === "shell") {
+      clearInput()
+      client.session
+        .shell({
+          sessionID: session.id,
+          agent,
+          model,
+          command: text,
+        })
+        .catch((err) => {
+          showToast({
+            title: language.t("prompt.toast.shellSendFailed.title"),
+            description: errorMessage(err),
+          })
+          restoreInput()
+        })
+      return
+    }
+
+    if (text.startsWith("/")) {
+      const [cmdName, ...args] = text.split(" ")
+      const commandName = cmdName.slice(1)
+      const customCommand = sync.data.command.find((c) => c.name === commandName)
+      if (customCommand) {
+        clearInput()
+        client.session
+          .command({
+            sessionID: session.id,
+            command: commandName,
+            arguments: args.join(" "),
+            agent,
+            model: `${model.providerID}/${model.modelID}`,
+            variant,
+            parts: images.map((attachment) => ({
+              id: Identifier.ascending("part"),
+              type: "file" as const,
+              mime: attachment.mime,
+              url: attachment.dataUrl,
+              filename: attachment.filename,
+            })),
+          })
+          .catch((err) => {
+            showToast({
+              title: language.t("prompt.toast.commandSendFailed.title"),
+              description: errorMessage(err),
+            })
+            restoreInput()
+          })
+        return
+      }
+    }
+
+    const toAbsolutePath = (path: string) =>
+      path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/")
+
+    const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[]
+    const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
+
+    const fileAttachmentParts = fileAttachments.map((attachment) => {
+      const absolute = toAbsolutePath(attachment.path)
+      const query = attachment.selection
+        ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
+        : ""
+      return {
+        id: Identifier.ascending("part"),
+        type: "file" as const,
+        mime: "text/plain",
+        url: `file://${absolute}${query}`,
+        filename: getFilename(attachment.path),
+        source: {
+          type: "file" as const,
+          text: {
+            value: attachment.content,
+            start: attachment.start,
+            end: attachment.end,
+          },
+          path: absolute,
+        },
+      }
+    })
+
+    const agentAttachmentParts = agentAttachments.map((attachment) => ({
+      id: Identifier.ascending("part"),
+      type: "agent" as const,
+      name: attachment.name,
+      source: {
+        value: attachment.content,
+        start: attachment.start,
+        end: attachment.end,
+      },
+    }))
+
+    const usedUrls = new Set(fileAttachmentParts.map((part) => part.url))
+
+    const context = prompt.context.items().slice()
+    const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
+
+    const contextParts: Array<
+      | {
+          id: string
+          type: "text"
+          text: string
+          synthetic?: boolean
+        }
+      | {
+          id: string
+          type: "file"
+          mime: string
+          url: string
+          filename?: string
+        }
+    > = []
+
+    const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
+      const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
+      const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
+      const range =
+        start === undefined || end === undefined
+          ? "this file"
+          : start === end
+            ? `line ${start}`
+            : `lines ${start} through ${end}`
+
+      return `The user made the following comment regarding ${range} of ${path}: ${comment}`
+    }
+
+    const addContextFile = (item: { path: string; selection?: FileSelection; comment?: string }) => {
+      const absolute = toAbsolutePath(item.path)
+      const query = item.selection ? `?start=${item.selection.startLine}&end=${item.selection.endLine}` : ""
+      const url = `file://${absolute}${query}`
+
+      const comment = item.comment?.trim()
+      if (!comment && usedUrls.has(url)) return
+      usedUrls.add(url)
+
+      if (comment) {
+        contextParts.push({
+          id: Identifier.ascending("part"),
+          type: "text",
+          text: commentNote(item.path, item.selection, comment),
+          synthetic: true,
+        })
+      }
+
+      contextParts.push({
+        id: Identifier.ascending("part"),
+        type: "file",
+        mime: "text/plain",
+        url,
+        filename: getFilename(item.path),
+      })
+    }
+
+    for (const item of context) {
+      if (item.type !== "file") continue
+      addContextFile({ path: item.path, selection: item.selection, comment: item.comment })
+    }
+
+    const imageAttachmentParts = images.map((attachment) => ({
+      id: Identifier.ascending("part"),
+      type: "file" as const,
+      mime: attachment.mime,
+      url: attachment.dataUrl,
+      filename: attachment.filename,
+    }))
+
+    const messageID = Identifier.ascending("message")
+    const requestParts = [
+      {
+        id: Identifier.ascending("part"),
+        type: "text" as const,
+        text,
+      },
+      ...fileAttachmentParts,
+      ...contextParts,
+      ...agentAttachmentParts,
+      ...imageAttachmentParts,
+    ]
+
+    const optimisticParts = requestParts.map((part) => ({
+      ...part,
+      sessionID: session.id,
+      messageID,
+    })) as unknown as Part[]
+
+    const optimisticMessage: Message = {
+      id: messageID,
+      sessionID: session.id,
+      role: "user",
+      time: { created: Date.now() },
+      agent,
+      model,
+    }
+
+    const addOptimisticMessage = () => {
+      if (sessionDirectory === projectDirectory) {
+        sync.set(
+          produce((draft) => {
+            const messages = draft.message[session.id]
+            if (!messages) {
+              draft.message[session.id] = [optimisticMessage]
+            } else {
+              const result = Binary.search(messages, messageID, (m) => m.id)
+              messages.splice(result.index, 0, optimisticMessage)
+            }
+            draft.part[messageID] = optimisticParts
+              .filter((part) => !!part?.id)
+              .slice()
+              .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+          }),
+        )
+        return
+      }
+
+      globalSync.child(sessionDirectory)[1](
+        produce((draft) => {
+          const messages = draft.message[session.id]
+          if (!messages) {
+            draft.message[session.id] = [optimisticMessage]
+          } else {
+            const result = Binary.search(messages, messageID, (m) => m.id)
+            messages.splice(result.index, 0, optimisticMessage)
+          }
+          draft.part[messageID] = optimisticParts
+            .filter((part) => !!part?.id)
+            .slice()
+            .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+        }),
+      )
+    }
+
+    const removeOptimisticMessage = () => {
+      if (sessionDirectory === projectDirectory) {
+        sync.set(
+          produce((draft) => {
+            const messages = draft.message[session.id]
+            if (messages) {
+              const result = Binary.search(messages, messageID, (m) => m.id)
+              if (result.found) messages.splice(result.index, 1)
+            }
+            delete draft.part[messageID]
+          }),
+        )
+        return
+      }
+
+      globalSync.child(sessionDirectory)[1](
+        produce((draft) => {
+          const messages = draft.message[session.id]
+          if (messages) {
+            const result = Binary.search(messages, messageID, (m) => m.id)
+            if (result.found) messages.splice(result.index, 1)
+          }
+          delete draft.part[messageID]
+        }),
+      )
+    }
+
+    removeCommentItems(commentItems)
+    clearInput()
+    addOptimisticMessage()
+
+    const waitForWorktree = async () => {
+      const worktree = WorktreeState.get(sessionDirectory)
+      if (!worktree || worktree.status !== "pending") return true
+
+      if (sessionDirectory === projectDirectory) {
+        sync.set("session_status", session.id, { type: "busy" })
+      }
+
+      const controller = new AbortController()
+      const cleanup = () => {
+        if (sessionDirectory === projectDirectory) {
+          sync.set("session_status", session.id, { type: "idle" })
+        }
+        removeOptimisticMessage()
+        restoreCommentItems(commentItems)
+        restoreInput()
+      }
+
+      pending.set(session.id, { abort: controller, cleanup })
+
+      const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
+        if (controller.signal.aborted) {
+          resolve({ status: "failed", message: "aborted" })
+          return
+        }
+        controller.signal.addEventListener(
+          "abort",
+          () => {
+            resolve({ status: "failed", message: "aborted" })
+          },
+          { once: true },
+        )
+      })
+
+      const timeoutMs = 5 * 60 * 1000
+      const timer = { id: undefined as number | undefined }
+      const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
+        timer.id = window.setTimeout(() => {
+          resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
+        }, timeoutMs)
+      })
+
+      const result = await Promise.race([WorktreeState.wait(sessionDirectory), abortWait, timeout]).finally(() => {
+        if (timer.id === undefined) return
+        clearTimeout(timer.id)
+      })
+      pending.delete(session.id)
+      if (controller.signal.aborted) return false
+      if (result.status === "failed") throw new Error(result.message)
+      return true
+    }
+
+    const send = async () => {
+      const ok = await waitForWorktree()
+      if (!ok) return
+      await client.session.prompt({
+        sessionID: session.id,
+        agent,
+        model,
+        messageID,
+        parts: requestParts,
+        variant,
+      })
+    }
+
+    void send().catch((err) => {
+      pending.delete(session.id)
+      if (sessionDirectory === projectDirectory) {
+        sync.set("session_status", session.id, { type: "idle" })
+      }
+      showToast({
+        title: language.t("prompt.toast.promptSendFailed.title"),
+        description: errorMessage(err),
+      })
+      removeOptimisticMessage()
+      restoreCommentItems(commentItems)
+      restoreInput()
+    })
+  }
+
+  return {
+    abort,
+    handleSubmit,
+  }
+}

+ 7 - 24
packages/app/src/components/session-context-usage.tsx

@@ -3,12 +3,11 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
 import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
 import { Button } from "@opencode-ai/ui/button"
 import { Button } from "@opencode-ai/ui/button"
 import { useParams } from "@solidjs/router"
 import { useParams } from "@solidjs/router"
-import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
-import { findLast } from "@opencode-ai/util/array"
 
 
 import { useLayout } from "@/context/layout"
 import { useLayout } from "@/context/layout"
 import { useSync } from "@/context/sync"
 import { useSync } from "@/context/sync"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
+import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
 
 
 interface SessionContextUsageProps {
 interface SessionContextUsageProps {
   variant?: "button" | "indicator"
   variant?: "button" | "indicator"
@@ -34,26 +33,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
       }),
       }),
   )
   )
 
 
+  const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
+  const context = createMemo(() => metrics().context)
   const cost = createMemo(() => {
   const cost = createMemo(() => {
-    const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
-    return usd().format(total)
-  })
-
-  const context = createMemo(() => {
-    const locale = language.locale()
-    const last = findLast(messages(), (x) => {
-      if (x.role !== "assistant") return false
-      const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
-      return total > 0
-    }) as AssistantMessage
-    if (!last) return
-    const total =
-      last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
-    const model = sync.data.provider.all.find((x) => x.id === last.providerID)?.models[last.modelID]
-    return {
-      tokens: total.toLocaleString(locale),
-      percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
-    }
+    return usd().format(metrics().totalCost)
   })
   })
 
 
   const openContext = () => {
   const openContext = () => {
@@ -67,7 +50,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
 
 
   const circle = () => (
   const circle = () => (
     <div class="flex items-center justify-center">
     <div class="flex items-center justify-center">
-      <ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
+      <ProgressCircle size={16} strokeWidth={2} percentage={context()?.usage ?? 0} />
     </div>
     </div>
   )
   )
 
 
@@ -77,11 +60,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
         {(ctx) => (
         {(ctx) => (
           <>
           <>
             <div class="flex items-center gap-2">
             <div class="flex items-center gap-2">
-              <span class="text-text-invert-strong">{ctx().tokens}</span>
+              <span class="text-text-invert-strong">{ctx().total.toLocaleString(language.locale())}</span>
               <span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
               <span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
             </div>
             </div>
             <div class="flex items-center gap-2">
             <div class="flex items-center gap-2">
-              <span class="text-text-invert-strong">{ctx().percentage ?? 0}%</span>
+              <span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
               <span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
               <span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
             </div>
             </div>
           </>
           </>

+ 93 - 0
packages/app/src/components/session/session-context-metrics.test.ts

@@ -0,0 +1,93 @@
+import { describe, expect, test } from "bun:test"
+import type { Message } from "@opencode-ai/sdk/v2/client"
+import { getSessionContextMetrics } from "./session-context-metrics"
+
+const assistant = (
+  id: string,
+  tokens: { input: number; output: number; reasoning: number; read: number; write: number },
+  cost: number,
+  providerID = "openai",
+  modelID = "gpt-4.1",
+) => {
+  return {
+    id,
+    role: "assistant",
+    providerID,
+    modelID,
+    cost,
+    tokens: {
+      input: tokens.input,
+      output: tokens.output,
+      reasoning: tokens.reasoning,
+      cache: {
+        read: tokens.read,
+        write: tokens.write,
+      },
+    },
+    time: { created: 1 },
+  } as unknown as Message
+}
+
+const user = (id: string) => {
+  return {
+    id,
+    role: "user",
+    cost: 0,
+    time: { created: 1 },
+  } as unknown as Message
+}
+
+describe("getSessionContextMetrics", () => {
+  test("computes totals and usage from latest assistant with tokens", () => {
+    const messages = [
+      user("u1"),
+      assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
+      assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
+    ]
+    const providers = [
+      {
+        id: "openai",
+        name: "OpenAI",
+        models: {
+          "gpt-4.1": {
+            name: "GPT-4.1",
+            limit: { context: 1000 },
+          },
+        },
+      },
+    ]
+
+    const metrics = getSessionContextMetrics(messages, providers)
+
+    expect(metrics.totalCost).toBe(1.75)
+    expect(metrics.context?.message.id).toBe("a2")
+    expect(metrics.context?.total).toBe(500)
+    expect(metrics.context?.usage).toBe(50)
+    expect(metrics.context?.providerLabel).toBe("OpenAI")
+    expect(metrics.context?.modelLabel).toBe("GPT-4.1")
+  })
+
+  test("preserves fallback labels and null usage when model metadata is missing", () => {
+    const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
+    const providers = [{ id: "p-1", models: {} }]
+
+    const metrics = getSessionContextMetrics(messages, providers)
+
+    expect(metrics.context?.providerLabel).toBe("p-1")
+    expect(metrics.context?.modelLabel).toBe("m-1")
+    expect(metrics.context?.limit).toBeUndefined()
+    expect(metrics.context?.usage).toBeNull()
+  })
+
+  test("memoizes by message and provider array identity", () => {
+    const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
+    const providers = [{ id: "openai", models: {} }]
+
+    const one = getSessionContextMetrics(messages, providers)
+    const two = getSessionContextMetrics(messages, providers)
+    const three = getSessionContextMetrics([...messages], providers)
+
+    expect(two).toBe(one)
+    expect(three).not.toBe(one)
+  })
+})

+ 94 - 0
packages/app/src/components/session/session-context-metrics.ts

@@ -0,0 +1,94 @@
+import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
+
+type Provider = {
+  id: string
+  name?: string
+  models: Record<string, Model | undefined>
+}
+
+type Model = {
+  name?: string
+  limit: {
+    context: number
+  }
+}
+
+type Context = {
+  message: AssistantMessage
+  provider?: Provider
+  model?: Model
+  providerLabel: string
+  modelLabel: string
+  limit: number | undefined
+  input: number
+  output: number
+  reasoning: number
+  cacheRead: number
+  cacheWrite: number
+  total: number
+  usage: number | null
+}
+
+type Metrics = {
+  totalCost: number
+  context: Context | undefined
+}
+
+const cache = new WeakMap<Message[], WeakMap<Provider[], Metrics>>()
+
+const tokenTotal = (msg: AssistantMessage) => {
+  return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
+}
+
+const lastAssistantWithTokens = (messages: Message[]) => {
+  for (let i = messages.length - 1; i >= 0; i--) {
+    const msg = messages[i]
+    if (msg.role !== "assistant") continue
+    if (tokenTotal(msg) <= 0) continue
+    return msg
+  }
+}
+
+const build = (messages: Message[], providers: Provider[]): Metrics => {
+  const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
+  const message = lastAssistantWithTokens(messages)
+  if (!message) return { totalCost, context: undefined }
+
+  const provider = providers.find((item) => item.id === message.providerID)
+  const model = provider?.models[message.modelID]
+  const limit = model?.limit.context
+  const total = tokenTotal(message)
+
+  return {
+    totalCost,
+    context: {
+      message,
+      provider,
+      model,
+      providerLabel: provider?.name ?? message.providerID,
+      modelLabel: model?.name ?? message.modelID,
+      limit,
+      input: message.tokens.input,
+      output: message.tokens.output,
+      reasoning: message.tokens.reasoning,
+      cacheRead: message.tokens.cache.read,
+      cacheWrite: message.tokens.cache.write,
+      total,
+      usage: limit ? Math.round((total / limit) * 100) : null,
+    },
+  }
+}
+
+export function getSessionContextMetrics(messages: Message[], providers: Provider[]) {
+  const byProvider = cache.get(messages)
+  if (byProvider) {
+    const hit = byProvider.get(providers)
+    if (hit) return hit
+  }
+
+  const value = build(messages, providers)
+  const next = byProvider ?? new WeakMap<Provider[], Metrics>()
+  next.set(providers, value)
+  if (!byProvider) cache.set(messages, next)
+  return value
+}

+ 7 - 40
packages/app/src/components/session/session-context-tab.tsx

@@ -11,8 +11,9 @@ import { Accordion } from "@opencode-ai/ui/accordion"
 import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
 import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
 import { Code } from "@opencode-ai/ui/code"
 import { Code } from "@opencode-ai/ui/code"
 import { Markdown } from "@opencode-ai/ui/markdown"
 import { Markdown } from "@opencode-ai/ui/markdown"
-import type { AssistantMessage, Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
+import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
+import { getSessionContextMetrics } from "./session-context-metrics"
 
 
 interface SessionContextTabProps {
 interface SessionContextTabProps {
   messages: () => Message[]
   messages: () => Message[]
@@ -34,44 +35,11 @@ export function SessionContextTab(props: SessionContextTabProps) {
       }),
       }),
   )
   )
 
 
-  const ctx = createMemo(() => {
-    const last = findLast(props.messages(), (x) => {
-      if (x.role !== "assistant") return false
-      const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
-      return total > 0
-    }) as AssistantMessage
-    if (!last) return
-
-    const provider = sync.data.provider.all.find((x) => x.id === last.providerID)
-    const model = provider?.models[last.modelID]
-    const limit = model?.limit.context
-
-    const input = last.tokens.input
-    const output = last.tokens.output
-    const reasoning = last.tokens.reasoning
-    const cacheRead = last.tokens.cache.read
-    const cacheWrite = last.tokens.cache.write
-    const total = input + output + reasoning + cacheRead + cacheWrite
-    const usage = limit ? Math.round((total / limit) * 100) : null
-
-    return {
-      message: last,
-      provider,
-      model,
-      limit,
-      input,
-      output,
-      reasoning,
-      cacheRead,
-      cacheWrite,
-      total,
-      usage,
-    }
-  })
+  const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
+  const ctx = createMemo(() => metrics().context)
 
 
   const cost = createMemo(() => {
   const cost = createMemo(() => {
-    const total = props.messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
-    return usd().format(total)
+    return usd().format(metrics().totalCost)
   })
   })
 
 
   const counts = createMemo(() => {
   const counts = createMemo(() => {
@@ -114,14 +82,13 @@ export function SessionContextTab(props: SessionContextTabProps) {
   const providerLabel = createMemo(() => {
   const providerLabel = createMemo(() => {
     const c = ctx()
     const c = ctx()
     if (!c) return "—"
     if (!c) return "—"
-    return c.provider?.name ?? c.message.providerID
+    return c.providerLabel
   })
   })
 
 
   const modelLabel = createMemo(() => {
   const modelLabel = createMemo(() => {
     const c = ctx()
     const c = ctx()
     if (!c) return "—"
     if (!c) return "—"
-    if (c.model?.name) return c.model.name
-    return c.message.modelID
+    return c.modelLabel
   })
   })
 
 
   const breakdown = createMemo(
   const breakdown = createMemo(

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

@@ -0,0 +1,111 @@
+import { beforeAll, describe, expect, mock, test } from "bun:test"
+import { createRoot } from "solid-js"
+import type { LineComment } from "./comments"
+
+let createCommentSessionForTest: typeof import("./comments").createCommentSessionForTest
+
+beforeAll(async () => {
+  mock.module("@solidjs/router", () => ({
+    useParams: () => ({}),
+  }))
+  mock.module("@opencode-ai/ui/context", () => ({
+    createSimpleContext: () => ({
+      use: () => undefined,
+      provider: () => undefined,
+    }),
+  }))
+  const mod = await import("./comments")
+  createCommentSessionForTest = mod.createCommentSessionForTest
+})
+
+function line(file: string, id: string, time: number): LineComment {
+  return {
+    id,
+    file,
+    comment: id,
+    time,
+    selection: { start: 1, end: 1 },
+  }
+}
+
+describe("comments session indexing", () => {
+  test("keeps file list behavior and aggregate chronological order", () => {
+    createRoot((dispose) => {
+      const now = Date.now()
+      const comments = createCommentSessionForTest({
+        "a.ts": [line("a.ts", "a-late", now + 20_000), line("a.ts", "a-early", now + 1_000)],
+        "b.ts": [line("b.ts", "b-mid", now + 10_000)],
+      })
+
+      expect(comments.list("a.ts").map((item) => item.id)).toEqual(["a-late", "a-early"])
+      expect(comments.all().map((item) => item.id)).toEqual(["a-early", "b-mid", "a-late"])
+
+      const next = comments.add({
+        file: "b.ts",
+        comment: "next",
+        selection: { start: 2, end: 2 },
+      })
+
+      expect(comments.list("b.ts").at(-1)?.id).toBe(next.id)
+      expect(comments.all().map((item) => item.time)).toEqual(
+        comments
+          .all()
+          .map((item) => item.time)
+          .slice()
+          .sort((a, b) => a - b),
+      )
+
+      dispose()
+    })
+  })
+
+  test("remove updates file and aggregate indexes consistently", () => {
+    createRoot((dispose) => {
+      const comments = createCommentSessionForTest({
+        "a.ts": [line("a.ts", "a1", 10), line("a.ts", "shared", 20)],
+        "b.ts": [line("b.ts", "shared", 30)],
+      })
+
+      comments.setFocus({ file: "a.ts", id: "shared" })
+      comments.setActive({ file: "a.ts", id: "shared" })
+      comments.remove("a.ts", "shared")
+
+      expect(comments.list("a.ts").map((item) => item.id)).toEqual(["a1"])
+      expect(
+        comments
+          .all()
+          .filter((item) => item.id === "shared")
+          .map((item) => item.file),
+      ).toEqual(["b.ts"])
+      expect(comments.focus()).toBeNull()
+      expect(comments.active()).toEqual({ file: "a.ts", id: "shared" })
+
+      dispose()
+    })
+  })
+
+  test("clear resets file and aggregate indexes plus focus state", () => {
+    createRoot((dispose) => {
+      const comments = createCommentSessionForTest({
+        "a.ts": [line("a.ts", "a1", 10)],
+      })
+
+      const next = comments.add({
+        file: "b.ts",
+        comment: "next",
+        selection: { start: 2, end: 2 },
+      })
+
+      comments.setActive({ file: "b.ts", id: next.id })
+      comments.clear()
+
+      expect(comments.list("a.ts")).toEqual([])
+      expect(comments.list("b.ts")).toEqual([])
+      expect(comments.all()).toEqual([])
+      expect(comments.focus()).toBeNull()
+      expect(comments.active()).toBeNull()
+
+      dispose()
+    })
+  })
+})

+ 86 - 66
packages/app/src/context/comments.tsx

@@ -1,8 +1,9 @@
-import { batch, createMemo, createRoot, onCleanup } from "solid-js"
-import { createStore } from "solid-js/store"
+import { batch, createEffect, 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 { createSimpleContext } from "@opencode-ai/ui/context"
 import { useParams } from "@solidjs/router"
 import { useParams } from "@solidjs/router"
 import { Persist, persisted } from "@/utils/persist"
 import { Persist, persisted } from "@/utils/persist"
+import { createScopedCache } from "@/utils/scoped-cache"
 import type { SelectedLineRange } from "@/context/file"
 import type { SelectedLineRange } from "@/context/file"
 
 
 export type LineComment = {
 export type LineComment = {
@@ -18,28 +19,28 @@ type CommentFocus = { file: string; id: string }
 const WORKSPACE_KEY = "__workspace__"
 const WORKSPACE_KEY = "__workspace__"
 const MAX_COMMENT_SESSIONS = 20
 const MAX_COMMENT_SESSIONS = 20
 
 
-type CommentSession = ReturnType<typeof createCommentSession>
-
-type CommentCacheEntry = {
-  value: CommentSession
-  dispose: VoidFunction
+type CommentStore = {
+  comments: Record<string, LineComment[]>
 }
 }
 
 
-function createCommentSession(dir: string, id: string | undefined) {
-  const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
+function aggregate(comments: Record<string, LineComment[]>) {
+  return Object.keys(comments)
+    .flatMap((file) => comments[file] ?? [])
+    .slice()
+    .sort((a, b) => a.time - b.time)
+}
 
 
-  const [store, setStore, _, ready] = persisted(
-    Persist.scoped(dir, id, "comments", [legacy]),
-    createStore<{
-      comments: Record<string, LineComment[]>
-    }>({
-      comments: {},
-    }),
-  )
+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({
   const [state, setState] = createStore({
     focus: null as CommentFocus | null,
     focus: null as CommentFocus | null,
     active: null as CommentFocus | null,
     active: null as CommentFocus | null,
+    all: aggregate(store.comments),
   })
   })
 
 
   const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
   const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
@@ -59,6 +60,7 @@ function createCommentSession(dir: string, id: string | undefined) {
 
 
     batch(() => {
     batch(() => {
       setStore("comments", input.file, (items) => [...(items ?? []), next])
       setStore("comments", input.file, (items) => [...(items ?? []), next])
+      setState("all", (items) => insert(items, next))
       setFocus({ file: input.file, id: next.id })
       setFocus({ file: input.file, id: next.id })
     })
     })
 
 
@@ -66,37 +68,72 @@ function createCommentSession(dir: string, id: string | undefined) {
   }
   }
 
 
   const remove = (file: string, id: string) => {
   const remove = (file: string, id: string) => {
-    setStore("comments", file, (items) => (items ?? []).filter((x) => x.id !== id))
-    setFocus((current) => (current?.id === id ? null : current))
+    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))
+    })
   }
   }
 
 
   const clear = () => {
   const clear = () => {
     batch(() => {
     batch(() => {
-      setStore("comments", {})
+      setStore("comments", reconcile({}))
+      setState("all", [])
       setFocus(null)
       setFocus(null)
       setActive(null)
       setActive(null)
     })
     })
   }
   }
 
 
-  const all = createMemo(() => {
-    const files = Object.keys(store.comments)
-    const items = files.flatMap((file) => store.comments[file] ?? [])
-    return items.slice().sort((a, b) => a.time - b.time)
-  })
-
   return {
   return {
-    ready,
     list,
     list,
-    all,
+    all: () => state.all,
     add,
     add,
     remove,
     remove,
     clear,
     clear,
-    focus: createMemo(() => state.focus),
+    focus: () => state.focus,
     setFocus,
     setFocus,
     clearFocus: () => setFocus(null),
     clearFocus: () => setFocus(null),
-    active: createMemo(() => state.active),
+    active: () => state.active,
     setActive,
     setActive,
     clearActive: () => setActive(null),
     clearActive: () => setActive(null),
+    reindex: () => setState("all", aggregate(store.comments)),
+  }
+}
+
+export function createCommentSessionForTest(comments: Record<string, LineComment[]> = {}) {
+  const [store, setStore] = createStore<CommentStore>({ comments })
+  return createCommentSessionState(store, setStore)
+}
+
+function createCommentSession(dir: string, id: string | undefined) {
+  const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
+
+  const [store, setStore, _, ready] = persisted(
+    Persist.scoped(dir, id, "comments", [legacy]),
+    createStore<CommentStore>({
+      comments: {},
+    }),
+  )
+  const session = createCommentSessionState(store, setStore)
+
+  createEffect(() => {
+    if (!ready()) return
+    session.reindex()
+  })
+
+  return {
+    ready,
+    list: session.list,
+    all: session.all,
+    add: session.add,
+    remove: session.remove,
+    clear: session.clear,
+    focus: session.focus,
+    setFocus: session.setFocus,
+    clearFocus: session.clearFocus,
+    active: session.active,
+    setActive: session.setActive,
+    clearActive: session.clearActive,
   }
   }
 }
 }
 
 
@@ -105,44 +142,27 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
   gate: false,
   gate: false,
   init: () => {
   init: () => {
     const params = useParams()
     const params = useParams()
-    const cache = new Map<string, CommentCacheEntry>()
-
-    const disposeAll = () => {
-      for (const entry of cache.values()) {
-        entry.dispose()
-      }
-      cache.clear()
-    }
-
-    onCleanup(disposeAll)
-
-    const prune = () => {
-      while (cache.size > MAX_COMMENT_SESSIONS) {
-        const first = cache.keys().next().value
-        if (!first) return
-        const entry = cache.get(first)
-        entry?.dispose()
-        cache.delete(first)
-      }
-    }
+    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
+        return createRoot((dispose) => ({
+          value: createCommentSession(dir, id === WORKSPACE_KEY ? undefined : id),
+          dispose,
+        }))
+      },
+      {
+        maxEntries: MAX_COMMENT_SESSIONS,
+        dispose: (entry) => entry.dispose(),
+      },
+    )
+
+    onCleanup(() => cache.clear())
 
 
     const load = (dir: string, id: string | undefined) => {
     const load = (dir: string, id: string | undefined) => {
-      const key = `${dir}:${id ?? WORKSPACE_KEY}`
-      const existing = cache.get(key)
-      if (existing) {
-        cache.delete(key)
-        cache.set(key, existing)
-        return existing.value
-      }
-
-      const entry = createRoot((dispose) => ({
-        value: createCommentSession(dir, id),
-        dispose,
-      }))
-
-      cache.set(key, entry)
-      prune()
-      return entry.value
+      const key = `${dir}\n${id ?? WORKSPACE_KEY}`
+      return cache.get(key).value
     }
     }
 
 
     const session = createMemo(() => load(params.dir!, params.id))
     const session = createMemo(() => load(params.dir!, params.id))

+ 85 - 0
packages/app/src/context/file-content-eviction-accounting.test.ts

@@ -0,0 +1,85 @@
+import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test"
+
+let evictContentLru: (keep: Set<string> | undefined, evict: (path: string) => void) => void
+let getFileContentBytesTotal: () => number
+let getFileContentEntryCount: () => number
+let removeFileContentBytes: (path: string) => void
+let resetFileContentLru: () => void
+let setFileContentBytes: (path: string, bytes: number) => void
+let touchFileContent: (path: string, bytes?: number) => void
+
+beforeAll(async () => {
+  mock.module("@solidjs/router", () => ({
+    useParams: () => ({}),
+  }))
+  mock.module("@opencode-ai/ui/context", () => ({
+    createSimpleContext: () => ({
+      use: () => undefined,
+      provider: () => undefined,
+    }),
+  }))
+
+  const mod = await import("./file")
+  evictContentLru = mod.evictContentLru
+  getFileContentBytesTotal = mod.getFileContentBytesTotal
+  getFileContentEntryCount = mod.getFileContentEntryCount
+  removeFileContentBytes = mod.removeFileContentBytes
+  resetFileContentLru = mod.resetFileContentLru
+  setFileContentBytes = mod.setFileContentBytes
+  touchFileContent = mod.touchFileContent
+})
+
+describe("file content eviction accounting", () => {
+  afterEach(() => {
+    resetFileContentLru()
+  })
+
+  test("updates byte totals incrementally for set, overwrite, remove, and reset", () => {
+    setFileContentBytes("a", 10)
+    setFileContentBytes("b", 15)
+    expect(getFileContentBytesTotal()).toBe(25)
+    expect(getFileContentEntryCount()).toBe(2)
+
+    setFileContentBytes("a", 5)
+    expect(getFileContentBytesTotal()).toBe(20)
+    expect(getFileContentEntryCount()).toBe(2)
+
+    touchFileContent("a")
+    expect(getFileContentBytesTotal()).toBe(20)
+
+    removeFileContentBytes("b")
+    expect(getFileContentBytesTotal()).toBe(5)
+    expect(getFileContentEntryCount()).toBe(1)
+
+    resetFileContentLru()
+    expect(getFileContentBytesTotal()).toBe(0)
+    expect(getFileContentEntryCount()).toBe(0)
+  })
+
+  test("evicts by entry cap using LRU order", () => {
+    for (const i of Array.from({ length: 41 }, (_, n) => n)) {
+      setFileContentBytes(`f-${i}`, 1)
+    }
+
+    const evicted: string[] = []
+    evictContentLru(undefined, (path) => evicted.push(path))
+
+    expect(evicted).toEqual(["f-0"])
+    expect(getFileContentEntryCount()).toBe(40)
+    expect(getFileContentBytesTotal()).toBe(40)
+  })
+
+  test("evicts by byte cap while preserving protected entries", () => {
+    const chunk = 8 * 1024 * 1024
+    setFileContentBytes("a", chunk)
+    setFileContentBytes("b", chunk)
+    setFileContentBytes("c", chunk)
+
+    const evicted: string[] = []
+    evictContentLru(new Set(["a"]), (path) => evicted.push(path))
+
+    expect(evicted).toEqual(["b"])
+    expect(getFileContentEntryCount()).toBe(2)
+    expect(getFileContentBytesTotal()).toBe(chunk * 2)
+  })
+})

+ 83 - 60
packages/app/src/context/file.tsx

@@ -9,6 +9,7 @@ import { useSDK } from "./sdk"
 import { useSync } from "./sync"
 import { useSync } from "./sync"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { Persist, persisted } from "@/utils/persist"
 import { Persist, persisted } from "@/utils/persist"
+import { createScopedCache } from "@/utils/scoped-cache"
 
 
 export type FileSelection = {
 export type FileSelection = {
   startLine: number
   startLine: number
@@ -155,6 +156,7 @@ const MAX_FILE_CONTENT_ENTRIES = 40
 const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
 const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
 
 
 const contentLru = new Map<string, number>()
 const contentLru = new Map<string, number>()
+let contentBytesTotal = 0
 
 
 function approxBytes(content: FileContent) {
 function approxBytes(content: FileContent) {
   const patchBytes =
   const patchBytes =
@@ -165,19 +167,72 @@ function approxBytes(content: FileContent) {
   return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2
   return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2
 }
 }
 
 
+function setContentBytes(path: string, nextBytes: number) {
+  const prev = contentLru.get(path)
+  if (prev !== undefined) contentBytesTotal -= prev
+  contentLru.delete(path)
+  contentLru.set(path, nextBytes)
+  contentBytesTotal += nextBytes
+}
+
 function touchContent(path: string, bytes?: number) {
 function touchContent(path: string, bytes?: number) {
   const prev = contentLru.get(path)
   const prev = contentLru.get(path)
   if (prev === undefined && bytes === undefined) return
   if (prev === undefined && bytes === undefined) return
-  const value = bytes ?? prev ?? 0
+  setContentBytes(path, bytes ?? prev ?? 0)
+}
+
+function removeContentBytes(path: string) {
+  const prev = contentLru.get(path)
+  if (prev === undefined) return
   contentLru.delete(path)
   contentLru.delete(path)
-  contentLru.set(path, value)
+  contentBytesTotal -= prev
+}
+
+function resetContentBytes() {
+  contentLru.clear()
+  contentBytesTotal = 0
+}
+
+export function evictContentLru(keep: Set<string> | undefined, evict: (path: string) => void) {
+  const protectedSet = keep ?? new Set<string>()
+
+  while (contentLru.size > MAX_FILE_CONTENT_ENTRIES || contentBytesTotal > MAX_FILE_CONTENT_BYTES) {
+    const path = contentLru.keys().next().value
+    if (!path) return
+
+    if (protectedSet.has(path)) {
+      touchContent(path)
+      if (contentLru.size <= protectedSet.size) return
+      continue
+    }
+
+    removeContentBytes(path)
+    evict(path)
+  }
+}
+
+export function resetFileContentLru() {
+  resetContentBytes()
+}
+
+export function setFileContentBytes(path: string, bytes: number) {
+  setContentBytes(path, bytes)
 }
 }
 
 
-type ViewSession = ReturnType<typeof createViewSession>
+export function removeFileContentBytes(path: string) {
+  removeContentBytes(path)
+}
+
+export function touchFileContent(path: string, bytes?: number) {
+  touchContent(path, bytes)
+}
 
 
-type ViewCacheEntry = {
-  value: ViewSession
-  dispose: VoidFunction
+export function getFileContentBytesTotal() {
+  return contentBytesTotal
+}
+
+export function getFileContentEntryCount() {
+  return contentLru.size
 }
 }
 
 
 function createViewSession(dir: string, id: string | undefined) {
 function createViewSession(dir: string, id: string | undefined) {
@@ -336,23 +391,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
     })
     })
 
 
     const evictContent = (keep?: Set<string>) => {
     const evictContent = (keep?: Set<string>) => {
-      const protectedSet = keep ?? new Set<string>()
-      const total = () => {
-        return Array.from(contentLru.values()).reduce((sum, bytes) => sum + bytes, 0)
-      }
-
-      while (contentLru.size > MAX_FILE_CONTENT_ENTRIES || total() > MAX_FILE_CONTENT_BYTES) {
-        const path = contentLru.keys().next().value
-        if (!path) return
-
-        if (protectedSet.has(path)) {
-          touchContent(path)
-          if (contentLru.size <= protectedSet.size) return
-          continue
-        }
-
-        contentLru.delete(path)
-        if (!store.file[path]) continue
+      evictContentLru(keep, (path) => {
+        if (!store.file[path]) return
         setStore(
         setStore(
           "file",
           "file",
           path,
           path,
@@ -361,14 +401,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
             draft.loaded = false
             draft.loaded = false
           }),
           }),
         )
         )
-      }
+      })
     }
     }
 
 
     createEffect(() => {
     createEffect(() => {
       scope()
       scope()
       inflight.clear()
       inflight.clear()
       treeInflight.clear()
       treeInflight.clear()
-      contentLru.clear()
+      resetContentBytes()
 
 
       batch(() => {
       batch(() => {
         setStore("file", reconcile({}))
         setStore("file", reconcile({}))
@@ -378,42 +418,25 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
       })
       })
     })
     })
 
 
-    const viewCache = new Map<string, ViewCacheEntry>()
-
-    const disposeViews = () => {
-      for (const entry of viewCache.values()) {
-        entry.dispose()
-      }
-      viewCache.clear()
-    }
-
-    const pruneViews = () => {
-      while (viewCache.size > MAX_FILE_VIEW_SESSIONS) {
-        const first = viewCache.keys().next().value
-        if (!first) return
-        const entry = viewCache.get(first)
-        entry?.dispose()
-        viewCache.delete(first)
-      }
-    }
+    const viewCache = 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
+        return createRoot((dispose) => ({
+          value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id),
+          dispose,
+        }))
+      },
+      {
+        maxEntries: MAX_FILE_VIEW_SESSIONS,
+        dispose: (entry) => entry.dispose(),
+      },
+    )
 
 
     const loadView = (dir: string, id: string | undefined) => {
     const loadView = (dir: string, id: string | undefined) => {
-      const key = `${dir}:${id ?? WORKSPACE_KEY}`
-      const existing = viewCache.get(key)
-      if (existing) {
-        viewCache.delete(key)
-        viewCache.set(key, existing)
-        return existing.value
-      }
-
-      const entry = createRoot((dispose) => ({
-        value: createViewSession(dir, id),
-        dispose,
-      }))
-
-      viewCache.set(key, entry)
-      pruneViews()
-      return entry.value
+      const key = `${dir}\n${id ?? WORKSPACE_KEY}`
+      return viewCache.get(key).value
     }
     }
 
 
     const view = createMemo(() => loadView(scope(), params.id))
     const view = createMemo(() => loadView(scope(), params.id))
@@ -690,7 +713,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
 
 
     onCleanup(() => {
     onCleanup(() => {
       stop()
       stop()
-      disposeViews()
+      viewCache.clear()
     })
     })
 
 
     return {
     return {

+ 69 - 0
packages/app/src/context/layout.test.ts

@@ -0,0 +1,69 @@
+import { describe, expect, test } from "bun:test"
+import { createRoot, createSignal } from "solid-js"
+import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout"
+
+describe("layout session-key helpers", () => {
+  test("couples touch and scroll seed in order", () => {
+    const calls: string[] = []
+    const result = ensureSessionKey(
+      "dir/a",
+      (key) => calls.push(`touch:${key}`),
+      (key) => calls.push(`seed:${key}`),
+    )
+
+    expect(result).toBe("dir/a")
+    expect(calls).toEqual(["touch:dir/a", "seed:dir/a"])
+  })
+
+  test("reads dynamic accessor keys lazily", () => {
+    const seen: string[] = []
+
+    createRoot((dispose) => {
+      const [key, setKey] = createSignal("dir/one")
+      const read = createSessionKeyReader(key, (value) => seen.push(value))
+
+      expect(read()).toBe("dir/one")
+      setKey("dir/two")
+      expect(read()).toBe("dir/two")
+
+      dispose()
+    })
+
+    expect(seen).toEqual(["dir/one", "dir/two"])
+  })
+})
+
+describe("pruneSessionKeys", () => {
+  test("keeps active key and drops lowest-used keys", () => {
+    const drop = pruneSessionKeys({
+      keep: "k4",
+      max: 3,
+      used: new Map([
+        ["k1", 1],
+        ["k2", 2],
+        ["k3", 3],
+        ["k4", 4],
+      ]),
+      view: ["k1", "k2", "k4"],
+      tabs: ["k1", "k3", "k4"],
+    })
+
+    expect(drop).toEqual(["k1"])
+    expect(drop.includes("k4")).toBe(false)
+  })
+
+  test("does not prune without keep key", () => {
+    const drop = pruneSessionKeys({
+      keep: undefined,
+      max: 1,
+      used: new Map([
+        ["k1", 1],
+        ["k2", 2],
+      ]),
+      view: ["k1"],
+      tabs: ["k2"],
+    })
+
+    expect(drop).toEqual([])
+  })
+})

+ 49 - 45
packages/app/src/context/layout.tsx

@@ -1,5 +1,5 @@
 import { createStore, produce } from "solid-js/store"
 import { createStore, produce } from "solid-js/store"
-import { batch, createEffect, createMemo, on, onCleanup, onMount, type Accessor } from "solid-js"
+import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useGlobalSync } from "./global-sync"
 import { useGlobalSync } from "./global-sync"
 import { useGlobalSDK } from "./global-sdk"
 import { useGlobalSDK } from "./global-sdk"
@@ -47,6 +47,43 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
 
 
 export type ReviewDiffStyle = "unified" | "split"
 export type ReviewDiffStyle = "unified" | "split"
 
 
+export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) {
+  touch(key)
+  seed(key)
+  return key
+}
+
+export function createSessionKeyReader(sessionKey: string | Accessor<string>, ensure: (key: string) => void) {
+  const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
+  return () => {
+    const value = key()
+    ensure(value)
+    return value
+  }
+}
+
+export function pruneSessionKeys(input: {
+  keep?: string
+  max: number
+  used: Map<string, number>
+  view: string[]
+  tabs: string[]
+}) {
+  if (!input.keep) return []
+
+  const keys = new Set<string>([...input.view, ...input.tabs])
+  if (keys.size <= input.max) return []
+
+  const score = (key: string) => {
+    if (key === input.keep) return Number.MAX_SAFE_INTEGER
+    return input.used.get(key) ?? 0
+  }
+
+  return Array.from(keys)
+    .sort((a, b) => score(b) - score(a))
+    .slice(input.max)
+}
+
 export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
 export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
   name: "Layout",
   name: "Layout",
   init: () => {
   init: () => {
@@ -172,20 +209,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
     }
     }
 
 
     function prune(keep?: string) {
     function prune(keep?: string) {
-      if (!keep) return
-
-      const keys = new Set<string>()
-      for (const key of Object.keys(store.sessionView)) keys.add(key)
-      for (const key of Object.keys(store.sessionTabs)) keys.add(key)
-      if (keys.size <= MAX_SESSION_KEYS) return
-
-      const score = (key: string) => {
-        if (key === keep) return Number.MAX_SAFE_INTEGER
-        return used.get(key) ?? 0
-      }
-
-      const ordered = Array.from(keys).sort((a, b) => score(b) - score(a))
-      const drop = ordered.slice(MAX_SESSION_KEYS)
+      const drop = pruneSessionKeys({
+        keep,
+        max: MAX_SESSION_KEYS,
+        used,
+        view: Object.keys(store.sessionView),
+        tabs: Object.keys(store.sessionTabs),
+      })
       if (drop.length === 0) return
       if (drop.length === 0) return
 
 
       setStore(
       setStore(
@@ -233,6 +263,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       },
       },
     })
     })
 
 
+    const ensureKey = (key: string) => ensureSessionKey(key, touch, (sessionKey) => scroll.seed(sessionKey))
+
     createEffect(() => {
     createEffect(() => {
       if (!ready()) return
       if (!ready()) return
       if (meta.pruned) return
       if (meta.pruned) return
@@ -616,22 +648,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         },
         },
       },
       },
       view(sessionKey: string | Accessor<string>) {
       view(sessionKey: string | Accessor<string>) {
-        const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
-
-        touch(key())
-        scroll.seed(key())
-
-        createEffect(
-          on(
-            key,
-            (value) => {
-              touch(value)
-              scroll.seed(value)
-            },
-            { defer: true },
-          ),
-        )
-
+        const key = createSessionKeyReader(sessionKey, ensureKey)
         const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
         const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
         const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
         const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
         const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
         const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
@@ -711,20 +728,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         }
         }
       },
       },
       tabs(sessionKey: string | Accessor<string>) {
       tabs(sessionKey: string | Accessor<string>) {
-        const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
-
-        touch(key())
-
-        createEffect(
-          on(
-            key,
-            (value) => {
-              touch(value)
-            },
-            { defer: true },
-          ),
-        )
-
+        const key = createSessionKeyReader(sessionKey, ensureKey)
         const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
         const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
         return {
         return {
           tabs,
           tabs,

+ 38 - 0
packages/app/src/context/terminal.test.ts

@@ -0,0 +1,38 @@
+import { beforeAll, describe, expect, mock, test } from "bun:test"
+
+let getWorkspaceTerminalCacheKey: (dir: string) => string
+let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
+
+beforeAll(async () => {
+  mock.module("@solidjs/router", () => ({
+    useParams: () => ({}),
+  }))
+  mock.module("@opencode-ai/ui/context", () => ({
+    createSimpleContext: () => ({
+      use: () => undefined,
+      provider: () => undefined,
+    }),
+  }))
+  const mod = await import("./terminal")
+  getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
+  getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
+})
+
+describe("getWorkspaceTerminalCacheKey", () => {
+  test("uses workspace-only directory cache key", () => {
+    expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
+  })
+})
+
+describe("getLegacyTerminalStorageKeys", () => {
+  test("keeps workspace storage path when no legacy session id", () => {
+    expect(getLegacyTerminalStorageKeys("/repo")).toEqual(["/repo/terminal.v1"])
+  })
+
+  test("includes legacy session path before workspace path", () => {
+    expect(getLegacyTerminalStorageKeys("/repo", "session-123")).toEqual([
+      "/repo/terminal/session-123.v1",
+      "/repo/terminal.v1",
+    ])
+  })
+})

+ 17 - 7
packages/app/src/context/terminal.tsx

@@ -19,15 +19,24 @@ export type LocalPTY = {
 const WORKSPACE_KEY = "__workspace__"
 const WORKSPACE_KEY = "__workspace__"
 const MAX_TERMINAL_SESSIONS = 20
 const MAX_TERMINAL_SESSIONS = 20
 
 
-type TerminalSession = ReturnType<typeof createTerminalSession>
+export function getWorkspaceTerminalCacheKey(dir: string) {
+  return `${dir}:${WORKSPACE_KEY}`
+}
+
+export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
+  if (!legacySessionID) return [`${dir}/terminal.v1`]
+  return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`]
+}
+
+type TerminalSession = ReturnType<typeof createWorkspaceTerminalSession>
 
 
 type TerminalCacheEntry = {
 type TerminalCacheEntry = {
   value: TerminalSession
   value: TerminalSession
   dispose: VoidFunction
   dispose: VoidFunction
 }
 }
 
 
-function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, session?: string) {
-  const legacy = session ? [`${dir}/terminal/${session}.v1`, `${dir}/terminal.v1`] : [`${dir}/terminal.v1`]
+function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
+  const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
 
 
   const numberFromTitle = (title: string) => {
   const numberFromTitle = (title: string) => {
     const match = title.match(/^Terminal (\d+)$/)
     const match = title.match(/^Terminal (\d+)$/)
@@ -235,8 +244,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
       }
       }
     }
     }
 
 
-    const load = (dir: string, session?: string) => {
-      const key = `${dir}:${WORKSPACE_KEY}`
+    const loadWorkspace = (dir: string, legacySessionID?: string) => {
+      // Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
+      const key = getWorkspaceTerminalCacheKey(dir)
       const existing = cache.get(key)
       const existing = cache.get(key)
       if (existing) {
       if (existing) {
         cache.delete(key)
         cache.delete(key)
@@ -245,7 +255,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
       }
       }
 
 
       const entry = createRoot((dispose) => ({
       const entry = createRoot((dispose) => ({
-        value: createTerminalSession(sdk, dir, session),
+        value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
         dispose,
         dispose,
       }))
       }))
 
 
@@ -254,7 +264,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
       return entry.value
       return entry.value
     }
     }
 
 
-    const workspace = createMemo(() => load(params.dir!, params.id))
+    const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
 
 
     return {
     return {
       ready: () => workspace().ready(),
       ready: () => workspace().ready(),

+ 213 - 290
packages/app/src/pages/session.tsx

@@ -75,6 +75,8 @@ import {
 } from "@/components/session"
 } from "@/components/session"
 import { navMark, navParams } from "@/utils/perf"
 import { navMark, navParams } from "@/utils/perf"
 import { same } from "@/utils/same"
 import { same } from "@/utils/same"
+import { combineCommandSections, createOpenReviewFile, focusTerminalById } from "@/pages/session/helpers"
+import { createScrollSpy } from "@/pages/session/scroll-spy"
 
 
 type DiffStyle = "unified" | "split"
 type DiffStyle = "unified" | "split"
 
 
@@ -872,19 +874,7 @@ export default function Page() {
         if (document.activeElement instanceof HTMLElement) {
         if (document.activeElement instanceof HTMLElement) {
           document.activeElement.blur()
           document.activeElement.blur()
         }
         }
-        const wrapper = document.getElementById(`terminal-wrapper-${activeId}`)
-        const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
-        if (!element) return
-
-        // Find and focus the ghostty textarea (the actual input element)
-        const textarea = element.querySelector("textarea") as HTMLTextAreaElement
-        if (textarea) {
-          textarea.focus()
-          return
-        }
-        // Fallback: focus container and dispatch pointer event
-        element.focus()
-        element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
+        focusTerminalById(activeId)
       },
       },
     ),
     ),
   )
   )
@@ -973,7 +963,7 @@ export default function Page() {
     })
     })
   }
   }
 
 
-  command.register(() => [
+  const sessionCommands = createMemo(() => [
     {
     {
       id: "session.new",
       id: "session.new",
       title: language.t("command.session.new"),
       title: language.t("command.session.new"),
@@ -982,6 +972,9 @@ export default function Page() {
       slash: "new",
       slash: "new",
       onSelect: () => navigate(`/${params.dir}/session`),
       onSelect: () => navigate(`/${params.dir}/session`),
     },
     },
+  ])
+
+  const fileCommands = createMemo(() => [
     {
     {
       id: "file.open",
       id: "file.open",
       title: language.t("command.file.open"),
       title: language.t("command.file.open"),
@@ -989,7 +982,7 @@ export default function Page() {
       category: language.t("command.category.file"),
       category: language.t("command.category.file"),
       keybind: "mod+p",
       keybind: "mod+p",
       slash: "open",
       slash: "open",
-      onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={() => showAllFiles()} />),
+      onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
     },
     },
     {
     {
       id: "tab.close",
       id: "tab.close",
@@ -1003,6 +996,9 @@ export default function Page() {
         tabs().close(active)
         tabs().close(active)
       },
       },
     },
     },
+  ])
+
+  const contextCommands = createMemo(() => [
     {
     {
       id: "context.addSelection",
       id: "context.addSelection",
       title: language.t("command.context.addSelection"),
       title: language.t("command.context.addSelection"),
@@ -1034,6 +1030,9 @@ export default function Page() {
         addSelectionToContext(path, selectionFromLines(range))
         addSelectionToContext(path, selectionFromLines(range))
       },
       },
     },
     },
+  ])
+
+  const viewCommands = createMemo(() => [
     {
     {
       id: "terminal.toggle",
       id: "terminal.toggle",
       title: language.t("command.terminal.toggle"),
       title: language.t("command.terminal.toggle"),
@@ -1087,6 +1086,9 @@ export default function Page() {
         setStore("expanded", msg.id, (open: boolean | undefined) => !open)
         setStore("expanded", msg.id, (open: boolean | undefined) => !open)
       },
       },
     },
     },
+  ])
+
+  const messageCommands = createMemo(() => [
     {
     {
       id: "message.previous",
       id: "message.previous",
       title: language.t("command.message.previous"),
       title: language.t("command.message.previous"),
@@ -1105,6 +1107,9 @@ export default function Page() {
       disabled: !params.id,
       disabled: !params.id,
       onSelect: () => navigateMessageByOffset(1),
       onSelect: () => navigateMessageByOffset(1),
     },
     },
+  ])
+
+  const agentCommands = createMemo(() => [
     {
     {
       id: "model.choose",
       id: "model.choose",
       title: language.t("command.model.choose"),
       title: language.t("command.model.choose"),
@@ -1150,6 +1155,9 @@ export default function Page() {
         local.model.variant.cycle()
         local.model.variant.cycle()
       },
       },
     },
     },
+  ])
+
+  const permissionCommands = createMemo(() => [
     {
     {
       id: "permissions.autoaccept",
       id: "permissions.autoaccept",
       title:
       title:
@@ -1173,6 +1181,9 @@ export default function Page() {
         })
         })
       },
       },
     },
     },
+  ])
+
+  const sessionActionCommands = createMemo(() => [
     {
     {
       id: "session.undo",
       id: "session.undo",
       title: language.t("command.session.undo"),
       title: language.t("command.session.undo"),
@@ -1187,17 +1198,14 @@ export default function Page() {
           await sdk.client.session.abort({ sessionID }).catch(() => {})
           await sdk.client.session.abort({ sessionID }).catch(() => {})
         }
         }
         const revert = info()?.revert?.messageID
         const revert = info()?.revert?.messageID
-        // Find the last user message that's not already reverted
         const message = findLast(userMessages(), (x) => !revert || x.id < revert)
         const message = findLast(userMessages(), (x) => !revert || x.id < revert)
         if (!message) return
         if (!message) return
         await sdk.client.session.revert({ sessionID, messageID: message.id })
         await sdk.client.session.revert({ sessionID, messageID: message.id })
-        // Restore the prompt from the reverted message
         const parts = sync.data.part[message.id]
         const parts = sync.data.part[message.id]
         if (parts) {
         if (parts) {
           const restored = extractPromptFromParts(parts, { directory: sdk.directory })
           const restored = extractPromptFromParts(parts, { directory: sdk.directory })
           prompt.set(restored)
           prompt.set(restored)
         }
         }
-        // Navigate to the message before the reverted one (which will be the new last visible message)
         const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
         const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
         setActiveMessage(priorMessage)
         setActiveMessage(priorMessage)
       },
       },
@@ -1216,17 +1224,13 @@ export default function Page() {
         if (!revertMessageID) return
         if (!revertMessageID) return
         const nextMessage = userMessages().find((x) => x.id > revertMessageID)
         const nextMessage = userMessages().find((x) => x.id > revertMessageID)
         if (!nextMessage) {
         if (!nextMessage) {
-          // Full unrevert - restore all messages and navigate to last
           await sdk.client.session.unrevert({ sessionID })
           await sdk.client.session.unrevert({ sessionID })
           prompt.reset()
           prompt.reset()
-          // Navigate to the last message (the one that was at the revert point)
           const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
           const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
           setActiveMessage(lastMsg)
           setActiveMessage(lastMsg)
           return
           return
         }
         }
-        // Partial redo - move forward to next message
         await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
         await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
-        // Navigate to the message before the new revert point
         const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
         const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
         setActiveMessage(priorMsg)
         setActiveMessage(priorMsg)
       },
       },
@@ -1265,74 +1269,90 @@ export default function Page() {
       disabled: !params.id || visibleUserMessages().length === 0,
       disabled: !params.id || visibleUserMessages().length === 0,
       onSelect: () => dialog.show(() => <DialogFork />),
       onSelect: () => dialog.show(() => <DialogFork />),
     },
     },
-    ...(sync.data.config.share !== "disabled"
-      ? [
-          {
-            id: "session.share",
-            title: language.t("command.session.share"),
-            description: language.t("command.session.share.description"),
-            category: language.t("command.category.session"),
-            slash: "share",
-            disabled: !params.id || !!info()?.share?.url,
-            onSelect: async () => {
-              if (!params.id) return
-              await sdk.client.session
-                .share({ sessionID: params.id })
-                .then((res) => {
-                  navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
-                    showToast({
-                      title: language.t("toast.session.share.copyFailed.title"),
-                      variant: "error",
-                    }),
-                  )
-                })
-                .then(() =>
-                  showToast({
-                    title: language.t("toast.session.share.success.title"),
-                    description: language.t("toast.session.share.success.description"),
-                    variant: "success",
-                  }),
-                )
-                .catch(() =>
-                  showToast({
-                    title: language.t("toast.session.share.failed.title"),
-                    description: language.t("toast.session.share.failed.description"),
-                    variant: "error",
-                  }),
-                )
-            },
-          },
-          {
-            id: "session.unshare",
-            title: language.t("command.session.unshare"),
-            description: language.t("command.session.unshare.description"),
-            category: language.t("command.category.session"),
-            slash: "unshare",
-            disabled: !params.id || !info()?.share?.url,
-            onSelect: async () => {
-              if (!params.id) return
-              await sdk.client.session
-                .unshare({ sessionID: params.id })
-                .then(() =>
-                  showToast({
-                    title: language.t("toast.session.unshare.success.title"),
-                    description: language.t("toast.session.unshare.success.description"),
-                    variant: "success",
-                  }),
-                )
-                .catch(() =>
-                  showToast({
-                    title: language.t("toast.session.unshare.failed.title"),
-                    description: language.t("toast.session.unshare.failed.description"),
-                    variant: "error",
-                  }),
-                )
-            },
-          },
-        ]
-      : []),
   ])
   ])
 
 
+  const shareCommands = createMemo(() => {
+    if (sync.data.config.share === "disabled") return []
+    return [
+      {
+        id: "session.share",
+        title: language.t("command.session.share"),
+        description: language.t("command.session.share.description"),
+        category: language.t("command.category.session"),
+        slash: "share",
+        disabled: !params.id || !!info()?.share?.url,
+        onSelect: async () => {
+          if (!params.id) return
+          await sdk.client.session
+            .share({ sessionID: params.id })
+            .then((res) => {
+              navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
+                showToast({
+                  title: language.t("toast.session.share.copyFailed.title"),
+                  variant: "error",
+                }),
+              )
+            })
+            .then(() =>
+              showToast({
+                title: language.t("toast.session.share.success.title"),
+                description: language.t("toast.session.share.success.description"),
+                variant: "success",
+              }),
+            )
+            .catch(() =>
+              showToast({
+                title: language.t("toast.session.share.failed.title"),
+                description: language.t("toast.session.share.failed.description"),
+                variant: "error",
+              }),
+            )
+        },
+      },
+      {
+        id: "session.unshare",
+        title: language.t("command.session.unshare"),
+        description: language.t("command.session.unshare.description"),
+        category: language.t("command.category.session"),
+        slash: "unshare",
+        disabled: !params.id || !info()?.share?.url,
+        onSelect: async () => {
+          if (!params.id) return
+          await sdk.client.session
+            .unshare({ sessionID: params.id })
+            .then(() =>
+              showToast({
+                title: language.t("toast.session.unshare.success.title"),
+                description: language.t("toast.session.unshare.success.description"),
+                variant: "success",
+              }),
+            )
+            .catch(() =>
+              showToast({
+                title: language.t("toast.session.unshare.failed.title"),
+                description: language.t("toast.session.unshare.failed.description"),
+                variant: "error",
+              }),
+            )
+        },
+      },
+    ]
+  })
+
+  command.register("session", () =>
+    combineCommandSections([
+      sessionCommands(),
+      fileCommands(),
+      contextCommands(),
+      viewCommands(),
+      messageCommands(),
+      agentCommands(),
+      permissionCommands(),
+      sessionActionCommands(),
+      shareCommands(),
+    ]),
+  )
+
   const handleKeyDown = (event: KeyboardEvent) => {
   const handleKeyDown = (event: KeyboardEvent) => {
     const activeElement = document.activeElement as HTMLElement | undefined
     const activeElement = document.activeElement as HTMLElement | undefined
     if (activeElement) {
     if (activeElement) {
@@ -1407,19 +1427,7 @@ export default function Page() {
     const activeId = terminal.active()
     const activeId = terminal.active()
     if (!activeId) return
     if (!activeId) return
     setTimeout(() => {
     setTimeout(() => {
-      const wrapper = document.getElementById(`terminal-wrapper-${activeId}`)
-      const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
-      if (!element) return
-
-      // Find and focus the ghostty textarea (the actual input element)
-      const textarea = element.querySelector("textarea") as HTMLTextAreaElement
-      if (textarea) {
-        textarea.focus()
-        return
-      }
-      // Fallback: focus container and dispatch pointer event
-      element.focus()
-      element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
+      focusTerminalById(activeId)
     }, 0)
     }, 0)
   }
   }
 
 
@@ -1457,6 +1465,13 @@ export default function Page() {
     setFileTreeTab("all")
     setFileTreeTab("all")
   }
   }
 
 
+  const openReviewFile = createOpenReviewFile({
+    showAllFiles,
+    tabForPath: file.tab,
+    openTab: tabs().open,
+    loadFile: file.load,
+  })
+
   const changesOptions = ["session", "turn"] as const
   const changesOptions = ["session", "turn"] as const
   const changesOptionsList = [...changesOptions]
   const changesOptionsList = [...changesOptions]
 
 
@@ -1481,65 +1496,72 @@ export default function Page() {
     </div>
     </div>
   )
   )
 
 
+  const reviewContent = (input: {
+    diffStyle: DiffStyle
+    onDiffStyleChange?: (style: DiffStyle) => void
+    classes?: SessionReviewTabProps["classes"]
+    loadingClass: string
+    emptyClass: string
+  }) => (
+    <Switch>
+      <Match when={store.changes === "turn" && !!params.id}>
+        <SessionReviewTab
+          title={changesTitle()}
+          empty={emptyTurn()}
+          diffs={reviewDiffs}
+          view={view}
+          diffStyle={input.diffStyle}
+          onDiffStyleChange={input.onDiffStyleChange}
+          onScrollRef={(el) => setTree("reviewScroll", el)}
+          focusedFile={tree.activeDiff}
+          onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+          comments={comments.all()}
+          focusedComment={comments.focus()}
+          onFocusedCommentChange={comments.setFocus}
+          onViewFile={openReviewFile}
+          classes={input.classes}
+        />
+      </Match>
+      <Match when={hasReview()}>
+        <Show
+          when={diffsReady()}
+          fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
+        >
+          <SessionReviewTab
+            title={changesTitle()}
+            diffs={reviewDiffs}
+            view={view}
+            diffStyle={input.diffStyle}
+            onDiffStyleChange={input.onDiffStyleChange}
+            onScrollRef={(el) => setTree("reviewScroll", el)}
+            focusedFile={tree.activeDiff}
+            onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+            comments={comments.all()}
+            focusedComment={comments.focus()}
+            onFocusedCommentChange={comments.setFocus}
+            onViewFile={openReviewFile}
+            classes={input.classes}
+          />
+        </Show>
+      </Match>
+      <Match when={true}>
+        <div class={input.emptyClass}>
+          <Mark class="w-14 opacity-10" />
+          <div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.empty")}</div>
+        </div>
+      </Match>
+    </Switch>
+  )
+
   const reviewPanel = () => (
   const reviewPanel = () => (
     <div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
     <div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
       <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
       <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
-        <Switch>
-          <Match when={store.changes === "turn" && !!params.id}>
-            <SessionReviewTab
-              title={changesTitle()}
-              empty={emptyTurn()}
-              diffs={reviewDiffs}
-              view={view}
-              diffStyle={layout.review.diffStyle()}
-              onDiffStyleChange={layout.review.setDiffStyle}
-              onScrollRef={(el) => setTree("reviewScroll", el)}
-              focusedFile={tree.activeDiff}
-              onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
-              comments={comments.all()}
-              focusedComment={comments.focus()}
-              onFocusedCommentChange={comments.setFocus}
-              onViewFile={(path) => {
-                showAllFiles()
-                const value = file.tab(path)
-                tabs().open(value)
-                file.load(path)
-              }}
-            />
-          </Match>
-          <Match when={hasReview()}>
-            <Show
-              when={diffsReady()}
-              fallback={<div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>}
-            >
-              <SessionReviewTab
-                title={changesTitle()}
-                diffs={reviewDiffs}
-                view={view}
-                diffStyle={layout.review.diffStyle()}
-                onDiffStyleChange={layout.review.setDiffStyle}
-                onScrollRef={(el) => setTree("reviewScroll", el)}
-                focusedFile={tree.activeDiff}
-                onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
-                comments={comments.all()}
-                focusedComment={comments.focus()}
-                onFocusedCommentChange={comments.setFocus}
-                onViewFile={(path) => {
-                  showAllFiles()
-                  const value = file.tab(path)
-                  tabs().open(value)
-                  file.load(path)
-                }}
-              />
-            </Show>
-          </Match>
-          <Match when={true}>
-            <div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
-              <Mark class="w-14 opacity-10" />
-              <div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.empty")}</div>
-            </div>
-          </Match>
-        </Switch>
+        {reviewContent({
+          diffStyle: layout.review.diffStyle(),
+          onDiffStyleChange: layout.review.setDiffStyle,
+          loadingClass: "px-6 py-4 text-text-weak",
+          emptyClass: "h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6",
+        })}
       </div>
       </div>
     </div>
     </div>
   )
   )
@@ -1656,6 +1678,12 @@ export default function Page() {
     return "empty"
     return "empty"
   })
   })
 
 
+  const activeFileTab = createMemo(() => {
+    const active = activeTab()
+    if (!openedTabs().includes(active)) return
+    return active
+  })
+
   createEffect(() => {
   createEffect(() => {
     if (!layout.ready()) return
     if (!layout.ready()) return
     if (tabs().active()) return
     if (tabs().active()) return
@@ -1760,6 +1788,12 @@ export default function Page() {
 
 
   let scrollStateFrame: number | undefined
   let scrollStateFrame: number | undefined
   let scrollStateTarget: HTMLDivElement | undefined
   let scrollStateTarget: HTMLDivElement | undefined
+  const scrollSpy = createScrollSpy({
+    onActive: (id) => {
+      if (id === store.messageId) return
+      setStore("messageId", id)
+    },
+  })
 
 
   const updateScrollState = (el: HTMLDivElement) => {
   const updateScrollState = (el: HTMLDivElement) => {
     const max = el.scrollHeight - el.clientHeight
     const max = el.scrollHeight - el.clientHeight
@@ -1807,16 +1841,11 @@ export default function Page() {
     ),
     ),
   )
   )
 
 
-  let scrollSpyFrame: number | undefined
-  let scrollSpyTarget: HTMLDivElement | undefined
-
   createEffect(
   createEffect(
     on(
     on(
       sessionKey,
       sessionKey,
       () => {
       () => {
-        if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
-        scrollSpyFrame = undefined
-        scrollSpyTarget = undefined
+        scrollSpy.clear()
       },
       },
       { defer: true },
       { defer: true },
     ),
     ),
@@ -1827,6 +1856,7 @@ export default function Page() {
   const setScrollRef = (el: HTMLDivElement | undefined) => {
   const setScrollRef = (el: HTMLDivElement | undefined) => {
     scroller = el
     scroller = el
     autoScroll.scrollRef(el)
     autoScroll.scrollRef(el)
+    scrollSpy.setContainer(el)
     if (el) scheduleScrollState(el)
     if (el) scheduleScrollState(el)
   }
   }
 
 
@@ -1835,6 +1865,7 @@ export default function Page() {
     () => {
     () => {
       const el = scroller
       const el = scroller
       if (el) scheduleScrollState(el)
       if (el) scheduleScrollState(el)
+      scrollSpy.markDirty()
     },
     },
   )
   )
 
 
@@ -1940,6 +1971,7 @@ export default function Page() {
       }
       }
 
 
       if (el) scheduleScrollState(el)
       if (el) scheduleScrollState(el)
+      scrollSpy.markDirty()
     },
     },
   )
   )
 
 
@@ -2053,61 +2085,6 @@ export default function Page() {
     if (el) scheduleScrollState(el)
     if (el) scheduleScrollState(el)
   }
   }
 
 
-  const closestMessage = (node: Element | null): HTMLElement | null => {
-    if (!node) return null
-    const match = node.closest?.("[data-message-id]") as HTMLElement | null
-    if (match) return match
-    const root = node.getRootNode?.()
-    if (root instanceof ShadowRoot) return closestMessage(root.host)
-    return null
-  }
-
-  const getActiveMessageId = (container: HTMLDivElement) => {
-    const rect = container.getBoundingClientRect()
-    if (!rect.width || !rect.height) return
-
-    const x = Math.min(window.innerWidth - 1, Math.max(0, rect.left + rect.width / 2))
-    const y = Math.min(window.innerHeight - 1, Math.max(0, rect.top + 100))
-
-    const hit = document.elementFromPoint(x, y)
-    const host = closestMessage(hit)
-    const id = host?.dataset.messageId
-    if (id) return id
-
-    // Fallback: DOM query (handles edge hit-testing cases)
-    const cutoff = container.scrollTop + 100
-    const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
-    let last: string | undefined
-
-    for (const node of nodes) {
-      const next = node.dataset.messageId
-      if (!next) continue
-      if (node.offsetTop > cutoff) break
-      last = next
-    }
-
-    return last
-  }
-
-  const scheduleScrollSpy = (container: HTMLDivElement) => {
-    scrollSpyTarget = container
-    if (scrollSpyFrame !== undefined) return
-
-    scrollSpyFrame = requestAnimationFrame(() => {
-      scrollSpyFrame = undefined
-
-      const target = scrollSpyTarget
-      scrollSpyTarget = undefined
-      if (!target) return
-
-      const id = getActiveMessageId(target)
-      if (!id) return
-      if (id === store.messageId) return
-
-      setStore("messageId", id)
-    })
-  }
-
   createEffect(() => {
   createEffect(() => {
     const sessionID = params.id
     const sessionID = params.id
     const ready = messagesReady()
     const ready = messagesReady()
@@ -2215,7 +2192,7 @@ export default function Page() {
   onCleanup(() => {
   onCleanup(() => {
     cancelTurnBackfill()
     cancelTurnBackfill()
     document.removeEventListener("keydown", handleKeyDown)
     document.removeEventListener("keydown", handleKeyDown)
-    if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
+    scrollSpy.destroy()
     if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
     if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
   })
   })
 
 
@@ -2272,74 +2249,16 @@ export default function Page() {
                     when={!mobileChanges()}
                     when={!mobileChanges()}
                     fallback={
                     fallback={
                       <div class="relative h-full overflow-hidden">
                       <div class="relative h-full overflow-hidden">
-                        <Switch>
-                          <Match when={store.changes === "turn" && !!params.id}>
-                            <SessionReviewTab
-                              title={changesTitle()}
-                              empty={emptyTurn()}
-                              diffs={reviewDiffs}
-                              view={view}
-                              diffStyle="unified"
-                              focusedFile={tree.activeDiff}
-                              onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
-                              comments={comments.all()}
-                              focusedComment={comments.focus()}
-                              onFocusedCommentChange={comments.setFocus}
-                              onViewFile={(path) => {
-                                showAllFiles()
-                                const value = file.tab(path)
-                                tabs().open(value)
-                                file.load(path)
-                              }}
-                              classes={{
-                                root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
-                                header: "px-4",
-                                container: "px-4",
-                              }}
-                            />
-                          </Match>
-                          <Match when={hasReview()}>
-                            <Show
-                              when={diffsReady()}
-                              fallback={
-                                <div class="px-4 py-4 text-text-weak">
-                                  {language.t("session.review.loadingChanges")}
-                                </div>
-                              }
-                            >
-                              <SessionReviewTab
-                                title={changesTitle()}
-                                diffs={reviewDiffs}
-                                view={view}
-                                diffStyle="unified"
-                                focusedFile={tree.activeDiff}
-                                onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
-                                comments={comments.all()}
-                                focusedComment={comments.focus()}
-                                onFocusedCommentChange={comments.setFocus}
-                                onViewFile={(path) => {
-                                  showAllFiles()
-                                  const value = file.tab(path)
-                                  tabs().open(value)
-                                  file.load(path)
-                                }}
-                                classes={{
-                                  root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
-                                  header: "px-4",
-                                  container: "px-4",
-                                }}
-                              />
-                            </Show>
-                          </Match>
-                          <Match when={true}>
-                            <div class="h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6">
-                              <Mark class="w-14 opacity-10" />
-                              <div class="text-14-regular text-text-weak max-w-56">
-                                {language.t("session.review.empty")}
-                              </div>
-                            </div>
-                          </Match>
-                        </Switch>
+                        {reviewContent({
+                          diffStyle: "unified",
+                          classes: {
+                            root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
+                            header: "px-4",
+                            container: "px-4",
+                          },
+                          loadingClass: "px-4 py-4 text-text-weak",
+                          emptyClass: "h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6",
+                        })}
                       </div>
                       </div>
                     }
                     }
                   >
                   >
@@ -2451,7 +2370,7 @@ export default function Page() {
                           if (!hasScrollGesture()) return
                           if (!hasScrollGesture()) return
                           autoScroll.handleScroll()
                           autoScroll.handleScroll()
                           markScrollGesture(e.currentTarget)
                           markScrollGesture(e.currentTarget)
-                          if (isDesktop()) scheduleScrollSpy(e.currentTarget)
+                          if (isDesktop()) scrollSpy.onScroll()
                         }}
                         }}
                         onClick={autoScroll.handleInteraction}
                         onClick={autoScroll.handleInteraction}
                         class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
                         class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
@@ -2636,6 +2555,10 @@ export default function Page() {
                                 <div
                                 <div
                                   id={anchor(message.id)}
                                   id={anchor(message.id)}
                                   data-message-id={message.id}
                                   data-message-id={message.id}
+                                  ref={(el) => {
+                                    scrollSpy.register(el, message.id)
+                                    onCleanup(() => scrollSpy.unregister(message.id))
+                                  }}
                                   classList={{
                                   classList={{
                                     "min-w-0 w-full max-w-full": true,
                                     "min-w-0 w-full max-w-full": true,
                                     "md:max-w-200 3xl:max-w-[1200px]": centered(),
                                     "md:max-w-200 3xl:max-w-[1200px]": centered(),
@@ -2979,7 +2902,7 @@ export default function Page() {
                         </Tabs.Content>
                         </Tabs.Content>
                       </Show>
                       </Show>
 
 
-                      <For each={openedTabs()}>
+                      <Show when={activeFileTab()} keyed>
                         {(tab) => {
                         {(tab) => {
                           let scroll: HTMLDivElement | undefined
                           let scroll: HTMLDivElement | undefined
                           let scrollFrame: number | undefined
                           let scrollFrame: number | undefined
@@ -3483,7 +3406,7 @@ export default function Page() {
                             </Tabs.Content>
                             </Tabs.Content>
                           )
                           )
                         }}
                         }}
-                      </For>
+                      </Show>
                     </Tabs>
                     </Tabs>
                     <DragOverlay>
                     <DragOverlay>
                       <Show when={store.activeDraggable}>
                       <Show when={store.activeDraggable}>

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

@@ -0,0 +1,61 @@
+import { describe, expect, test } from "bun:test"
+import { combineCommandSections, createOpenReviewFile, focusTerminalById } from "./helpers"
+
+describe("createOpenReviewFile", () => {
+  test("opens and loads selected review file", () => {
+    const calls: string[] = []
+    const openReviewFile = createOpenReviewFile({
+      showAllFiles: () => calls.push("show"),
+      tabForPath: (path) => {
+        calls.push(`tab:${path}`)
+        return `file://${path}`
+      },
+      openTab: (tab) => calls.push(`open:${tab}`),
+      loadFile: (path) => calls.push(`load:${path}`),
+    })
+
+    openReviewFile("src/a.ts")
+
+    expect(calls).toEqual(["show", "tab:src/a.ts", "open:file://src/a.ts", "load:src/a.ts"])
+  })
+})
+
+describe("focusTerminalById", () => {
+  test("focuses textarea when present", () => {
+    document.body.innerHTML = `<div id="terminal-wrapper-one"><div data-component="terminal"><textarea></textarea></div></div>`
+
+    const focused = focusTerminalById("one")
+
+    expect(focused).toBe(true)
+    expect(document.activeElement?.tagName).toBe("TEXTAREA")
+  })
+
+  test("falls back to terminal element focus", () => {
+    document.body.innerHTML = `<div id="terminal-wrapper-two"><div data-component="terminal" tabindex="0"></div></div>`
+    const terminal = document.querySelector('[data-component="terminal"]') as HTMLElement
+    let pointerDown = false
+    terminal.addEventListener("pointerdown", () => {
+      pointerDown = true
+    })
+
+    const focused = focusTerminalById("two")
+
+    expect(focused).toBe(true)
+    expect(document.activeElement).toBe(terminal)
+    expect(pointerDown).toBe(true)
+  })
+})
+
+describe("combineCommandSections", () => {
+  test("keeps section order stable", () => {
+    const result = combineCommandSections([
+      [{ id: "a", title: "A" }],
+      [
+        { id: "b", title: "B" },
+        { id: "c", title: "C" },
+      ],
+    ])
+
+    expect(result.map((item) => item.id)).toEqual(["a", "b", "c"])
+  })
+})

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

@@ -0,0 +1,38 @@
+import type { CommandOption } from "@/context/command"
+
+export const focusTerminalById = (id: string) => {
+  const wrapper = document.getElementById(`terminal-wrapper-${id}`)
+  const terminal = wrapper?.querySelector('[data-component="terminal"]')
+  if (!(terminal instanceof HTMLElement)) return false
+
+  const textarea = terminal.querySelector("textarea")
+  if (textarea instanceof HTMLTextAreaElement) {
+    textarea.focus()
+    return true
+  }
+
+  terminal.focus()
+  terminal.dispatchEvent(
+    typeof PointerEvent === "function"
+      ? new PointerEvent("pointerdown", { bubbles: true, cancelable: true })
+      : new MouseEvent("pointerdown", { bubbles: true, cancelable: true }),
+  )
+  return true
+}
+
+export const createOpenReviewFile = (input: {
+  showAllFiles: () => void
+  tabForPath: (path: string) => string
+  openTab: (tab: string) => void
+  loadFile: (path: string) => void
+}) => {
+  return (path: string) => {
+    input.showAllFiles()
+    input.openTab(input.tabForPath(path))
+    input.loadFile(path)
+  }
+}
+
+export const combineCommandSections = (sections: readonly (readonly CommandOption[])[]) => {
+  return sections.flatMap((section) => section)
+}

+ 127 - 0
packages/app/src/pages/session/scroll-spy.test.ts

@@ -0,0 +1,127 @@
+import { describe, expect, test } from "bun:test"
+import { createScrollSpy, pickOffsetId, pickVisibleId } from "./scroll-spy"
+
+const rect = (top: number, height = 80): DOMRect =>
+  ({
+    x: 0,
+    y: top,
+    top,
+    left: 0,
+    right: 800,
+    bottom: top + height,
+    width: 800,
+    height,
+    toJSON: () => ({}),
+  }) as DOMRect
+
+const setRect = (el: Element, top: number, height = 80) => {
+  Object.defineProperty(el, "getBoundingClientRect", {
+    configurable: true,
+    value: () => rect(top, height),
+  })
+}
+
+describe("pickVisibleId", () => {
+  test("prefers higher intersection ratio", () => {
+    const id = pickVisibleId(
+      [
+        { id: "a", ratio: 0.2, top: 100 },
+        { id: "b", ratio: 0.8, top: 300 },
+      ],
+      120,
+    )
+
+    expect(id).toBe("b")
+  })
+
+  test("breaks ratio ties by nearest line", () => {
+    const id = pickVisibleId(
+      [
+        { id: "a", ratio: 0.5, top: 90 },
+        { id: "b", ratio: 0.5, top: 140 },
+      ],
+      130,
+    )
+
+    expect(id).toBe("b")
+  })
+})
+
+describe("pickOffsetId", () => {
+  test("uses binary search cutoff", () => {
+    const id = pickOffsetId(
+      [
+        { id: "a", top: 0 },
+        { id: "b", top: 200 },
+        { id: "c", top: 400 },
+      ],
+      350,
+    )
+
+    expect(id).toBe("b")
+  })
+})
+
+describe("createScrollSpy fallback", () => {
+  test("tracks active id from offsets and dirty refresh", () => {
+    const active: string[] = []
+    const root = document.createElement("div") as HTMLDivElement
+    const one = document.createElement("div")
+    const two = document.createElement("div")
+    const three = document.createElement("div")
+
+    root.append(one, two, three)
+    document.body.append(root)
+
+    Object.defineProperty(root, "scrollTop", { configurable: true, writable: true, value: 250 })
+    setRect(root, 0, 800)
+    setRect(one, -250)
+    setRect(two, -50)
+    setRect(three, 150)
+
+    const queue: FrameRequestCallback[] = []
+    const flush = () => {
+      const run = [...queue]
+      queue.length = 0
+      for (const cb of run) cb(0)
+    }
+
+    const spy = createScrollSpy({
+      onActive: (id) => active.push(id),
+      raf: (cb) => (queue.push(cb), queue.length),
+      caf: () => {},
+      IntersectionObserver: undefined,
+      ResizeObserver: undefined,
+      MutationObserver: undefined,
+    })
+
+    spy.setContainer(root)
+    spy.register(one, "a")
+    spy.register(two, "b")
+    spy.register(three, "c")
+    spy.onScroll()
+    flush()
+
+    expect(spy.getActiveId()).toBe("b")
+    expect(active.at(-1)).toBe("b")
+
+    root.scrollTop = 450
+    setRect(one, -450)
+    setRect(two, -250)
+    setRect(three, -50)
+    spy.onScroll()
+    flush()
+    expect(spy.getActiveId()).toBe("c")
+
+    root.scrollTop = 250
+    setRect(one, -250)
+    setRect(two, 250)
+    setRect(three, 150)
+    spy.markDirty()
+    spy.onScroll()
+    flush()
+    expect(spy.getActiveId()).toBe("a")
+
+    spy.destroy()
+  })
+})

+ 274 - 0
packages/app/src/pages/session/scroll-spy.ts

@@ -0,0 +1,274 @@
+type Visible = {
+  id: string
+  ratio: number
+  top: number
+}
+
+type Offset = {
+  id: string
+  top: number
+}
+
+type Input = {
+  onActive: (id: string) => void
+  raf?: (cb: FrameRequestCallback) => number
+  caf?: (id: number) => void
+  IntersectionObserver?: typeof globalThis.IntersectionObserver
+  ResizeObserver?: typeof globalThis.ResizeObserver
+  MutationObserver?: typeof globalThis.MutationObserver
+}
+
+export const pickVisibleId = (list: Visible[], line: number) => {
+  if (list.length === 0) return
+
+  const sorted = [...list].sort((a, b) => {
+    if (b.ratio !== a.ratio) return b.ratio - a.ratio
+
+    const da = Math.abs(a.top - line)
+    const db = Math.abs(b.top - line)
+    if (da !== db) return da - db
+
+    return a.top - b.top
+  })
+
+  return sorted[0]?.id
+}
+
+export const pickOffsetId = (list: Offset[], cutoff: number) => {
+  if (list.length === 0) return
+
+  let lo = 0
+  let hi = list.length - 1
+  let out = 0
+
+  while (lo <= hi) {
+    const mid = (lo + hi) >> 1
+    const top = list[mid]?.top
+    if (top === undefined) break
+
+    if (top <= cutoff) {
+      out = mid
+      lo = mid + 1
+      continue
+    }
+
+    hi = mid - 1
+  }
+
+  return list[out]?.id
+}
+
+export const createScrollSpy = (input: Input) => {
+  const raf = input.raf ?? requestAnimationFrame
+  const caf = input.caf ?? cancelAnimationFrame
+  const CtorIO = input.IntersectionObserver ?? globalThis.IntersectionObserver
+  const CtorRO = input.ResizeObserver ?? globalThis.ResizeObserver
+  const CtorMO = input.MutationObserver ?? globalThis.MutationObserver
+
+  let root: HTMLDivElement | undefined
+  let io: IntersectionObserver | undefined
+  let ro: ResizeObserver | undefined
+  let mo: MutationObserver | undefined
+  let frame: number | undefined
+  let active: string | undefined
+  let dirty = true
+
+  const node = new Map<string, HTMLElement>()
+  const id = new WeakMap<HTMLElement, string>()
+  const visible = new Map<string, { ratio: number; top: number }>()
+  let offset: Offset[] = []
+
+  const schedule = () => {
+    if (frame !== undefined) return
+    frame = raf(() => {
+      frame = undefined
+      update()
+    })
+  }
+
+  const refreshOffset = () => {
+    const el = root
+    if (!el) {
+      offset = []
+      dirty = false
+      return
+    }
+
+    const base = el.getBoundingClientRect().top
+    offset = [...node].map(([next, item]) => ({
+      id: next,
+      top: item.getBoundingClientRect().top - base + el.scrollTop,
+    }))
+    offset.sort((a, b) => a.top - b.top)
+    dirty = false
+  }
+
+  const update = () => {
+    const el = root
+    if (!el) return
+
+    const line = el.getBoundingClientRect().top + 100
+    const next =
+      pickVisibleId(
+        [...visible].map(([k, v]) => ({
+          id: k,
+          ratio: v.ratio,
+          top: v.top,
+        })),
+        line,
+      ) ??
+      (() => {
+        if (dirty) refreshOffset()
+        return pickOffsetId(offset, el.scrollTop + 100)
+      })()
+
+    if (!next || next === active) return
+    active = next
+    input.onActive(next)
+  }
+
+  const observe = () => {
+    const el = root
+    if (!el) return
+
+    io?.disconnect()
+    io = undefined
+    if (CtorIO) {
+      try {
+        io = new CtorIO(
+          (entries) => {
+            for (const entry of entries) {
+              const item = entry.target
+              if (!(item instanceof HTMLElement)) continue
+              const key = id.get(item)
+              if (!key) continue
+
+              if (!entry.isIntersecting || entry.intersectionRatio <= 0) {
+                visible.delete(key)
+                continue
+              }
+
+              visible.set(key, {
+                ratio: entry.intersectionRatio,
+                top: entry.boundingClientRect.top,
+              })
+            }
+
+            schedule()
+          },
+          {
+            root: el,
+            threshold: [0, 0.25, 0.5, 0.75, 1],
+          },
+        )
+      } catch {
+        io = undefined
+      }
+    }
+
+    if (io) {
+      for (const item of node.values()) io.observe(item)
+    }
+
+    ro?.disconnect()
+    ro = undefined
+    if (CtorRO) {
+      ro = new CtorRO(() => {
+        dirty = true
+        schedule()
+      })
+      ro.observe(el)
+      for (const item of node.values()) ro.observe(item)
+    }
+
+    mo?.disconnect()
+    mo = undefined
+    if (CtorMO) {
+      mo = new CtorMO(() => {
+        dirty = true
+        schedule()
+      })
+      mo.observe(el, { subtree: true, childList: true, characterData: true })
+    }
+
+    dirty = true
+    schedule()
+  }
+
+  const setContainer = (el?: HTMLDivElement) => {
+    if (root === el) return
+
+    root = el
+    visible.clear()
+    active = undefined
+    observe()
+  }
+
+  const register = (el: HTMLElement, key: string) => {
+    const prev = node.get(key)
+    if (prev && prev !== el) {
+      io?.unobserve(prev)
+      ro?.unobserve(prev)
+    }
+
+    node.set(key, el)
+    id.set(el, key)
+    if (io) io.observe(el)
+    if (ro) ro.observe(el)
+    dirty = true
+    schedule()
+  }
+
+  const unregister = (key: string) => {
+    const item = node.get(key)
+    if (!item) return
+
+    io?.unobserve(item)
+    ro?.unobserve(item)
+    node.delete(key)
+    visible.delete(key)
+    dirty = true
+  }
+
+  const markDirty = () => {
+    dirty = true
+    schedule()
+  }
+
+  const clear = () => {
+    for (const item of node.values()) {
+      io?.unobserve(item)
+      ro?.unobserve(item)
+    }
+
+    node.clear()
+    visible.clear()
+    offset = []
+    active = undefined
+    dirty = true
+  }
+
+  const destroy = () => {
+    if (frame !== undefined) caf(frame)
+    frame = undefined
+    clear()
+    io?.disconnect()
+    ro?.disconnect()
+    mo?.disconnect()
+    io = undefined
+    ro = undefined
+    mo = undefined
+    root = undefined
+  }
+
+  return {
+    setContainer,
+    register,
+    unregister,
+    onScroll: schedule,
+    markDirty,
+    clear,
+    destroy,
+    getActiveId: () => active,
+  }
+}

+ 69 - 0
packages/app/src/utils/scoped-cache.test.ts

@@ -0,0 +1,69 @@
+import { describe, expect, test } from "bun:test"
+import { createScopedCache } from "./scoped-cache"
+
+describe("createScopedCache", () => {
+  test("evicts least-recently-used entry when max is reached", () => {
+    const disposed: string[] = []
+    const cache = createScopedCache((key) => ({ key }), {
+      maxEntries: 2,
+      dispose: (value) => disposed.push(value.key),
+    })
+
+    const a = cache.get("a")
+    const b = cache.get("b")
+    expect(a.key).toBe("a")
+    expect(b.key).toBe("b")
+
+    cache.get("a")
+    const c = cache.get("c")
+
+    expect(c.key).toBe("c")
+    expect(cache.peek("a")?.key).toBe("a")
+    expect(cache.peek("b")).toBeUndefined()
+    expect(cache.peek("c")?.key).toBe("c")
+    expect(disposed).toEqual(["b"])
+  })
+
+  test("disposes entries on delete and clear", () => {
+    const disposed: string[] = []
+    const cache = createScopedCache((key) => ({ key }), {
+      dispose: (value) => disposed.push(value.key),
+    })
+
+    cache.get("a")
+    cache.get("b")
+
+    const removed = cache.delete("a")
+    expect(removed?.key).toBe("a")
+    expect(cache.peek("a")).toBeUndefined()
+
+    cache.clear()
+    expect(cache.peek("b")).toBeUndefined()
+    expect(disposed).toEqual(["a", "b"])
+  })
+
+  test("expires stale entries with ttl and recreates on get", () => {
+    let clock = 0
+    let count = 0
+    const disposed: string[] = []
+    const cache = createScopedCache((key) => ({ key, count: ++count }), {
+      ttlMs: 10,
+      now: () => clock,
+      dispose: (value) => disposed.push(`${value.key}:${value.count}`),
+    })
+
+    const first = cache.get("a")
+    expect(first.count).toBe(1)
+
+    clock = 9
+    expect(cache.peek("a")?.count).toBe(1)
+
+    clock = 11
+    expect(cache.peek("a")).toBeUndefined()
+    expect(disposed).toEqual(["a:1"])
+
+    const second = cache.get("a")
+    expect(second.count).toBe(2)
+    expect(disposed).toEqual(["a:1"])
+  })
+})

+ 104 - 0
packages/app/src/utils/scoped-cache.ts

@@ -0,0 +1,104 @@
+type ScopedCacheOptions<T> = {
+  maxEntries?: number
+  ttlMs?: number
+  dispose?: (value: T, key: string) => void
+  now?: () => number
+}
+
+type Entry<T> = {
+  value: T
+  touchedAt: number
+}
+
+export function createScopedCache<T>(createValue: (key: string) => T, options: ScopedCacheOptions<T> = {}) {
+  const store = new Map<string, Entry<T>>()
+  const now = options.now ?? Date.now
+
+  const dispose = (key: string, entry: Entry<T>) => {
+    options.dispose?.(entry.value, key)
+  }
+
+  const expired = (entry: Entry<T>) => {
+    if (options.ttlMs === undefined) return false
+    return now() - entry.touchedAt >= options.ttlMs
+  }
+
+  const sweep = () => {
+    if (options.ttlMs === undefined) return
+    for (const [key, entry] of store) {
+      if (!expired(entry)) continue
+      store.delete(key)
+      dispose(key, entry)
+    }
+  }
+
+  const touch = (key: string, entry: Entry<T>) => {
+    entry.touchedAt = now()
+    store.delete(key)
+    store.set(key, entry)
+  }
+
+  const prune = () => {
+    if (options.maxEntries === undefined) return
+    while (store.size > options.maxEntries) {
+      const key = store.keys().next().value
+      if (!key) return
+      const entry = store.get(key)
+      store.delete(key)
+      if (!entry) continue
+      dispose(key, entry)
+    }
+  }
+
+  const remove = (key: string) => {
+    const entry = store.get(key)
+    if (!entry) return
+    store.delete(key)
+    dispose(key, entry)
+    return entry.value
+  }
+
+  const peek = (key: string) => {
+    sweep()
+    const entry = store.get(key)
+    if (!entry) return
+    if (!expired(entry)) return entry.value
+    store.delete(key)
+    dispose(key, entry)
+  }
+
+  const get = (key: string) => {
+    sweep()
+    const entry = store.get(key)
+    if (entry && !expired(entry)) {
+      touch(key, entry)
+      return entry.value
+    }
+    if (entry) {
+      store.delete(key)
+      dispose(key, entry)
+    }
+
+    const created = {
+      value: createValue(key),
+      touchedAt: now(),
+    }
+    store.set(key, created)
+    prune()
+    return created.value
+  }
+
+  const clear = () => {
+    for (const [key, entry] of store) {
+      dispose(key, entry)
+    }
+    store.clear()
+  }
+
+  return {
+    get,
+    peek,
+    delete: remove,
+    clear,
+  }
+}

+ 105 - 0
specs/09-session-page-hot-paths.md

@@ -0,0 +1,105 @@
+## Session hot paths
+
+Reduce render work and duplication in `session.tsx`
+
+---
+
+### Summary
+
+`packages/app/src/pages/session.tsx` mixes routing, commands, tab rendering, review panel wiring, terminal focus logic, and message scrolling. This spec targets hot-path performance + local code quality improvements that can ship together in one session-page-focused PR. It should follow the keyed command-registration pattern introduced in `packages/app/src/context/command.tsx`.
+
+---
+
+### Goals
+
+- Render heavy file-tab content only for the active tab
+- Deduplicate review-panel wiring used in desktop and mobile paths
+- Centralize terminal-focus DOM logic into one helper
+- Reduce churn in command registration setup
+
+---
+
+### Non-goals
+
+- Scroll-spy rewrite (covered by `specs/04-scroll-spy-optimization.md`)
+- Large routing/layout redesign
+- Behavior changes to prompt submission or session history
+
+---
+
+### Parallel execution contract
+
+This spec owns:
+
+- `packages/app/src/pages/session.tsx`
+- New files under `packages/app/src/pages/session/*` (if extracted)
+
+This spec should not modify:
+
+- `packages/app/src/context/*`
+- `packages/app/src/components/prompt-input.tsx`
+- `packages/app/src/components/file-tree.tsx`
+
+---
+
+### Implementation plan
+
+1. Add shared helpers for repeated session-page actions
+
+- Extract `openReviewFile(path)` helper to replace repeated inline `onViewFile` bodies.
+- Extract `focusTerminalById(id)` helper and reuse in both:
+  - terminal active change effect
+  - terminal drag-end focus restoration
+
+2. Deduplicate review panel construction
+
+- Build a shared review props factory (or local render helper) so desktop/mobile paths do not duplicate comment wiring, `onViewFile`, and classes glue.
+- Keep per-surface differences limited to layout classes and diff style.
+
+3. Gate heavy file-tab rendering by active tab
+
+- Keep tab trigger list rendered for all opened tabs.
+- Render `Tabs.Content` body only for `activeTab()`, plus lightweight placeholders as needed.
+- Ensure per-tab scroll state restore still works when reactivating a tab.
+
+4. Reduce command registry reallocation
+
+- Register session commands with a stable key (`command.register("session", ...)`) so remounts replace prior session command entries.
+- Move large command-array construction into smaller memoized blocks:
+  - stable command definitions
+  - dynamic state fields (`disabled`, titles) as narrow computed closures
+- Keep command IDs, keybinds, and behavior identical.
+
+---
+
+### Acceptance criteria
+
+- File tab bodies are not all mounted at once for large open-tab sets.
+- `onViewFile` review behavior is defined in one shared helper.
+- Terminal focus query/dispatch logic lives in one function and is reused.
+- Session command registration uses a stable key (`"session"`) and `command.register` no longer contains one monolithic inline array with repeated inline handlers for shared actions.
+- Session UX remains unchanged for:
+  - opening files from review
+  - drag-reordering terminal tabs
+  - keyboard command execution
+
+---
+
+### Validation plan
+
+- Manual:
+  - Open 12+ file tabs, switch quickly, verify active tab restore and no blank states.
+  - Open review panel (desktop and mobile), use "view file" from diffs, verify same behavior as before.
+  - Drag terminal tab, ensure terminal input focus is restored.
+  - Run key commands: `mod+p`, `mod+w`, `mod+shift+r`, `ctrl+``.
+- Perf sanity:
+  - Compare CPU usage while switching tabs with many opened files before/after.
+
+---
+
+### Risks and mitigations
+
+- Risk: unmounted tab content loses transient editor state.
+  - Mitigation: keep persisted scroll/selection restore path intact and verify reactivation behavior.
+- Risk: command refactor subtly changes command ordering.
+  - Mitigation: keep IDs and registration order stable, diff against current command list in dev.

+ 99 - 0
specs/10-file-content-eviction-accounting.md

@@ -0,0 +1,99 @@
+## File cache accounting
+
+Make file-content eviction bookkeeping O(1)
+
+---
+
+### Summary
+
+`packages/app/src/context/file.tsx` currently recomputes total cached bytes by reducing the entire LRU map inside the eviction loop. This creates avoidable overhead on large file sets. We will switch to incremental byte accounting while keeping LRU behavior unchanged.
+
+---
+
+### Goals
+
+- Remove repeated full-map reductions from eviction path
+- Maintain accurate total byte tracking incrementally
+- Preserve existing eviction semantics (entry count + byte cap)
+
+---
+
+### Non-goals
+
+- Changing cache limits
+- Changing file loading API behavior
+- Introducing cross-session shared caches
+
+---
+
+### Parallel execution contract
+
+This spec owns:
+
+- `packages/app/src/context/file.tsx`
+- Optional tests in `packages/app/src/context/*file*.test.ts`
+
+This spec should not modify:
+
+- `packages/app/src/pages/session.tsx`
+- `packages/app/src/components/file-tree.tsx`
+
+---
+
+### Implementation plan
+
+1. Introduce incremental byte counters
+
+- Add module-level `contentBytesTotal`.
+- Add helper(s):
+  - `setContentBytes(path, nextBytes)`
+  - `removeContentBytes(path)`
+  - `resetContentBytes()`
+
+2. Refactor LRU touch/update path
+
+- Keep `contentLru` as LRU order map.
+- Update byte total only when a path is inserted/updated/removed.
+- Ensure replacing existing byte value updates total correctly.
+
+3. Refactor eviction loop
+
+- Use `contentBytesTotal` in loop condition instead of `Array.from(...).reduce(...)`.
+- On eviction, remove from both `contentLru` and byte counter.
+
+4. Keep scope reset correct
+
+- On directory scope change, clear inflight maps + `contentLru` + byte counter.
+
+---
+
+### Acceptance criteria
+
+- `evictContent` performs no full-map reduction per iteration.
+- Total bytes remain accurate after:
+  - loading file A
+  - loading file B
+  - force-reloading file A with a different size
+  - evicting entries
+  - scope reset
+- Existing caps (`MAX_FILE_CONTENT_ENTRIES`, `MAX_FILE_CONTENT_BYTES`) continue to enforce correctly.
+
+---
+
+### Validation plan
+
+- Manual:
+  - Open many files with mixed sizes and verify old files still evict as before.
+  - Switch directory scope and verify cache clears safely.
+- Optional unit coverage:
+  - size counter updates on overwrite + delete.
+  - eviction condition uses count and bytes as expected.
+
+---
+
+### Risks and mitigations
+
+- Risk: byte counter drifts from map contents.
+  - Mitigation: route all updates through centralized helpers.
+- Risk: stale bytes retained on early returns.
+  - Mitigation: assert cleanup paths in `finally`/scope reset still execute.

+ 92 - 0
specs/11-layout-view-tabs-reactivity.md

@@ -0,0 +1,92 @@
+## Layout reactivity
+
+Reduce per-call reactive overhead in `useLayout`
+
+---
+
+### Summary
+
+`packages/app/src/context/layout.tsx` creates reactive effects inside `view(sessionKey)` and `tabs(sessionKey)` each time these helpers are called. Multiple consumers for the same key can accumulate duplicate watchers. This spec simplifies the API internals so calls stay lightweight while preserving behavior.
+
+---
+
+### Goals
+
+- Remove avoidable per-call `createEffect` allocations in `view()` and `tabs()`
+- Preserve scroll seeding, pruning, and touch semantics
+- Keep external `useLayout` API stable
+
+---
+
+### Non-goals
+
+- Persistence schema migration
+- Session tab behavior redesign
+- New layout features
+
+---
+
+### Parallel execution contract
+
+This spec owns:
+
+- `packages/app/src/context/layout.tsx`
+- `packages/app/src/context/layout-scroll.test.ts` (if updates needed)
+
+This spec should not modify:
+
+- `packages/app/src/pages/session.tsx`
+- `packages/app/src/components/session/*`
+
+---
+
+### Implementation plan
+
+1. Consolidate key-touch logic
+
+- Introduce shared internal helper, e.g. `ensureSessionKey(key)` that performs:
+  - `touch(key)`
+  - `scroll.seed(key)`
+
+2. Remove per-call effects in `view()` / `tabs()`
+
+- Replace internal `createEffect(on(key, ...))` usage with lazy key reads inside accessors/memos.
+- Ensure reads still invoke `ensureSessionKey` at safe points.
+
+3. Keep return API stable
+
+- Preserve current method names and behavior:
+  - `view(...).scroll`, `setScroll`, `terminal`, `reviewPanel`, `review`
+  - `tabs(...).active`, `all`, `open`, `close`, `move`, etc.
+
+4. Verify pruning behavior
+
+- Ensure session-key pruning still runs when key set grows and active key changes.
+
+---
+
+### Acceptance criteria
+
+- `view()` and `tabs()` no longer instantiate per-call key-change effects.
+- Existing callers do not require API changes.
+- Scroll restore and tab persistence still work across session navigation.
+- No regressions in handoff/pending-message behavior.
+
+---
+
+### Validation plan
+
+- Manual:
+  - Navigate across multiple sessions; verify tabs + review open state + scroll positions restore.
+  - Toggle terminal/review panels and confirm persisted state remains consistent.
+- Tests:
+  - Update/add targeted tests for key seeding/pruning if behavior changed.
+
+---
+
+### Risks and mitigations
+
+- Risk: subtle key-touch ordering changes affect prune timing.
+  - Mitigation: keep `touch` and `seed` coupled through one helper and verify prune boundaries.
+- Risk: removing effects misses updates for dynamic accessor keys.
+  - Mitigation: ensure every public accessor path reads current key and calls helper.

+ 96 - 0
specs/12-session-context-metrics-shared.md

@@ -0,0 +1,96 @@
+## Context metrics shared
+
+Unify duplicate session usage calculations
+
+---
+
+### Summary
+
+`session-context-tab.tsx` and `session-context-usage.tsx` both compute overlapping session metrics (cost, last assistant token totals, provider/model context usage). This creates duplicate loops and raises drift risk. We will centralize shared calculations in one helper module and have both components consume it.
+
+---
+
+### Goals
+
+- Compute shared session usage metrics in one place
+- Remove duplicate loops for cost and latest-token context usage
+- Keep UI output unchanged in both components
+
+---
+
+### Non-goals
+
+- Rewriting the detailed context breakdown estimator logic
+- Changing translations or labels
+- Moving metrics into backend API responses
+
+---
+
+### Parallel execution contract
+
+This spec owns:
+
+- `packages/app/src/components/session/session-context-tab.tsx`
+- `packages/app/src/components/session-context-usage.tsx`
+- New helper in `packages/app/src/components/session/*` or `packages/app/src/utils/*`
+
+This spec should not modify:
+
+- `packages/app/src/pages/session.tsx`
+- `packages/app/src/context/sync.tsx`
+
+---
+
+### Implementation plan
+
+1. Add shared metrics helper
+
+- Create helper for raw metrics from message list + provider map, e.g.:
+  - `totalCost`
+  - `lastAssistantWithTokens`
+  - `tokenTotal`
+  - `tokenUsagePercent`
+  - provider/model labels
+- Return raw numeric values; keep locale formatting in consumers.
+
+2. Add memoization guard
+
+- Use reference-based memoization (e.g. by message-array identity) inside helper or component-level memo to avoid duplicate recalculation on unchanged arrays.
+
+3. Migrate both components
+
+- Replace duplicated loops in:
+  - `session-context-tab.tsx`
+  - `session-context-usage.tsx`
+- Keep existing UI structure and i18n keys unchanged.
+
+---
+
+### Acceptance criteria
+
+- Shared cost + token calculations are defined in one module.
+- Both components read from the shared helper.
+- Rendered values remain identical for:
+  - total cost
+  - token totals
+  - usage percentage
+  - provider/model fallback labels
+
+---
+
+### Validation plan
+
+- Manual:
+  - Open session context tab and compare values with header/context indicator tooltip.
+  - Verify values update correctly while new assistant messages stream in.
+- Regression:
+  - locale change still formats numbers/currency correctly.
+
+---
+
+### Risks and mitigations
+
+- Risk: helper changes semantic edge cases (no provider, no model, missing token fields).
+  - Mitigation: preserve existing fallback behavior (`"—"`, null percent).
+- Risk: memoization over-caches stale values.
+  - Mitigation: key cache by message-array reference and dependent IDs only.

+ 88 - 0
specs/13-file-tree-fetch-discipline.md

@@ -0,0 +1,88 @@
+## File tree fetches
+
+Make directory listing triggers explicit and minimal
+
+---
+
+### Summary
+
+`packages/app/src/components/file-tree.tsx` currently invokes `file.tree.list(path)` from a generic effect in each tree instance. Even with inflight guards, this pattern causes avoidable list calls and makes load behavior harder to reason about. This spec tightens fetch triggers.
+
+---
+
+### Goals
+
+- Avoid redundant list invocations from passive rerenders
+- Fetch directory data only when needed (mount + expansion + explicit refresh)
+- Keep tree behavior unchanged for users
+
+---
+
+### Non-goals
+
+- Replacing recursive tree rendering with virtualization
+- Changing file-tree visual design
+- Backend/API changes for file listing
+
+---
+
+### Parallel execution contract
+
+This spec owns:
+
+- `packages/app/src/components/file-tree.tsx`
+
+This spec should not modify:
+
+- `packages/app/src/context/file.tsx`
+- `packages/app/src/pages/session.tsx`
+
+---
+
+### Implementation plan
+
+1. Replace broad list effect with explicit triggers
+
+- Load root path on mount.
+- For nested directories, list only when:
+  - node is expanded, or
+  - parent explicitly requests refresh.
+
+2. Guard expansion-driven fetches
+
+- Keep `file.tree.expand(path)` as the primary source of truth for expansion fetches.
+- Ensure passive rerenders do not retrigger `list(path)` calls for already loaded dirs.
+
+3. Keep filter auto-expand behavior
+
+- Preserve existing "allowed filter" directory auto-expansion.
+- Ensure auto-expanded directories still fetch exactly once unless force refresh occurs.
+
+---
+
+### Acceptance criteria
+
+- `file-tree.tsx` no longer calls `file.tree.list(path)` from an unscoped rerender effect.
+- Expanding a folder still loads its children correctly.
+- Filtering by `allowed` still opens and shows required parent directories.
+- No regressions in change/all tabs where `FileTree` is used.
+
+---
+
+### Validation plan
+
+- Manual:
+  - Expand/collapse deep directory trees repeatedly.
+  - Switch between "changes" and "all" tree tabs.
+  - Open review, click files, verify tree stays responsive.
+- Optional instrumentation:
+  - count list calls per user action and compare before/after.
+
+---
+
+### Risks and mitigations
+
+- Risk: directories fail to load when expansion timing changes.
+  - Mitigation: rely on `expand()` path and verify for root + nested nodes.
+- Risk: filter-driven auto-expand misses one level.
+  - Mitigation: keep existing auto-expand iteration and add regression checks.

+ 87 - 0
specs/14-comments-aggregation-index.md

@@ -0,0 +1,87 @@
+## Comments indexing
+
+Avoid repeated flatten+sort for comment aggregates
+
+---
+
+### Summary
+
+`packages/app/src/context/comments.tsx` derives `all` by flattening all file comment arrays and sorting on every change. This is simple but can become expensive with many comments. We will maintain an indexed aggregate structure incrementally.
+
+---
+
+### Goals
+
+- Keep `comments.list(file)` behavior unchanged
+- Make `comments.all()` retrieval near O(1) for reads
+- Preserve chronological ordering guarantees
+
+---
+
+### Non-goals
+
+- Persisting comments in a new schema
+- Adding new comment metadata fields
+- UI changes for comment display
+
+---
+
+### Parallel execution contract
+
+This spec owns:
+
+- `packages/app/src/context/comments.tsx`
+- Optional tests for comments context
+
+This spec should not modify:
+
+- `packages/app/src/pages/session.tsx`
+- `packages/ui/src/components/line-comment.tsx`
+
+---
+
+### Implementation plan
+
+1. Add aggregate index state
+
+- Maintain `commentsByFile` (existing) plus an `allComments` array in chronological order.
+- Keep both updated through the same mutator paths.
+
+2. Update mutators
+
+- `add`: append new comment to file list and aggregate list.
+- `remove`: remove from file list and aggregate list by id/file.
+- `clear`: reset both structures and focus/active state.
+
+3. Simplify selectors
+
+- `list(file)` reads file list directly.
+- `all()` returns pre-indexed aggregate list without per-read flatten+sort.
+
+---
+
+### Acceptance criteria
+
+- `comments.all()` no longer flattens and sorts every reactive run.
+- Comment order stays chronological by `time`.
+- `add/remove/clear/focus/active` semantics remain unchanged.
+
+---
+
+### Validation plan
+
+- Manual:
+  - Add multiple comments across different files.
+  - Remove one comment and verify both file-level and global views update correctly.
+  - Submit prompt (which clears comments) and verify reset behavior.
+- Optional unit test:
+  - add/remove/clear keeps aggregate ordering and integrity.
+
+---
+
+### Risks and mitigations
+
+- Risk: aggregate list and per-file lists diverge.
+  - Mitigation: funnel all writes through centralized mutators; avoid direct store writes elsewhere.
+- Risk: ID collision edge cases.
+  - Mitigation: keep UUID creation unchanged and remove by `file + id` pair.

+ 104 - 0
specs/15-prompt-input-modularization.md

@@ -0,0 +1,104 @@
+## Prompt input split
+
+Modularize `prompt-input.tsx` without behavior changes
+
+---
+
+### Summary
+
+`packages/app/src/components/prompt-input.tsx` is a very large component that combines editor DOM parsing, popovers, history, drag/drop + paste uploads, worktree/session creation, optimistic messages, and send/abort flow. This spec splits it into focused modules so future changes are safer.
+
+---
+
+### Goals
+
+- Reduce `prompt-input.tsx` complexity and file size
+- Extract cohesive logic into testable hooks/helpers
+- Keep runtime behavior and UX unchanged
+
+---
+
+### Non-goals
+
+- Replacing contenteditable editor approach
+- Major UX redesign of composer controls
+- API contract changes for prompt submission
+
+---
+
+### Parallel execution contract
+
+This spec owns:
+
+- `packages/app/src/components/prompt-input.tsx`
+- New files under `packages/app/src/components/prompt-input/*`
+
+This spec should not modify:
+
+- `packages/app/src/pages/session.tsx`
+- `packages/app/src/context/prompt.tsx` (except minor type-only imports if needed)
+
+---
+
+### Implementation plan
+
+1. Extract editor DOM helpers
+
+- Move pure DOM/selection helpers into `prompt-input/editor-dom.ts`:
+  - `createTextFragment`
+  - `getNodeLength`
+  - `getTextLength`
+  - cursor get/set helpers
+
+2. Extract history controller
+
+- Move prompt history read/write/navigation logic into `prompt-input/history.ts` hook.
+- Keep existing persisted keys and history semantics unchanged.
+
+3. Extract attachment interactions
+
+- Move image/file paste + drag/drop + file-input attachment flows to `prompt-input/attachments.ts` hook.
+
+4. Extract submit pipeline
+
+- Move send/abort/optimistic message pipeline to `prompt-input/submit.ts` service/hook.
+- Keep existing error toasts, worktree handling, and rollback behavior.
+
+5. Keep composition shell stable
+
+- `PromptInput` component remains the integration shell that wires hooks + JSX.
+- Preserve exported component API and props.
+
+---
+
+### Acceptance criteria
+
+- `prompt-input.tsx` becomes primarily orchestration + view code.
+- Extracted modules contain the heavy imperative logic.
+- All existing behaviors remain intact:
+  - slash and @ popovers
+  - history up/down navigation
+  - image attach/paste/drag-drop
+  - shell mode submit/abort
+  - optimistic message + rollback on failure
+
+---
+
+### Validation plan
+
+- Manual regression checklist:
+  - type prompt, submit, stop, retry
+  - use `/` command selection and `@` selector
+  - history navigation with arrows
+  - paste image, drag image, remove attachment
+  - start in new session + worktree create path
+  - failure path restores prompt and context comments
+
+---
+
+### Risks and mitigations
+
+- Risk: subtle ordering changes in submit rollback logic.
+  - Mitigation: migrate logic mechanically first, then cleanup.
+- Risk: editor selection bugs after helper extraction.
+  - Mitigation: keep existing cursor helpers unchanged and add focused manual checks.

+ 82 - 0
specs/16-terminal-cache-key-clarity.md

@@ -0,0 +1,82 @@
+## Terminal cache scope
+
+Clarify workspace-only terminal cache semantics
+
+---
+
+### Summary
+
+`packages/app/src/context/terminal.tsx` accepts `(dir, session)` but currently keys cache entries as `${dir}:${WORKSPACE_KEY}`. The behavior is workspace-scoped, but the API shape suggests session-scoped caching. This spec aligns naming and implementation to avoid confusion and future bugs.
+
+---
+
+### Goals
+
+- Make terminal cache scope explicit (workspace-scoped)
+- Remove misleading unused session-keying surface
+- Preserve existing runtime behavior
+
+---
+
+### Non-goals
+
+- Changing terminal persistence behavior
+- Moving terminals to per-session isolation
+- UI changes to terminal tabs
+
+---
+
+### Parallel execution contract
+
+This spec owns:
+
+- `packages/app/src/context/terminal.tsx`
+
+This spec should not modify:
+
+- `packages/app/src/pages/session.tsx`
+- `packages/app/src/components/session/session-sortable-terminal-tab.tsx`
+
+---
+
+### Implementation plan
+
+1. Rename internals for clarity
+
+- Update internal function names/variables from session-oriented to workspace-oriented where applicable.
+
+2. Remove unused session cache-key parametering
+
+- Simplify `load`/factory signatures so keying intent is explicit.
+- Keep key format workspace-only by directory.
+
+3. Add inline documentation
+
+- Add short comment near cache key creation clarifying why terminals are shared across sessions in the same workspace.
+
+4. Keep behavior stable
+
+- Ensure active terminal, tab order, clone/new/close behavior remain unchanged.
+
+---
+
+### Acceptance criteria
+
+- No unused session-derived cache key logic remains.
+- Code communicates workspace-scoped terminal lifecycle clearly.
+- No functional changes to terminal operations.
+
+---
+
+### Validation plan
+
+- Manual:
+  - Create multiple terminals, navigate between sessions in same workspace, confirm state continuity.
+  - Switch workspace directory, confirm separate terminal state.
+
+---
+
+### Risks and mitigations
+
+- Risk: accidental behavior change to session-scoped terminals.
+  - Mitigation: keep cache key unchanged; refactor naming/signatures only.

+ 59 - 0
specs/parallel-agent-plan.md

@@ -0,0 +1,59 @@
+## Parallel agent plan
+
+Execution map for session-page improvement concerns
+
+---
+
+### New specs added
+
+- `specs/09-session-page-hot-paths.md`
+- `specs/10-file-content-eviction-accounting.md`
+- `specs/11-layout-view-tabs-reactivity.md`
+- `specs/12-session-context-metrics-shared.md`
+- `specs/13-file-tree-fetch-discipline.md`
+- `specs/14-comments-aggregation-index.md`
+- `specs/15-prompt-input-modularization.md`
+- `specs/16-terminal-cache-key-clarity.md`
+
+---
+
+### Existing related specs
+
+- `specs/04-scroll-spy-optimization.md` (session scroll-spy concern)
+- `specs/05-modularize-and-dedupe.md` (broad modularization roadmap)
+
+---
+
+### Parallel-safe batching
+
+Batch A (run one at a time, shared `session.tsx` surface):
+
+- `specs/09-session-page-hot-paths.md`
+- `specs/04-scroll-spy-optimization.md`
+
+Batch B (parallel with each other and with Batch A):
+
+- `specs/10-file-content-eviction-accounting.md`
+- `specs/11-layout-view-tabs-reactivity.md`
+- `specs/12-session-context-metrics-shared.md`
+- `specs/13-file-tree-fetch-discipline.md`
+- `specs/14-comments-aggregation-index.md`
+- `specs/15-prompt-input-modularization.md`
+- `specs/16-terminal-cache-key-clarity.md`
+
+Batch C (broad follow-up after focused specs land):
+
+- `specs/05-modularize-and-dedupe.md`
+
+---
+
+### Suggested assignment
+
+1. Agent A: `specs/09-session-page-hot-paths.md`
+2. Agent B: `specs/10-file-content-eviction-accounting.md`
+3. Agent C: `specs/11-layout-view-tabs-reactivity.md`
+4. Agent D: `specs/12-session-context-metrics-shared.md`
+5. Agent E: `specs/13-file-tree-fetch-discipline.md`
+6. Agent F: `specs/14-comments-aggregation-index.md`
+7. Agent G: `specs/15-prompt-input-modularization.md`
+8. Agent H: `specs/16-terminal-cache-key-clarity.md`