2
0
Эх сурвалжийг харах

Merge branch 'public/dev' into ide-plugin

paviko 1 долоо хоног өмнө
parent
commit
bf2f7a2603
100 өөрчлөгдсөн 6602 нэмэгдсэн , 2046 устгасан
  1. 1 0
      .github/CODEOWNERS
  2. 1 1
      .github/actions/setup-bun/action.yml
  3. 1 0
      .github/workflows/beta.yml
  4. 183 31
      .github/workflows/close-stale-prs.yml
  5. 9 0
      .github/workflows/deploy.yml
  6. 100 81
      .github/workflows/nix-hashes.yml
  7. 46 87
      .github/workflows/test.yml
  8. 2 0
      .github/workflows/typecheck.yml
  9. 1 0
      .gitignore
  10. 1 1
      .opencode/command/commit.md
  11. 1 7
      .opencode/opencode.jsonc
  12. 2 1
      .prettierignore
  13. 64 34
      AGENTS.md
  14. 3 1
      README.ar.md
  15. 3 1
      README.br.md
  16. 136 0
      README.bs.md
  17. 3 1
      README.da.md
  18. 3 1
      README.de.md
  19. 3 1
      README.es.md
  20. 3 1
      README.fr.md
  21. 3 1
      README.it.md
  22. 3 1
      README.ja.md
  23. 3 1
      README.ko.md
  24. 11 9
      README.md
  25. 3 1
      README.no.md
  26. 3 1
      README.pl.md
  27. 3 1
      README.ru.md
  28. 2 1
      README.th.md
  29. 135 0
      README.tr.md
  30. 3 1
      README.zh.md
  31. 4 2
      README.zht.md
  32. 200 111
      bun.lock
  33. 5 18
      flake.nix
  34. 3 3
      nix/desktop.nix
  35. 4 4
      nix/hashes.json
  36. 8 10
      nix/node_modules.nix
  37. 5 3
      package.json
  38. 176 0
      packages/app/e2e/AGENTS.md
  39. 156 6
      packages/app/e2e/actions.ts
  40. 46 13
      packages/app/e2e/fixtures.ts
  41. 37 36
      packages/app/e2e/projects/project-edit.spec.ts
  42. 48 44
      packages/app/e2e/projects/projects-close.spec.ts
  43. 17 16
      packages/app/e2e/projects/projects-switch.spec.ts
  44. 333 0
      packages/app/e2e/projects/workspaces.spec.ts
  45. 22 0
      packages/app/e2e/selectors.ts
  46. 56 14
      packages/app/e2e/session/session.spec.ts
  47. 317 0
      packages/app/e2e/settings/settings-keybinds.spec.ts
  48. 0 28
      packages/app/e2e/settings/settings-language.spec.ts
  49. 122 0
      packages/app/e2e/settings/settings-models.spec.ts
  50. 121 15
      packages/app/e2e/settings/settings-providers.spec.ts
  51. 279 1
      packages/app/e2e/settings/settings.spec.ts
  52. 94 0
      packages/app/e2e/status/status-popover.spec.ts
  53. 7 4
      packages/app/package.json
  54. 1 1
      packages/app/src/addons/serialize.test.ts
  55. 65 22
      packages/app/src/addons/serialize.ts
  56. 7 4
      packages/app/src/app.tsx
  57. 41 41
      packages/app/src/components/dialog-custom-provider.tsx
  58. 5 5
      packages/app/src/components/dialog-edit-project.tsx
  59. 150 32
      packages/app/src/components/dialog-select-directory.tsx
  60. 180 18
      packages/app/src/components/dialog-select-file.tsx
  61. 8 11
      packages/app/src/components/dialog-select-model.tsx
  62. 23 78
      packages/app/src/components/dialog-select-server.tsx
  63. 77 0
      packages/app/src/components/file-tree.test.ts
  64. 110 16
      packages/app/src/components/file-tree.tsx
  65. 90 900
      packages/app/src/components/prompt-input.tsx
  66. 132 0
      packages/app/src/components/prompt-input/attachments.ts
  67. 67 0
      packages/app/src/components/prompt-input/build-request-parts.test.ts
  68. 180 0
      packages/app/src/components/prompt-input/build-request-parts.ts
  69. 82 0
      packages/app/src/components/prompt-input/context-items.tsx
  70. 20 0
      packages/app/src/components/prompt-input/drag-overlay.tsx
  71. 51 0
      packages/app/src/components/prompt-input/editor-dom.test.ts
  72. 135 0
      packages/app/src/components/prompt-input/editor-dom.ts
  73. 69 0
      packages/app/src/components/prompt-input/history.test.ts
  74. 160 0
      packages/app/src/components/prompt-input/history.ts
  75. 51 0
      packages/app/src/components/prompt-input/image-attachments.tsx
  76. 35 0
      packages/app/src/components/prompt-input/placeholder.test.ts
  77. 13 0
      packages/app/src/components/prompt-input/placeholder.ts
  78. 144 0
      packages/app/src/components/prompt-input/slash-popover.tsx
  79. 411 0
      packages/app/src/components/prompt-input/submit.ts
  80. 295 0
      packages/app/src/components/question-dock.tsx
  81. 77 0
      packages/app/src/components/server/server-row.tsx
  82. 10 25
      packages/app/src/components/session-context-usage.tsx
  83. 93 0
      packages/app/src/components/session/session-context-metrics.test.ts
  84. 94 0
      packages/app/src/components/session/session-context-metrics.ts
  85. 7 40
      packages/app/src/components/session/session-context-tab.tsx
  86. 261 8
      packages/app/src/components/session/session-header.tsx
  87. 9 3
      packages/app/src/components/session/session-sortable-tab.tsx
  88. 37 21
      packages/app/src/components/settings-general.tsx
  89. 2 1
      packages/app/src/components/settings-keybinds.tsx
  90. 6 3
      packages/app/src/components/settings-providers.tsx
  91. 39 88
      packages/app/src/components/status-popover.tsx
  92. 83 18
      packages/app/src/components/terminal.tsx
  93. 63 0
      packages/app/src/components/titlebar-history.test.ts
  94. 57 0
      packages/app/src/components/titlebar-history.ts
  95. 56 43
      packages/app/src/components/titlebar.tsx
  96. 43 0
      packages/app/src/context/command-keybind.test.ts
  97. 25 0
      packages/app/src/context/command.test.ts
  98. 38 10
      packages/app/src/context/command.tsx
  99. 111 0
      packages/app/src/context/comments.test.ts
  100. 94 64
      packages/app/src/context/comments.tsx

+ 1 - 0
.github/CODEOWNERS

@@ -1,4 +1,5 @@
 # web + desktop packages
 packages/app/      @adamdotdevin
 packages/tauri/    @adamdotdevin
+packages/desktop/src-tauri/  @brendonovich
 packages/desktop/  @adamdotdevin

+ 1 - 1
.github/actions/setup-bun/action.yml

@@ -6,7 +6,7 @@ runs:
     - name: Mount Bun Cache
       uses: useblacksmith/stickydisk@v1
       with:
-        key: ${{ github.repository }}-bun-cache
+        key: ${{ github.repository }}-bun-cache-${{ runner.os }}
         path: ~/.bun
 
     - name: Setup Bun

+ 1 - 0
.github/workflows/beta.yml

@@ -8,6 +8,7 @@ jobs:
     runs-on: blacksmith-4vcpu-ubuntu-2404
     permissions:
       contents: write
+      pull-requests: write
     steps:
       - name: Checkout repository
         uses: actions/checkout@v4

+ 183 - 31
.github/workflows/close-stale-prs.yml

@@ -15,6 +15,7 @@ permissions:
 jobs:
   close-stale-prs:
     runs-on: ubuntu-latest
+    timeout-minutes: 15
     steps:
       - name: Close inactive PRs
         uses: actions/github-script@v8
@@ -22,59 +23,210 @@ jobs:
           github-token: ${{ secrets.GITHUB_TOKEN }}
           script: |
             const DAYS_INACTIVE = 60
+            const MAX_RETRIES = 3
+
+            // Adaptive delay: fast for small batches, slower for large to respect
+            // GitHub's 80 content-generating requests/minute limit
+            const SMALL_BATCH_THRESHOLD = 10
+            const SMALL_BATCH_DELAY_MS = 1000  // 1s for daily operations (≤10 PRs)
+            const LARGE_BATCH_DELAY_MS = 2000  // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit
+
+            const startTime = Date.now()
             const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
             const { owner, repo } = context.repo
             const dryRun = context.payload.inputs?.dryRun === "true"
-            const stalePrs = []
 
             core.info(`Dry run mode: ${dryRun}`)
+            core.info(`Cutoff date: ${cutoff.toISOString()}`)
 
-            const prs = await github.paginate(github.rest.pulls.list, {
-              owner,
-              repo,
-              state: "open",
-              per_page: 100,
-              sort: "updated",
-              direction: "asc",
-            })
+            function sleep(ms) {
+              return new Promise(resolve => setTimeout(resolve, ms))
+            }
 
-            for (const pr of prs) {
-              const lastUpdated = new Date(pr.updated_at)
-              if (lastUpdated > cutoff) {
-                core.info(`PR ${pr.number} is fresh`)
-                continue
+            async function withRetry(fn, description = 'API call') {
+              let lastError
+              for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
+                try {
+                  const result = await fn()
+                  return result
+                } catch (error) {
+                  lastError = error
+                  const isRateLimited = error.status === 403 &&
+                    (error.message?.includes('rate limit') || error.message?.includes('secondary'))
+
+                  if (!isRateLimited) {
+                    throw error
+                  }
+
+                  // Parse retry-after header, default to 60 seconds
+                  const retryAfter = error.response?.headers?.['retry-after']
+                    ? parseInt(error.response.headers['retry-after'])
+                    : 60
+
+                  // Exponential backoff: retryAfter * 2^attempt
+                  const backoffMs = retryAfter * 1000 * Math.pow(2, attempt)
+
+                  core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`)
+
+                  await sleep(backoffMs)
+                }
               }
+              core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`)
+              throw lastError
+            }
+
+            const query = `
+              query($owner: String!, $repo: String!, $cursor: String) {
+                repository(owner: $owner, name: $repo) {
+                  pullRequests(first: 100, states: OPEN, after: $cursor) {
+                    pageInfo {
+                      hasNextPage
+                      endCursor
+                    }
+                    nodes {
+                      number
+                      title
+                      author {
+                        login
+                      }
+                      createdAt
+                      commits(last: 1) {
+                        nodes {
+                          commit {
+                            committedDate
+                          }
+                        }
+                      }
+                      comments(last: 1) {
+                        nodes {
+                          createdAt
+                        }
+                      }
+                      reviews(last: 1) {
+                        nodes {
+                          createdAt
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            `
+
+            const allPrs = []
+            let cursor = null
+            let hasNextPage = true
+            let pageCount = 0
+
+            while (hasNextPage) {
+              pageCount++
+              core.info(`Fetching page ${pageCount} of open PRs...`)
+
+              const result = await withRetry(
+                () => github.graphql(query, { owner, repo, cursor }),
+                `GraphQL page ${pageCount}`
+              )
+
+              allPrs.push(...result.repository.pullRequests.nodes)
+              hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
+              cursor = result.repository.pullRequests.pageInfo.endCursor
+
+              core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`)
 
-              stalePrs.push(pr)
+              // Delay between pagination requests (use small batch delay for reads)
+              if (hasNextPage) {
+                await sleep(SMALL_BATCH_DELAY_MS)
+              }
             }
 
+            core.info(`Found ${allPrs.length} open pull requests`)
+
+            const stalePrs = allPrs.filter((pr) => {
+              const dates = [
+                new Date(pr.createdAt),
+                pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null,
+                pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null,
+                pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null,
+              ].filter((d) => d !== null)
+
+              const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0]
+
+              if (!lastActivity || lastActivity > cutoff) {
+                core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`)
+                return false
+              }
+
+              core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`)
+              return true
+            })
+
             if (!stalePrs.length) {
               core.info("No stale pull requests found.")
               return
             }
 
+            core.info(`Found ${stalePrs.length} stale pull requests`)
+
+            // ============================================
+            // Close stale PRs
+            // ============================================
+            const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD
+              ? LARGE_BATCH_DELAY_MS
+              : SMALL_BATCH_DELAY_MS
+
+            core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`)
+
+            let closedCount = 0
+            let skippedCount = 0
+
             for (const pr of stalePrs) {
               const issue_number = pr.number
               const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`
 
               if (dryRun) {
-                core.info(`[dry-run] Would close PR #${issue_number} from ${pr.user.login}`)
+                core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
                 continue
               }
 
-              await github.rest.issues.createComment({
-                owner,
-                repo,
-                issue_number,
-                body: closeComment,
-              })
-
-              await github.rest.pulls.update({
-                owner,
-                repo,
-                pull_number: issue_number,
-                state: "closed",
-              })
-
-              core.info(`Closed PR #${issue_number} from ${pr.user.login}`)
+              try {
+                // Add comment
+                await withRetry(
+                  () => github.rest.issues.createComment({
+                    owner,
+                    repo,
+                    issue_number,
+                    body: closeComment,
+                  }),
+                  `Comment on PR #${issue_number}`
+                )
+
+                // Close PR
+                await withRetry(
+                  () => github.rest.pulls.update({
+                    owner,
+                    repo,
+                    pull_number: issue_number,
+                    state: "closed",
+                  }),
+                  `Close PR #${issue_number}`
+                )
+
+                closedCount++
+                core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
+
+                // Delay before processing next PR
+                await sleep(requestDelayMs)
+              } catch (error) {
+                skippedCount++
+                core.error(`Failed to close PR #${issue_number}: ${error.message}`)
+              }
             }
+
+            const elapsed = Math.round((Date.now() - startTime) / 1000)
+            core.info(`\n========== Summary ==========`)
+            core.info(`Total open PRs found: ${allPrs.length}`)
+            core.info(`Stale PRs identified: ${stalePrs.length}`)
+            core.info(`PRs closed: ${closedCount}`)
+            core.info(`PRs skipped (errors): ${skippedCount}`)
+            core.info(`Elapsed time: ${elapsed}s`)
+            core.info(`=============================`)

+ 9 - 0
.github/workflows/deploy.yml

@@ -17,6 +17,15 @@ jobs:
         with:
           node-version: "24"
 
+      # Workaround for Pulumi version conflict:
+      # GitHub runners have Pulumi 3.212.0+ pre-installed, which removed the -root flag
+      # from pulumi-language-nodejs (see https://github.com/pulumi/pulumi/pull/21065).
+      # SST 3.17.x uses Pulumi SDK 3.210.0 which still passes -root, causing a conflict.
+      # Removing the system language plugin forces SST to use its bundled compatible version.
+      # TODO: Remove when sst supports Pulumi >3.210.0
+      - name: Fix Pulumi version conflict
+        run: sudo rm -f /usr/local/bin/pulumi-language-nodejs
+
       - run: bun sst deploy --stage=${{ github.ref_name }}
         env:
           CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

+ 100 - 81
.github/workflows/nix-hashes.yml

@@ -5,122 +5,141 @@ permissions:
 
 on:
   workflow_dispatch:
+  push:
+    branches: [dev]
+    paths:
+      - "bun.lock"
+      - "package.json"
+      - "packages/*/package.json"
+      - "flake.lock"
+      - ".github/workflows/nix-hashes.yml"
 
 jobs:
-  nix-hashes:
-    if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
-    runs-on: blacksmith-4vcpu-ubuntu-2404
-    env:
-      TITLE: node_modules hashes
+  # Native runners required: bun install cross-compilation flags (--os/--cpu)
+  # do not produce byte-identical node_modules as native installs.
+  compute-hash:
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - system: x86_64-linux
+            runner: blacksmith-4vcpu-ubuntu-2404
+          - system: aarch64-linux
+            runner: blacksmith-4vcpu-ubuntu-2404-arm
+          - system: x86_64-darwin
+            runner: macos-15-intel
+          - system: aarch64-darwin
+            runner: macos-latest
+    runs-on: ${{ matrix.runner }}
 
     steps:
       - name: Checkout repository
         uses: actions/checkout@v6
+
+      - name: Setup Nix
+        uses: nixbuild/nix-quick-install-action@v34
+
+      - name: Compute node_modules hash
+        id: hash
+        env:
+          SYSTEM: ${{ matrix.system }}
+        run: |
+          set -euo pipefail
+
+          BUILD_LOG=$(mktemp)
+          trap 'rm -f "$BUILD_LOG"' EXIT
+
+          # Build with fakeHash to trigger hash mismatch and reveal correct hash
+          nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
+
+          # Extract hash from build log with portability
+          HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
+
+          if [ -z "$HASH" ]; then
+            echo "::error::Failed to compute hash for ${SYSTEM}"
+            cat "$BUILD_LOG"
+            exit 1
+          fi
+
+          echo "$HASH" > hash.txt
+          echo "Computed hash for ${SYSTEM}: $HASH"
+
+      - name: Upload hash
+        uses: actions/upload-artifact@v4
         with:
-          token: ${{ secrets.GITHUB_TOKEN }}
+          name: hash-${{ matrix.system }}
+          path: hash.txt
+          retention-days: 1
+
+  update-hashes:
+    needs: compute-hash
+    if: github.event_name != 'pull_request'
+    runs-on: blacksmith-4vcpu-ubuntu-2404
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+        with:
+          persist-credentials: false
           fetch-depth: 0
-          ref: ${{ github.head_ref || github.ref_name }}
-          repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
+          ref: ${{ github.ref_name }}
 
       - name: Setup git committer
-        id: committer
         uses: ./.github/actions/setup-git-committer
         with:
           opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
           opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
 
-      - name: Setup Nix
-        uses: nixbuild/nix-quick-install-action@v34
-
       - name: Pull latest changes
-        env:
-          TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
         run: |
-          BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
-          git pull --rebase --autostash origin "$BRANCH"
+          git pull --rebase --autostash origin "$GITHUB_REF_NAME"
 
-      - name: Compute all node_modules hashes
+      - name: Download hash artifacts
+        uses: actions/download-artifact@v4
+        with:
+          path: hashes
+          pattern: hash-*
+
+      - name: Update hashes.json
         run: |
           set -euo pipefail
 
           HASH_FILE="nix/hashes.json"
-          SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
-
-          if [ ! -f "$HASH_FILE" ]; then
-            mkdir -p "$(dirname "$HASH_FILE")"
-            echo '{"nodeModules":{}}' > "$HASH_FILE"
-          fi
-
-          for SYSTEM in $SYSTEMS; do
-            echo "Computing hash for ${SYSTEM}..."
-            BUILD_LOG=$(mktemp)
-            trap 'rm -f "$BUILD_LOG"' EXIT
 
-            # The updater derivations use fakeHash, so they will fail and reveal the correct hash
-            UPDATER_ATTR=".#packages.x86_64-linux.${SYSTEM}_node_modules"
-
-            nix build "$UPDATER_ATTR" --no-link 2>&1 | tee "$BUILD_LOG" || true
-
-            CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)"
-
-            if [ -z "$CORRECT_HASH" ]; then
-              CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)"
-            fi
-
-            if [ -z "$CORRECT_HASH" ]; then
-              echo "Failed to determine correct node_modules hash for ${SYSTEM}."
-              cat "$BUILD_LOG"
-              exit 1
+          [ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE"
+
+          for SYSTEM in x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin; do
+            FILE="hashes/hash-${SYSTEM}/hash.txt"
+            if [ -f "$FILE" ]; then
+              HASH="$(tr -d '[:space:]' < "$FILE")"
+              echo "${SYSTEM}: ${HASH}"
+              jq --arg sys "$SYSTEM" --arg h "$HASH" '.nodeModules[$sys] = $h' "$HASH_FILE" > tmp.json
+              mv tmp.json "$HASH_FILE"
+            else
+              echo "::warning::Missing hash for ${SYSTEM}"
             fi
-
-            echo "  ${SYSTEM}: ${CORRECT_HASH}"
-            jq --arg sys "$SYSTEM" --arg h "$CORRECT_HASH" \
-              '.nodeModules[$sys] = $h' "$HASH_FILE" > "${HASH_FILE}.tmp"
-            mv "${HASH_FILE}.tmp" "$HASH_FILE"
           done
 
-          echo "All hashes computed:"
           cat "$HASH_FILE"
 
-      - name: Commit ${{ env.TITLE }} changes
-        env:
-          TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
+      - name: Commit changes
         run: |
           set -euo pipefail
 
           HASH_FILE="nix/hashes.json"
-          echo "Checking for changes..."
-
-          summarize() {
-            local status="$1"
-            {
-              echo "### Nix $TITLE"
-              echo ""
-              echo "- ref: ${GITHUB_REF_NAME}"
-              echo "- status: ${status}"
-            } >> "$GITHUB_STEP_SUMMARY"
-            if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then
-              echo "- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" >> "$GITHUB_STEP_SUMMARY"
-            fi
-            echo "" >> "$GITHUB_STEP_SUMMARY"
-          }
-
-          FILES=("$HASH_FILE")
-          STATUS="$(git status --short -- "${FILES[@]}" || true)"
-          if [ -z "$STATUS" ]; then
-            echo "No changes detected."
-            summarize "no changes"
+
+          if [ -z "$(git status --short -- "$HASH_FILE")" ]; then
+            echo "No changes to commit"
+            echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY"
+            echo "Status: no changes" >> "$GITHUB_STEP_SUMMARY"
             exit 0
           fi
 
-          echo "Changes detected:"
-          echo "$STATUS"
-          git add "${FILES[@]}"
+          git add "$HASH_FILE"
           git commit -m "chore: update nix node_modules hashes"
 
-          BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
-          git pull --rebase --autostash origin "$BRANCH"
-          git push origin HEAD:"$BRANCH"
-          echo "Changes pushed successfully"
+          git pull --rebase --autostash origin "$GITHUB_REF_NAME"
+          git push origin HEAD:"$GITHUB_REF_NAME"
 
-          summarize "committed $(git rev-parse --short HEAD)"
+          echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY"
+          echo "Status: committed $(git rev-parse --short HEAD)" >> "$GITHUB_STEP_SUMMARY"

+ 46 - 87
.github/workflows/test.yml

@@ -3,8 +3,32 @@ name: test
 on:
   workflow_dispatch:
 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:
       fail-fast: false
       matrix:
@@ -12,17 +36,12 @@ jobs:
           - name: linux
             host: blacksmith-4vcpu-ubuntu-2404
             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
-            host: windows-latest
+            host: blacksmith-4vcpu-windows-2025
             playwright: bunx playwright install
-            workdir: packages/app
-            command: bun test:e2e:local
     runs-on: ${{ matrix.settings.host }}
+    env:
+      PLAYWRIGHT_BROWSERS_PATH: 0
     defaults:
       run:
         shell: bash
@@ -39,85 +58,10 @@ jobs:
         working-directory: packages/app
         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
+      - name: Run app e2e tests
+        run: bun --cwd packages/app test:e2e:local
         env:
           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
 
       - name: Upload Playwright artifacts
@@ -130,3 +74,18 @@ jobs:
           path: |
             packages/app/e2e/test-results
             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 - 0
.github/workflows/typecheck.yml

@@ -1,6 +1,8 @@
 name: typecheck
 
 on:
+  push:
+    branches: [dev]
   pull_request:
     branches: [dev]
   workflow_dispatch:

+ 1 - 0
.gitignore

@@ -5,6 +5,7 @@ node_modules
 .env
 .idea
 .vscode
+.codex
 *~
 playground
 tmp

+ 1 - 1
.opencode/command/commit.md

@@ -1,6 +1,6 @@
 ---
 description: git commit and push
-model: opencode/glm-4.7
+model: opencode/kimi-k2.5
 subtask: true
 ---
 

+ 1 - 7
.opencode/opencode.jsonc

@@ -1,6 +1,5 @@
 {
   "$schema": "https://opencode.ai/config.json",
-  // "plugin": ["opencode-openai-codex-auth"],
   // "enterprise": {
   //   "url": "https://enterprise.dev.opencode.ai",
   // },
@@ -9,12 +8,7 @@
       "options": {},
     },
   },
-  "mcp": {
-    "context7": {
-      "type": "remote",
-      "url": "https://mcp.context7.com/mcp",
-    },
-  },
+  "mcp": {},
   "tools": {
     "github-triage": false,
     "github-pr-search": false,

+ 2 - 1
.prettierignore

@@ -1 +1,2 @@
-sst-env.d.ts
+sst-env.d.ts
+packages/desktop/src/bindings.ts

+ 64 - 34
AGENTS.md

@@ -1,82 +1,112 @@
 - To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
 - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
 - The default branch in this repo is `dev`.
+- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
 - Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
 
 ## Style Guide
 
+### General Principles
+
 - Keep things in one function unless composable or reusable
-- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context
 - Avoid `try`/`catch` where possible
 - Avoid using the `any` type
 - Prefer single word variable names where possible
 - Use Bun APIs when possible, like `Bun.file()`
 - Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
+- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
+
+### Naming
 
-### Avoid let statements
+Prefer single word names for variables and functions. Only use multiple words if necessary.
+
+```ts
+// Good
+const foo = 1
+function journal(dir: string) {}
 
-We don't like `let` statements, especially combined with if/else statements.
-Prefer `const`.
+// Bad
+const fooBar = 1
+function prepareJournal(dir: string) {}
+```
 
-Good:
+Reduce total variable count by inlining when a value is only used once.
 
 ```ts
-const foo = condition ? 1 : 2
+// Good
+const journal = await Bun.file(path.join(dir, "journal.json")).json()
+
+// Bad
+const journalPath = path.join(dir, "journal.json")
+const journal = await Bun.file(journalPath).json()
 ```
 
-Bad:
+### Destructuring
+
+Avoid unnecessary destructuring. Use dot notation to preserve context.
 
 ```ts
-let foo
+// Good
+obj.a
+obj.b
+
+// Bad
+const { a, b } = obj
+```
+
+### Variables
+
+Prefer `const` over `let`. Use ternaries or early returns instead of reassignment.
 
+```ts
+// Good
+const foo = condition ? 1 : 2
+
+// Bad
+let foo
 if (condition) foo = 1
 else foo = 2
 ```
 
-### Avoid else statements
+### Control Flow
 
-Prefer early returns or using an `iife` to avoid else statements.
-
-Good:
+Avoid `else` statements. Prefer early returns.
 
 ```ts
+// Good
 function foo() {
   if (condition) return 1
   return 2
 }
-```
 
-Bad:
-
-```ts
+// Bad
 function foo() {
   if (condition) return 1
   else return 2
 }
 ```
 
-### Prefer single word naming
+### Schema Definitions (Drizzle)
 
-Try your best to find a single word name for your variables, functions, etc.
-Only use multiple words if you cannot.
-
-Good:
+Use snake_case for field names so column names don't need to be redefined as strings.
 
 ```ts
-const foo = 1
-const bar = 2
-const baz = 3
-```
-
-Bad:
-
-```ts
-const fooBar = 1
-const barBaz = 2
-const bazFoo = 3
+// Good
+const table = sqliteTable("session", {
+  id: text().primaryKey(),
+  project_id: text().notNull(),
+  created_at: integer().notNull(),
+})
+
+// Bad
+const table = sqliteTable("session", {
+  id: text("id").primaryKey(),
+  projectID: text("project_id").notNull(),
+  createdAt: integer("created_at").notNull(),
+})
 ```
 
 ## Testing
 
-You MUST avoid using `mocks` as much as possible.
-Tests MUST test actual implementation, do not duplicate logic into a test.
+- Avoid mocks as much as possible
+- Test actual implementation, do not duplicate logic into tests

+ 3 - 1
README.ar.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.br.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 136 - 0
README.bs.md

@@ -0,0 +1,136 @@
+<p align="center">
+  <a href="https://opencode.ai">
+    <picture>
+      <source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
+      <source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
+      <img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
+    </picture>
+  </a>
+</p>
+<p align="center">OpenCode je open source AI agent za programiranje.</p>
+<p align="center">
+  <a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
+  <a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
+  <a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
+</p>
+
+<p align="center">
+  <a href="README.md">English</a> |
+  <a href="README.zh.md">简体中文</a> |
+  <a href="README.zht.md">繁體中文</a> |
+  <a href="README.ko.md">한국어</a> |
+  <a href="README.de.md">Deutsch</a> |
+  <a href="README.es.md">Español</a> |
+  <a href="README.fr.md">Français</a> |
+  <a href="README.it.md">Italiano</a> |
+  <a href="README.da.md">Dansk</a> |
+  <a href="README.ja.md">日本語</a> |
+  <a href="README.pl.md">Polski</a> |
+  <a href="README.ru.md">Русский</a> |
+  <a href="README.bs.md">Bosanski</a> |
+  <a href="README.ar.md">العربية</a> |
+  <a href="README.no.md">Norsk</a> |
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
+</p>
+
+[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+
+---
+
+### Instalacija
+
+```bash
+# YOLO
+curl -fsSL https://opencode.ai/install | bash
+
+# Package manageri
+npm i -g opencode-ai@latest        # ili bun/pnpm/yarn
+scoop install opencode             # Windows
+choco install opencode             # Windows
+brew install anomalyco/tap/opencode # macOS i Linux (preporučeno, uvijek ažurno)
+brew install opencode              # macOS i Linux (zvanična brew formula, rjeđe se ažurira)
+paru -S opencode-bin               # Arch Linux
+mise use -g opencode               # Bilo koji OS
+nix run nixpkgs#opencode           # ili github:anomalyco/opencode za najnoviji dev branch
+```
+
+> [!TIP]
+> Ukloni verzije starije od 0.1.x prije instalacije.
+
+### Desktop aplikacija (BETA)
+
+OpenCode je dostupan i kao desktop aplikacija. Preuzmi je direktno sa [stranice izdanja](https://github.com/anomalyco/opencode/releases) ili sa [opencode.ai/download](https://opencode.ai/download).
+
+| Platforma             | Preuzimanje                           |
+| --------------------- | ------------------------------------- |
+| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
+| macOS (Intel)         | `opencode-desktop-darwin-x64.dmg`     |
+| Windows               | `opencode-desktop-windows-x64.exe`    |
+| Linux                 | `.deb`, `.rpm`, ili AppImage          |
+
+```bash
+# macOS (Homebrew)
+brew install --cask opencode-desktop
+# Windows (Scoop)
+scoop bucket add extras; scoop install extras/opencode-desktop
+```
+
+#### Instalacijski direktorij
+
+Instalacijska skripta koristi sljedeći redoslijed prioriteta za putanju instalacije:
+
+1. `$OPENCODE_INSTALL_DIR` - Prilagođeni instalacijski direktorij
+2. `$XDG_BIN_DIR` - Putanja usklađena sa XDG Base Directory specifikacijom
+3. `$HOME/bin` - Standardni korisnički bin direktorij (ako postoji ili se može kreirati)
+4. `$HOME/.opencode/bin` - Podrazumijevana rezervna lokacija
+
+```bash
+# Primjeri
+OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
+XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
+```
+
+### Agenti
+
+OpenCode uključuje dva ugrađena agenta između kojih možeš prebacivati tasterom `Tab`.
+
+- **build** - Podrazumijevani agent sa punim pristupom za razvoj
+- **plan** - Agent samo za čitanje za analizu i istraživanje koda
+  - Podrazumijevano zabranjuje izmjene datoteka
+  - Traži dozvolu prije pokretanja bash komandi
+  - Idealan za istraživanje nepoznatih codebase-ova ili planiranje izmjena
+
+Uključen je i **general** pod-agent za složene pretrage i višekoračne zadatke.
+Koristi se interno i može se pozvati pomoću `@general` u porukama.
+
+Saznaj više o [agentima](https://opencode.ai/docs/agents).
+
+### Dokumentacija
+
+Za više informacija o konfiguraciji OpenCode-a, [**pogledaj dokumentaciju**](https://opencode.ai/docs).
+
+### Doprinosi
+
+Ako želiš doprinositi OpenCode-u, pročitaj [upute za doprinošenje](./CONTRIBUTING.md) prije slanja pull requesta.
+
+### Gradnja na OpenCode-u
+
+Ako radiš na projektu koji je povezan s OpenCode-om i koristi "opencode" kao dio naziva, npr. "opencode-dashboard" ili "opencode-mobile", dodaj napomenu u svoj README da projekat nije napravio OpenCode tim i da nije povezan s nama.
+
+### FAQ
+
+#### Po čemu se razlikuje od Claude Code-a?
+
+Po mogućnostima je vrlo sličan Claude Code-u. Ključne razlike su:
+
+- 100% open source
+- Nije vezan za jednog provajdera. Iako preporučujemo modele koje nudimo kroz [OpenCode Zen](https://opencode.ai/zen), OpenCode možeš koristiti s Claude, OpenAI, Google ili čak lokalnim modelima. Kako modeli napreduju, razlike među njima će se smanjivati, a cijene padati, zato je nezavisnost od provajdera važna.
+- LSP podrška odmah po instalaciji
+- Fokus na TUI. OpenCode grade neovim korisnici i kreatori [terminal.shop](https://terminal.shop); pomjeraćemo granice onoga što je moguće u terminalu.
+- Klijent/server arhitektura. To, recimo, omogućava da OpenCode radi na tvom računaru dok ga daljinski koristiš iz mobilne aplikacije, što znači da je TUI frontend samo jedan od mogućih klijenata.
+
+---
+
+**Pridruži se našoj zajednici** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)

+ 3 - 1
README.da.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.de.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.es.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.fr.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.it.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.ja.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.ko.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 11 - 9
README.md

@@ -37,10 +37,12 @@
   <a href="README.ja.md">日本語</a> |
   <a href="README.pl.md">Polski</a> |
   <a href="README.ru.md">Русский</a> |
+  <a href="README.bs.md">Bosanski</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.br.md">Português (Brasil)</a> |
-  <a href="README.th.md">ไทย</a>
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -91,7 +93,7 @@ The install script respects the following priority order for the installation pa
 
 1. `$OPENCODE_INSTALL_DIR` - Custom installation directory
 2. `$XDG_BIN_DIR` - XDG Base Directory Specification compliant path
-3. `$HOME/bin` - Standard user binary directory (if exists or can be created)
+3. `$HOME/bin` - Standard user binary directory (if it exists or can be created)
 4. `$HOME/.opencode/bin` - Default fallback
 
 ```bash
@@ -104,20 +106,20 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
 
 OpenCode includes two built-in agents you can switch between with the `Tab` key.
 
-- **build** - Default, full access agent for development work
+- **build** - Default, full-access agent for development work
 - **plan** - Read-only agent for analysis and code exploration
   - Denies file edits by default
   - Asks permission before running bash commands
   - Ideal for exploring unfamiliar codebases or planning changes
 
-Also, included is a **general** subagent for complex searches and multistep tasks.
+Also included is a **general** subagent for complex searches and multistep tasks.
 This is used internally and can be invoked using `@general` in messages.
 
 Learn more about [agents](https://opencode.ai/docs/agents).
 
 ### Documentation
 
-For more info on how to configure OpenCode [**head over to our docs**](https://opencode.ai/docs).
+For more info on how to configure OpenCode, [**head over to our docs**](https://opencode.ai/docs).
 
 ### Contributing
 
@@ -125,7 +127,7 @@ If you're interested in contributing to OpenCode, please read our [contributing
 
 ### Building on OpenCode
 
-If you are working on a project that's related to OpenCode and is using "opencode" as a part of its name; for example, "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way.
+If you are working on a project that's related to OpenCode and is using "opencode" as part of its name, for example "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way.
 
 ### FAQ
 
@@ -134,10 +136,10 @@ If you are working on a project that's related to OpenCode and is using "opencod
 It's very similar to Claude Code in terms of capability. Here are the key differences:
 
 - 100% open source
-- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen); OpenCode can be used with Claude, OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important.
-- Out of the box LSP support
+- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
+- Out-of-the-box LSP support
 - A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
-- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
+- A client/server architecture. This, for example, can allow OpenCode to run on your computer while you drive it remotely from a mobile app, meaning that the TUI frontend is just one of the possible clients.
 
 ---
 

+ 3 - 1
README.no.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.pl.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.ru.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 2 - 1
README.th.md

@@ -30,7 +30,8 @@
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.br.md">Português (Brasil)</a> |
-  <a href="README.th.md">ไทย</a>
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 135 - 0
README.tr.md

@@ -0,0 +1,135 @@
+<p align="center">
+  <a href="https://opencode.ai">
+    <picture>
+      <source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
+      <source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
+      <img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
+    </picture>
+  </a>
+</p>
+<p align="center">Açık kaynaklı yapay zeka kodlama asistanı.</p>
+<p align="center">
+  <a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
+  <a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
+  <a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
+</p>
+
+<p align="center">
+  <a href="README.md">English</a> |
+  <a href="README.zh.md">简体中文</a> |
+  <a href="README.zht.md">繁體中文</a> |
+  <a href="README.ko.md">한국어</a> |
+  <a href="README.de.md">Deutsch</a> |
+  <a href="README.es.md">Español</a> |
+  <a href="README.fr.md">Français</a> |
+  <a href="README.it.md">Italiano</a> |
+  <a href="README.da.md">Dansk</a> |
+  <a href="README.ja.md">日本語</a> |
+  <a href="README.pl.md">Polski</a> |
+  <a href="README.ru.md">Русский</a> |
+  <a href="README.ar.md">العربية</a> |
+  <a href="README.no.md">Norsk</a> |
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
+</p>
+
+[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+
+---
+
+### Kurulum
+
+```bash
+# YOLO
+curl -fsSL https://opencode.ai/install | bash
+
+# Paket yöneticileri
+npm i -g opencode-ai@latest        # veya bun/pnpm/yarn
+scoop install opencode             # Windows
+choco install opencode             # Windows
+brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
+brew install opencode              # macOS ve Linux (resmi brew formülü, daha az güncellenir)
+paru -S opencode-bin               # Arch Linux
+mise use -g opencode               # Tüm işletim sistemleri
+nix run nixpkgs#opencode           # veya en güncel geliştirme dalı için github:anomalyco/opencode
+```
+
+> [!TIP]
+> Kurulumdan önce 0.1.x'ten eski sürümleri kaldırın.
+
+### Masaüstü Uygulaması (BETA)
+
+OpenCode ayrıca masaüstü uygulaması olarak da mevcuttur. Doğrudan [sürüm sayfasından](https://github.com/anomalyco/opencode/releases) veya [opencode.ai/download](https://opencode.ai/download) adresinden indirebilirsiniz.
+
+| Platform              | İndirme                               |
+| --------------------- | ------------------------------------- |
+| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
+| macOS (Intel)         | `opencode-desktop-darwin-x64.dmg`     |
+| Windows               | `opencode-desktop-windows-x64.exe`    |
+| Linux                 | `.deb`, `.rpm` veya AppImage          |
+
+```bash
+# macOS (Homebrew)
+brew install --cask opencode-desktop
+# Windows (Scoop)
+scoop bucket add extras; scoop install extras/opencode-desktop
+```
+
+#### Kurulum Dizini (Installation Directory)
+
+Kurulum betiği (install script), kurulum yolu (installation path) için aşağıdaki öncelik sırasını takip eder:
+
+1. `$OPENCODE_INSTALL_DIR` - Özel kurulum dizini
+2. `$XDG_BIN_DIR` - XDG Base Directory Specification uyumlu yol
+3. `$HOME/bin` - Standart kullanıcı binary dizini (varsa veya oluşturulabiliyorsa)
+4. `$HOME/.opencode/bin` - Varsayılan yedek konum
+
+```bash
+# Örnekler
+OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
+XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
+```
+
+### Ajanlar
+
+OpenCode, `Tab` tuşuyla aralarında geçiş yapabileceğiniz iki yerleşik (built-in) ajan içerir.
+
+- **build** - Varsayılan, geliştirme çalışmaları için tam erişimli ajan
+- **plan** - Analiz ve kod keşfi için salt okunur ajan
+  - Varsayılan olarak dosya düzenlemelerini reddeder
+  - Bash komutlarını çalıştırmadan önce izin ister
+  - Tanımadığınız kod tabanlarını keşfetmek veya değişiklikleri planlamak için ideal
+
+Ayrıca, karmaşık aramalar ve çok adımlı görevler için bir **genel** alt ajan bulunmaktadır.
+Bu dahili olarak kullanılır ve mesajlarda `@general` ile çağrılabilir.
+
+[Ajanlar](https://opencode.ai/docs/agents) hakkında daha fazla bilgi edinin.
+
+### Dokümantasyon
+
+OpenCode'u nasıl yapılandıracağınız hakkında daha fazla bilgi için [**dokümantasyonumuza göz atın**](https://opencode.ai/docs).
+
+### Katkıda Bulunma
+
+OpenCode'a katkıda bulunmak istiyorsanız, lütfen bir pull request göndermeden önce [katkıda bulunma dokümanlarımızı](./CONTRIBUTING.md) okuyun.
+
+### OpenCode Üzerine Geliştirme
+
+OpenCode ile ilgili bir proje üzerinde çalışıyorsanız ve projenizin adının bir parçası olarak "opencode" kullanıyorsanız (örneğin, "opencode-dashboard" veya "opencode-mobile"), lütfen README dosyanıza projenin OpenCode ekibi tarafından geliştirilmediğini ve bizimle hiçbir şekilde bağlantılı olmadığını belirten bir not ekleyin.
+
+### SSS
+
+#### Bu Claude Code'dan nasıl farklı?
+
+Yetenekler açısından Claude Code'a çok benzer. İşte temel farklar:
+
+- %100 açık kaynak
+- Herhangi bir sağlayıcıya bağlı değil. [OpenCode Zen](https://opencode.ai/zen) üzerinden sunduğumuz modelleri önermekle birlikte; OpenCode, Claude, OpenAI, Google veya hatta yerel modellerle kullanılabilir. Modeller geliştikçe aralarındaki farklar kapanacak ve fiyatlar düşecek, bu nedenle sağlayıcıdan bağımsız olmak önemlidir.
+- Kurulum gerektirmeyen hazır LSP desteği
+- TUI odaklı yaklaşım. OpenCode, neovim kullanıcıları ve [terminal.shop](https://terminal.shop)'un geliştiricileri tarafından geliştirilmektedir; terminalde olabileceklerin sınırlarını zorlayacağız.
+- İstemci/sunucu (client/server) mimarisi. Bu, örneğin OpenCode'un bilgisayarınızda çalışması ve siz onu bir mobil uygulamadan uzaktan yönetmenizi sağlar. TUI arayüzü olası istemcilerden sadece biridir.
+
+---
+
+**Topluluğumuza katılın** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)

+ 3 - 1
README.zh.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 4 - 2
README.zht.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -124,7 +126,7 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
 - 100% 開源。
 - 不綁定特定的服務提供商。雖然我們推薦使用透過 [OpenCode Zen](https://opencode.ai/zen) 提供的模型,但 OpenCode 也可搭配 Claude, OpenAI, Google 甚至本地模型使用。隨著模型不斷演進,彼此間的差距會縮小且價格會下降,因此具備「不限廠商 (provider-agnostic)」的特性至關重要。
 - 內建 LSP (語言伺服器協定) 支援。
-- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造我們將不斷挑戰終端機介面的極限。
+- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造我們將不斷挑戰終端機介面的極限。
 - 客戶端/伺服器架構 (Client/Server Architecture)。這讓 OpenCode 能夠在您的電腦上運行的同時,由行動裝置進行遠端操控。這意味著 TUI 前端只是眾多可能的客戶端之一。
 
 ---

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 200 - 111
bun.lock


+ 5 - 18
flake.nix

@@ -42,28 +42,15 @@
           desktop = pkgs.callPackage ./nix/desktop.nix {
             inherit opencode;
           };
-          # nixpkgs cpu naming to bun cpu naming
-          cpuMap = { x86_64 = "x64"; aarch64 = "arm64"; };
-          # matrix of node_modules builds - these will always fail due to fakeHash usage
-          # but allow computation of the correct hash from any build machine for any cpu/os
-          # see the update-nix-hashes workflow for usage
-          moduleUpdaters = pkgs.lib.listToAttrs (
-            pkgs.lib.concatMap (cpu:
-              map (os: {
-                name = "${cpu}-${os}_node_modules";
-                value = node_modules.override {
-                  bunCpu = cpuMap.${cpu};
-                  bunOs = os;
-                  hash = pkgs.lib.fakeHash;
-                };
-              }) [ "linux" "darwin" ]
-            ) [ "x86_64" "aarch64" ]
-          );
         in
         {
           default = opencode;
           inherit opencode desktop;
-        } // moduleUpdaters
+          # Updater derivation with fakeHash - build fails and reveals correct hash
+          node_modules_updater = node_modules.override {
+            hash = pkgs.lib.fakeHash;
+          };
+        }
       );
     };
 }

+ 3 - 3
nix/desktop.nix

@@ -45,8 +45,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
     rustc
     jq
     makeWrapper
-  ]
-  ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
+  ] ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
 
   buildInputs = lib.optionals stdenv.isLinux [
     dbus
@@ -61,6 +60,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
     gst_all_1.gstreamer
     gst_all_1.gst-plugins-base
     gst_all_1.gst-plugins-good
+    gst_all_1.gst-plugins-bad
   ];
 
   strictDeps = true;
@@ -97,4 +97,4 @@ rustPlatform.buildRustPackage (finalAttrs: {
     mainProgram = "opencode-desktop";
     inherit (opencode.meta) platforms;
   };
-})
+})

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-3wRTDLo5FZoUc2Bwm1aAJZ4dNsekX8XoY6TwTmohgYo=",
-    "aarch64-linux": "sha256-CKiuc6c52UV9cLEtccYEYS4QN0jYzNJv1fHSayqbHKo=",
-    "aarch64-darwin": "sha256-pWfXomWTDvG8WpWmUCwNXdbSHw6hPlqoT0Q/XuNceMc=",
-    "x86_64-darwin": "sha256-Dmg4+cUq2r6vZB2ta9tLpNAWqcl11ZCu4ZpieegRFrY="
+    "x86_64-linux": "sha256-UBz5qXhO+Xy6XptVdbo9V0wKsvZgItmHkWDm6I5VRCk=",
+    "aarch64-linux": "sha256-G2ezu/ThZR3kYfHnbD0EOcLoAa6hwtICpmo9r+bqibE=",
+    "aarch64-darwin": "sha256-PhSE23OzNlyfNFP5LffA3AtyN+hsyCeGInmDBBRjr0g=",
+    "x86_64-darwin": "sha256-vWusYJD+7ClDLUFy1wEqRLf9hY8V43iqdqnZ6YWkh1Q="
   }
 }

+ 8 - 10
nix/node_modules.nix

@@ -2,8 +2,6 @@
   lib,
   stdenvNoCC,
   bun,
-  bunCpu ? if stdenvNoCC.hostPlatform.isAarch64 then "arm64" else "x64",
-  bunOs ? if stdenvNoCC.hostPlatform.isLinux then "linux" else "darwin",
   rev ? "dirty",
   hash ?
     (lib.pipe ./hashes.json [
@@ -16,6 +14,9 @@ let
     builtins.readFile
     builtins.fromJSON
   ];
+  platform = stdenvNoCC.hostPlatform;
+  bunCpu = if platform.isAarch64 then "arm64" else "x64";
+  bunOs = if platform.isLinux then "linux" else "darwin";
 in
 stdenvNoCC.mkDerivation {
   pname = "opencode-node_modules";
@@ -39,23 +40,22 @@ stdenvNoCC.mkDerivation {
     "SOCKS_SERVER"
   ];
 
-  nativeBuildInputs = [
-    bun
-  ];
+  nativeBuildInputs = [ bun ];
 
   dontConfigure = true;
 
   buildPhase = ''
     runHook preBuild
-    export HOME=$(mktemp -d)
     export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
     bun install \
       --cpu="${bunCpu}" \
       --os="${bunOs}" \
+      --filter '!./' \
+      --filter './packages/opencode' \
+      --filter './packages/desktop' \
       --frozen-lockfile \
       --ignore-scripts \
-      --no-progress \
-      --linker=isolated
+      --no-progress
     bun --bun ${./scripts/canonicalize-node-modules.ts}
     bun --bun ${./scripts/normalize-bun-binaries.ts}
     runHook postBuild
@@ -63,10 +63,8 @@ stdenvNoCC.mkDerivation {
 
   installPhase = ''
     runHook preInstall
-
     mkdir -p $out
     find . -type d -name node_modules -exec cp -R --parents {} $out \;
-
     runHook postInstall
   '';
 

+ 5 - 3
package.json

@@ -4,9 +4,11 @@
   "description": "AI-powered development tool",
   "private": true,
   "type": "module",
-  "packageManager": "[email protected].5",
+  "packageManager": "[email protected].8",
   "scripts": {
     "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
+    "dev:desktop": "bun --cwd packages/desktop tauri dev",
+    "dev:web": "bun --cwd packages/app dev",
     "typecheck": "bun turbo typecheck",
     "prepare": "husky",
     "random": "echo 'Random script'",
@@ -22,7 +24,7 @@
       "packages/opencode/webgui"
     ],
     "catalog": {
-      "@types/bun": "1.3.5",
+      "@types/bun": "1.3.8",
       "@octokit/rest": "22.0.0",
       "@hono/zod-validator": "0.4.2",
       "ulid": "3.0.1",
@@ -101,6 +103,6 @@
     "@types/node": "catalog:"
   },
   "patchedDependencies": {
-    "[email protected]": "patches/[email protected].patch"
+    "@standard-community/[email protected]": "patches/@standard-community%[email protected].patch"
   }
 }

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

@@ -0,0 +1,176 @@
+# E2E Testing Guide
+
+## Build/Lint/Test Commands
+
+```bash
+# Run all e2e tests
+bun test:e2e
+
+# Run specific test file
+bun test:e2e -- app/home.spec.ts
+
+# Run single test by title
+bun test:e2e -- -g "home renders and shows core entrypoints"
+
+# Run tests with UI mode (for debugging)
+bun test:e2e:ui
+
+# Run tests locally with full server setup
+bun test:e2e:local
+
+# View test report
+bun test:e2e:report
+
+# Typecheck
+bun typecheck
+```
+
+## Test Structure
+
+All tests live in `packages/app/e2e/`:
+
+```
+e2e/
+├── fixtures.ts       # Test fixtures (test, expect, gotoSession, sdk)
+├── actions.ts        # Reusable action helpers
+├── selectors.ts      # DOM selectors
+├── utils.ts          # Utilities (serverUrl, modKey, path helpers)
+└── [feature]/
+    └── *.spec.ts     # Test files
+```
+
+## Test Patterns
+
+### Basic Test Structure
+
+```typescript
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { withSession } from "../actions"
+
+test("test description", async ({ page, sdk, gotoSession }) => {
+  await gotoSession() // or gotoSession(sessionID)
+
+  // Your test code
+  await expect(page.locator(promptSelector)).toBeVisible()
+})
+```
+
+### Using Fixtures
+
+- `page` - Playwright page
+- `sdk` - OpenCode SDK client for API calls
+- `gotoSession(sessionID?)` - Navigate to session
+
+### Helper Functions
+
+**Actions** (`actions.ts`):
+
+- `openPalette(page)` - Open command palette
+- `openSettings(page)` - Open settings dialog
+- `closeDialog(page, dialog)` - Close any dialog
+- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
+- `withSession(sdk, title, callback)` - Create temp session
+- `clickListItem(container, filter)` - Click list item by key/text
+
+**Selectors** (`selectors.ts`):
+
+- `promptSelector` - Prompt input
+- `terminalSelector` - Terminal panel
+- `sessionItemSelector(id)` - Session in sidebar
+- `listItemSelector` - Generic list items
+
+**Utils** (`utils.ts`):
+
+- `modKey` - Meta (Mac) or Control (Linux/Win)
+- `serverUrl` - Backend server URL
+- `sessionPath(dir, id?)` - Build session URL
+
+## Code Style Guidelines
+
+### Imports
+
+Always import from `../fixtures`, not `@playwright/test`:
+
+```typescript
+// ✅ Good
+import { test, expect } from "../fixtures"
+
+// ❌ Bad
+import { test, expect } from "@playwright/test"
+```
+
+### Naming Conventions
+
+- Test files: `feature-name.spec.ts`
+- Test names: lowercase, descriptive: `"sidebar can be toggled"`
+- Variables: camelCase
+- Constants: SCREAMING_SNAKE_CASE
+
+### Error Handling
+
+Tests should clean up after themselves:
+
+```typescript
+test("test with cleanup", async ({ page, sdk, gotoSession }) => {
+  await withSession(sdk, "test session", async (session) => {
+    await gotoSession(session.id)
+    // Test code...
+  }) // Auto-deletes session
+})
+```
+
+### Timeouts
+
+Default: 60s per test, 10s per assertion. Override when needed:
+
+```typescript
+test.setTimeout(120_000) // For long LLM operations
+test("slow test", async () => {
+  await expect.poll(() => check(), { timeout: 90_000 }).toBe(true)
+})
+```
+
+### Selectors
+
+Use `data-component`, `data-action`, or semantic roles:
+
+```typescript
+// ✅ Good
+await page.locator('[data-component="prompt-input"]').click()
+await page.getByRole("button", { name: "Open settings" }).click()
+
+// ❌ Bad
+await page.locator(".css-class-name").click()
+await page.locator("#id-name").click()
+```
+
+### Keyboard Shortcuts
+
+Use `modKey` for cross-platform compatibility:
+
+```typescript
+import { modKey } from "../utils"
+
+await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
+await page.keyboard.press(`${modKey}+Comma`) // Open settings
+```
+
+## Writing New Tests
+
+1. Choose appropriate folder or create new one
+2. Import from `../fixtures`
+3. Use helper functions from `../actions` and `../selectors`
+4. Clean up any created resources
+5. Use specific selectors (avoid CSS classes)
+6. Test one feature per test file
+
+## Local Development
+
+For UI debugging, use:
+
+```bash
+bun test:e2e:ui
+```
+
+This opens Playwright's interactive UI for step-through debugging.

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

@@ -8,16 +8,25 @@ import {
   sessionItemSelector,
   dropdownMenuTriggerSelector,
   dropdownMenuContentSelector,
+  projectMenuTriggerSelector,
+  projectWorkspacesToggleSelector,
   titlebarRightSelector,
   popoverBodySelector,
   listItemSelector,
   listItemKeySelector,
   listItemKeyStartsWithSelector,
+  workspaceItemSelector,
+  workspaceMenuTriggerSelector,
 } from "./selectors"
 import type { createSdk } from "./utils"
 
 export async function defocus(page: Page) {
-  await page.mouse.click(5, 5)
+  await page
+    .evaluate(() => {
+      const el = document.activeElement
+      if (el instanceof HTMLElement) el.blur()
+    })
+    .catch(() => undefined)
 }
 
 export async function openPalette(page: Page) {
@@ -64,14 +73,50 @@ export async function toggleSidebar(page: Page) {
 
 export async function openSidebar(page: Page) {
   if (!(await isSidebarClosed(page))) return
+
+  const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
+  const visible = await button
+    .isVisible()
+    .then((x) => x)
+    .catch(() => false)
+
+  if (visible) await button.click()
+  if (!visible) await toggleSidebar(page)
+
+  const main = page.locator("main")
+  const opened = await expect(main)
+    .not.toHaveClass(/xl:border-l/, { timeout: 1500 })
+    .then(() => true)
+    .catch(() => false)
+
+  if (opened) return
+
   await toggleSidebar(page)
-  await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
+  await expect(main).not.toHaveClass(/xl:border-l/)
 }
 
 export async function closeSidebar(page: Page) {
   if (await isSidebarClosed(page)) return
+
+  const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
+  const visible = await button
+    .isVisible()
+    .then((x) => x)
+    .catch(() => false)
+
+  if (visible) await button.click()
+  if (!visible) await toggleSidebar(page)
+
+  const main = page.locator("main")
+  const closed = await expect(main)
+    .toHaveClass(/xl:border-l/, { timeout: 1500 })
+    .then(() => true)
+    .catch(() => false)
+
+  if (closed) return
+
   await toggleSidebar(page)
-  await expect(page.locator("main")).toHaveClass(/xl:border-l/)
+  await expect(main).toHaveClass(/xl:border-l/)
 }
 
 export async function openSettings(page: Page) {
@@ -178,13 +223,30 @@ export async function hoverSessionItem(page: Page, sessionID: string) {
 }
 
 export async function openSessionMoreMenu(page: Page, sessionID: string) {
-  const sessionEl = await hoverSessionItem(page, sessionID)
+  await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
+
+  const scroller = page.locator(".session-scroller").first()
+  await expect(scroller).toBeVisible()
+  await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
+
+  const menu = page
+    .locator(dropdownMenuContentSelector)
+    .filter({ has: page.getByRole("menuitem", { name: /rename/i }) })
+    .filter({ has: page.getByRole("menuitem", { name: /archive/i }) })
+    .filter({ has: page.getByRole("menuitem", { name: /delete/i }) })
+    .first()
+
+  const opened = await menu
+    .isVisible()
+    .then((x) => x)
+    .catch(() => false)
+
+  if (opened) return menu
 
-  const menuTrigger = sessionEl.locator(dropdownMenuTriggerSelector).first()
+  const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
   await expect(menuTrigger).toBeVisible()
   await menuTrigger.click()
 
-  const menu = page.locator(dropdownMenuContentSelector).first()
   await expect(menu).toBeVisible()
   return menu
 }
@@ -269,3 +331,91 @@ export async function withSession<T>(
     await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
   }
 }
+
+export async function openStatusPopover(page: Page) {
+  await defocus(page)
+
+  const rightSection = page.locator(titlebarRightSelector)
+  const trigger = rightSection.getByRole("button", { name: /status/i }).first()
+
+  const popoverBody = page.locator(popoverBodySelector).filter({ has: page.locator('[data-component="tabs"]') })
+
+  const opened = await popoverBody
+    .isVisible()
+    .then((x) => x)
+    .catch(() => false)
+
+  if (!opened) {
+    await expect(trigger).toBeVisible()
+    await trigger.click()
+    await expect(popoverBody).toBeVisible()
+  }
+
+  return { rightSection, popoverBody }
+}
+
+export async function openProjectMenu(page: Page, projectSlug: string) {
+  const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
+  await expect(trigger).toHaveCount(1)
+
+  await trigger.focus()
+  await page.keyboard.press("Enter")
+
+  const menu = page.locator(dropdownMenuContentSelector).first()
+  const opened = await menu
+    .waitFor({ state: "visible", timeout: 1500 })
+    .then(() => true)
+    .catch(() => false)
+
+  if (opened) {
+    const viewport = page.viewportSize()
+    const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
+    const y = viewport ? Math.max(viewport.height - 5, 0) : 800
+    await page.mouse.move(x, y)
+    return menu
+  }
+
+  await trigger.click({ force: true })
+
+  await expect(menu).toBeVisible()
+
+  const viewport = page.viewportSize()
+  const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
+  const y = viewport ? Math.max(viewport.height - 5, 0) : 800
+  await page.mouse.move(x, y)
+  return menu
+}
+
+export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
+  const current = await page
+    .getByRole("button", { name: "New workspace" })
+    .first()
+    .isVisible()
+    .then((x) => x)
+    .catch(() => false)
+
+  if (current === enabled) return
+
+  await openProjectMenu(page, projectSlug)
+
+  const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
+  await expect(toggle).toBeVisible()
+  await toggle.click({ force: true })
+
+  const expected = enabled ? "New workspace" : "New session"
+  await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
+}
+
+export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
+  const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
+  await expect(item).toBeVisible()
+  await item.hover()
+
+  const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first()
+  await expect(trigger).toBeVisible()
+  await trigger.click({ force: true })
+
+  const menu = page.locator(dropdownMenuContentSelector).first()
+  await expect(menu).toBeVisible()
+  return menu
+}

+ 46 - 13
packages/app/e2e/fixtures.ts

@@ -1,11 +1,21 @@
-import { test as base, expect } from "@playwright/test"
-import { seedProjects } from "./actions"
+import { test as base, expect, type Page } from "@playwright/test"
+import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
 import { promptSelector } from "./selectors"
 import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
 
+export const settingsKey = "settings.v3"
+
 type TestFixtures = {
   sdk: ReturnType<typeof createSdk>
   gotoSession: (sessionID?: string) => Promise<void>
+  withProject: <T>(
+    callback: (project: {
+      directory: string
+      slug: string
+      gotoSession: (sessionID?: string) => Promise<void>
+    }) => Promise<T>,
+    options?: { extra?: string[] },
+  ) => Promise<T>
 }
 
 type WorkerFixtures = {
@@ -31,17 +41,7 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
     await use(createSdk(directory))
   },
   gotoSession: async ({ page, directory }, use) => {
-    await seedProjects(page, { directory })
-    await page.addInitScript(() => {
-      localStorage.setItem(
-        "opencode.global.dat:model",
-        JSON.stringify({
-          recent: [{ providerID: "opencode", modelID: "big-pickle" }],
-          user: [],
-          variant: {},
-        }),
-      )
-    })
+    await seedStorage(page, { directory })
 
     const gotoSession = async (sessionID?: string) => {
       await page.goto(sessionPath(directory, sessionID))
@@ -49,6 +49,39 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
     }
     await use(gotoSession)
   },
+  withProject: async ({ page }, use) => {
+    await use(async (callback, options) => {
+      const directory = await createTestProject()
+      const slug = dirSlug(directory)
+      await seedStorage(page, { directory, extra: options?.extra })
+
+      const gotoSession = async (sessionID?: string) => {
+        await page.goto(sessionPath(directory, sessionID))
+        await expect(page.locator(promptSelector)).toBeVisible()
+      }
+
+      try {
+        await gotoSession()
+        return await callback({ directory, slug, gotoSession })
+      } finally {
+        await cleanupTestProject(directory)
+      }
+    })
+  },
 })
 
+async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
+  await seedProjects(page, input)
+  await page.addInitScript(() => {
+    localStorage.setItem(
+      "opencode.global.dat:model",
+      JSON.stringify({
+        recent: [{ providerID: "opencode", modelID: "big-pickle" }],
+        user: [],
+        variant: {},
+      }),
+    )
+  })
+}
+
 export { expect }

+ 37 - 36
packages/app/e2e/projects/project-edit.spec.ts

@@ -1,52 +1,53 @@
 import { test, expect } from "../fixtures"
 import { openSidebar } from "../actions"
 
-test("dialog edit project updates name and startup script", async ({ page, gotoSession }) => {
-  await gotoSession()
+test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
 
-  await openSidebar(page)
+  await withProject(async () => {
+    await openSidebar(page)
 
-  const open = async () => {
-    const header = page.locator(".group\\/project").first()
-    await header.hover()
-    const trigger = header.getByRole("button", { name: "More options" }).first()
-    await expect(trigger).toBeVisible()
-    await trigger.click({ force: true })
-
-    const menu = page.locator('[data-component="dropdown-menu-content"]').first()
-    await expect(menu).toBeVisible()
+    const open = async () => {
+      const header = page.locator(".group\\/project").first()
+      await header.hover()
+      const trigger = header.getByRole("button", { name: "More options" }).first()
+      await expect(trigger).toBeVisible()
+      await trigger.click({ force: true })
 
-    const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
-    await expect(editItem).toBeVisible()
-    await editItem.click({ force: true })
+      const menu = page.locator('[data-component="dropdown-menu-content"]').first()
+      await expect(menu).toBeVisible()
 
-    const dialog = page.getByRole("dialog")
-    await expect(dialog).toBeVisible()
-    await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
-    return dialog
-  }
+      const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
+      await expect(editItem).toBeVisible()
+      await editItem.click({ force: true })
 
-  const name = `e2e project ${Date.now()}`
-  const startup = `echo e2e_${Date.now()}`
+      const dialog = page.getByRole("dialog")
+      await expect(dialog).toBeVisible()
+      await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
+      return dialog
+    }
 
-  const dialog = await open()
+    const name = `e2e project ${Date.now()}`
+    const startup = `echo e2e_${Date.now()}`
 
-  const nameInput = dialog.getByLabel("Name")
-  await nameInput.fill(name)
+    const dialog = await open()
 
-  const startupInput = dialog.getByLabel("Workspace startup script")
-  await startupInput.fill(startup)
+    const nameInput = dialog.getByLabel("Name")
+    await nameInput.fill(name)
 
-  await dialog.getByRole("button", { name: "Save" }).click()
-  await expect(dialog).toHaveCount(0)
+    const startupInput = dialog.getByLabel("Workspace startup script")
+    await startupInput.fill(startup)
 
-  const header = page.locator(".group\\/project").first()
-  await expect(header).toContainText(name)
+    await dialog.getByRole("button", { name: "Save" }).click()
+    await expect(dialog).toHaveCount(0)
 
-  const reopened = await open()
-  await expect(reopened.getByLabel("Name")).toHaveValue(name)
-  await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
-  await reopened.getByRole("button", { name: "Cancel" }).click()
-  await expect(reopened).toHaveCount(0)
+    const header = page.locator(".group\\/project").first()
+    await expect(header).toContainText(name)
+
+    const reopened = await open()
+    await expect(reopened.getByLabel("Name")).toHaveValue(name)
+    await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
+    await reopened.getByRole("button", { name: "Cancel" }).click()
+    await expect(reopened).toHaveCount(0)
+  })
 })

+ 48 - 44
packages/app/e2e/projects/projects-close.spec.ts

@@ -1,69 +1,73 @@
 import { test, expect } from "../fixtures"
-import { createTestProject, seedProjects, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
+import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
 import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors"
 import { dirSlug } from "../utils"
 
-test("can close a project via hover card close button", async ({ page, directory, gotoSession }) => {
+test("can close a project via hover card close button", async ({ page, withProject }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
 
   const other = await createTestProject()
   const otherSlug = dirSlug(other)
-  await seedProjects(page, { directory, extra: [other] })
 
   try {
-    await gotoSession()
-
-    await openSidebar(page)
-
-    const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
-    await expect(otherButton).toBeVisible()
-    await otherButton.hover()
-
-    const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
-    await expect(close).toBeVisible()
-    await close.click()
-
-    await expect(otherButton).toHaveCount(0)
+    await withProject(
+      async () => {
+        await openSidebar(page)
+
+        const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
+        await expect(otherButton).toBeVisible()
+        await otherButton.hover()
+
+        const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
+        await expect(close).toBeVisible()
+        await close.click()
+
+        await expect(otherButton).toHaveCount(0)
+      },
+      { extra: [other] },
+    )
   } finally {
     await cleanupTestProject(other)
   }
 })
 
-test("can close a project via project header more options menu", async ({ page, directory, gotoSession }) => {
+test("can close a project via project header more options menu", async ({ page, withProject }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
 
   const other = await createTestProject()
   const otherName = other.split("/").pop() ?? other
   const otherSlug = dirSlug(other)
-  await seedProjects(page, { directory, extra: [other] })
 
   try {
-    await gotoSession()
-
-    await openSidebar(page)
-
-    const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
-    await expect(otherButton).toBeVisible()
-    await otherButton.click()
-
-    await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
-
-    const header = page
-      .locator(".group\\/project")
-      .filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) })
-      .first()
-    await expect(header).toContainText(otherName)
-
-    const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
-    await expect(trigger).toHaveCount(1)
-    await trigger.focus()
-    await page.keyboard.press("Enter")
-
-    const menu = page.locator('[data-component="dropdown-menu-content"]').first()
-    await expect(menu).toBeVisible({ timeout: 10_000 })
-
-    await clickMenuItem(menu, /^Close$/i, { force: true })
-    await expect(otherButton).toHaveCount(0)
+    await withProject(
+      async () => {
+        await openSidebar(page)
+
+        const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
+        await expect(otherButton).toBeVisible()
+        await otherButton.click()
+
+        await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
+
+        const header = page
+          .locator(".group\\/project")
+          .filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) })
+          .first()
+        await expect(header).toContainText(otherName)
+
+        const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
+        await expect(trigger).toHaveCount(1)
+        await trigger.focus()
+        await page.keyboard.press("Enter")
+
+        const menu = page.locator('[data-component="dropdown-menu-content"]').first()
+        await expect(menu).toBeVisible({ timeout: 10_000 })
+
+        await clickMenuItem(menu, /^Close$/i, { force: true })
+        await expect(otherButton).toHaveCount(0)
+      },
+      { extra: [other] },
+    )
   } finally {
     await cleanupTestProject(other)
   }

+ 17 - 16
packages/app/e2e/projects/projects-switch.spec.ts

@@ -1,33 +1,34 @@
 import { test, expect } from "../fixtures"
-import { defocus, createTestProject, seedProjects, cleanupTestProject } from "../actions"
+import { defocus, createTestProject, cleanupTestProject } from "../actions"
 import { projectSwitchSelector } from "../selectors"
 import { dirSlug } from "../utils"
 
-test("can switch between projects from sidebar", async ({ page, directory, gotoSession }) => {
+test("can switch between projects from sidebar", async ({ page, withProject }) => {
   await page.setViewportSize({ width: 1400, height: 800 })
 
   const other = await createTestProject()
   const otherSlug = dirSlug(other)
 
-  await seedProjects(page, { directory, extra: [other] })
-
   try {
-    await gotoSession()
-
-    await defocus(page)
+    await withProject(
+      async ({ directory }) => {
+        await defocus(page)
 
-    const currentSlug = dirSlug(directory)
-    const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
-    await expect(otherButton).toBeVisible()
-    await otherButton.click()
+        const currentSlug = dirSlug(directory)
+        const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
+        await expect(otherButton).toBeVisible()
+        await otherButton.click()
 
-    await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
+        await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
 
-    const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
-    await expect(currentButton).toBeVisible()
-    await currentButton.click()
+        const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
+        await expect(currentButton).toBeVisible()
+        await currentButton.click()
 
-    await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
+        await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
+      },
+      { extra: [other] },
+    )
   } finally {
     await cleanupTestProject(other)
   }

+ 333 - 0
packages/app/e2e/projects/workspaces.spec.ts

@@ -0,0 +1,333 @@
+import { base64Decode } from "@opencode-ai/util/encode"
+import fs from "node:fs/promises"
+import path from "node:path"
+import type { Page } from "@playwright/test"
+
+import { test, expect } from "../fixtures"
+
+test.describe.configure({ mode: "serial" })
+import {
+  cleanupTestProject,
+  clickMenuItem,
+  confirmDialog,
+  openSidebar,
+  openWorkspaceMenu,
+  setWorkspacesEnabled,
+} from "../actions"
+import { inlineInputSelector, workspaceItemSelector } from "../selectors"
+
+function slugFromUrl(url: string) {
+  return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
+}
+
+async function setupWorkspaceTest(page: Page, project: { slug: string }) {
+  const rootSlug = project.slug
+  await openSidebar(page)
+
+  await setWorkspacesEnabled(page, rootSlug, true)
+
+  await page.getByRole("button", { name: "New workspace" }).first().click()
+  await expect
+    .poll(
+      () => {
+        const slug = slugFromUrl(page.url())
+        return slug.length > 0 && slug !== rootSlug
+      },
+      { timeout: 45_000 },
+    )
+    .toBe(true)
+
+  const slug = slugFromUrl(page.url())
+  const dir = base64Decode(slug)
+
+  await openSidebar(page)
+
+  await expect
+    .poll(
+      async () => {
+        const item = page.locator(workspaceItemSelector(slug)).first()
+        try {
+          await item.hover({ timeout: 500 })
+          return true
+        } catch {
+          return false
+        }
+      },
+      { timeout: 60_000 },
+    )
+    .toBe(true)
+
+  return { rootSlug, slug, directory: dir }
+}
+
+test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  await withProject(async ({ slug }) => {
+    await openSidebar(page)
+
+    await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
+    await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
+
+    await setWorkspacesEnabled(page, slug, true)
+    await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
+    await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
+
+    await setWorkspacesEnabled(page, slug, false)
+    await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
+    await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
+  })
+})
+
+test("can create a workspace", async ({ page, withProject }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  await withProject(async ({ slug }) => {
+    await openSidebar(page)
+    await setWorkspacesEnabled(page, slug, true)
+
+    await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
+
+    await page.getByRole("button", { name: "New workspace" }).first().click()
+
+    await expect
+      .poll(
+        () => {
+          const currentSlug = slugFromUrl(page.url())
+          return currentSlug.length > 0 && currentSlug !== slug
+        },
+        { timeout: 45_000 },
+      )
+      .toBe(true)
+
+    const workspaceSlug = slugFromUrl(page.url())
+    const workspaceDir = base64Decode(workspaceSlug)
+
+    await openSidebar(page)
+
+    await expect
+      .poll(
+        async () => {
+          const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
+          try {
+            await item.hover({ timeout: 500 })
+            return true
+          } catch {
+            return false
+          }
+        },
+        { timeout: 60_000 },
+      )
+      .toBe(true)
+
+    await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
+
+    await cleanupTestProject(workspaceDir)
+  })
+})
+
+test("can rename a workspace", async ({ page, withProject }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  await withProject(async (project) => {
+    const { slug } = await setupWorkspaceTest(page, project)
+
+    const rename = `e2e workspace ${Date.now()}`
+    const menu = await openWorkspaceMenu(page, slug)
+    await clickMenuItem(menu, /^Rename$/i, { force: true })
+
+    await expect(menu).toHaveCount(0)
+
+    const item = page.locator(workspaceItemSelector(slug)).first()
+    await expect(item).toBeVisible()
+    const input = item.locator(inlineInputSelector).first()
+    await expect(input).toBeVisible()
+    await input.fill(rename)
+    await input.press("Enter")
+    await expect(item).toContainText(rename)
+  })
+})
+
+test("can reset a workspace", async ({ page, sdk, withProject }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  await withProject(async (project) => {
+    const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
+
+    const readme = path.join(createdDir, "README.md")
+    const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
+    const original = await fs.readFile(readme, "utf8")
+    const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
+    await fs.writeFile(readme, dirty, "utf8")
+    await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
+
+    await expect
+      .poll(async () => {
+        return await fs
+          .stat(extra)
+          .then(() => true)
+          .catch(() => false)
+      })
+      .toBe(true)
+
+    await expect
+      .poll(async () => {
+        const files = await sdk.file
+          .status({ directory: createdDir })
+          .then((r) => r.data ?? [])
+          .catch(() => [])
+        return files.length
+      })
+      .toBeGreaterThan(0)
+
+    const menu = await openWorkspaceMenu(page, slug)
+    await clickMenuItem(menu, /^Reset$/i, { force: true })
+    await confirmDialog(page, /^Reset workspace$/i)
+
+    await expect
+      .poll(
+        async () => {
+          const files = await sdk.file
+            .status({ directory: createdDir })
+            .then((r) => r.data ?? [])
+            .catch(() => [])
+          return files.length
+        },
+        { timeout: 60_000 },
+      )
+      .toBe(0)
+
+    await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
+
+    await expect
+      .poll(async () => {
+        return await fs
+          .stat(extra)
+          .then(() => true)
+          .catch(() => false)
+      })
+      .toBe(false)
+  })
+})
+
+test("can delete a workspace", async ({ page, withProject }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  await withProject(async (project) => {
+    const { rootSlug, slug } = await setupWorkspaceTest(page, project)
+
+    const menu = await openWorkspaceMenu(page, slug)
+    await clickMenuItem(menu, /^Delete$/i, { force: true })
+    await confirmDialog(page, /^Delete workspace$/i)
+
+    await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
+    await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
+    await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
+  })
+})
+
+test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+  await withProject(async ({ slug: rootSlug }) => {
+    const workspaces = [] as { directory: string; slug: string }[]
+
+    const listSlugs = async () => {
+      const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
+      const slugs = await nodes.evaluateAll((els) => {
+        return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
+      })
+      return slugs
+    }
+
+    const waitReady = async (slug: string) => {
+      await expect
+        .poll(
+          async () => {
+            const item = page.locator(workspaceItemSelector(slug)).first()
+            try {
+              await item.hover({ timeout: 500 })
+              return true
+            } catch {
+              return false
+            }
+          },
+          { timeout: 60_000 },
+        )
+        .toBe(true)
+    }
+
+    const drag = async (from: string, to: string) => {
+      const src = page.locator(workspaceItemSelector(from)).first()
+      const dst = page.locator(workspaceItemSelector(to)).first()
+
+      await src.scrollIntoViewIfNeeded()
+      await dst.scrollIntoViewIfNeeded()
+
+      const a = await src.boundingBox()
+      const b = await dst.boundingBox()
+      if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
+
+      await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
+      await page.mouse.down()
+      await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
+      await page.mouse.up()
+    }
+
+    try {
+      await openSidebar(page)
+
+      await setWorkspacesEnabled(page, rootSlug, true)
+
+      for (const _ of [0, 1]) {
+        const prev = slugFromUrl(page.url())
+        await page.getByRole("button", { name: "New workspace" }).first().click()
+        await expect
+          .poll(
+            () => {
+              const slug = slugFromUrl(page.url())
+              return slug.length > 0 && slug !== rootSlug && slug !== prev
+            },
+            { timeout: 45_000 },
+          )
+          .toBe(true)
+
+        const slug = slugFromUrl(page.url())
+        const dir = base64Decode(slug)
+        workspaces.push({ slug, directory: dir })
+
+        await openSidebar(page)
+      }
+
+      if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
+
+      const a = workspaces[0].slug
+      const b = workspaces[1].slug
+
+      await waitReady(a)
+      await waitReady(b)
+
+      const list = async () => {
+        const slugs = await listSlugs()
+        return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
+      }
+
+      await expect
+        .poll(async () => {
+          const slugs = await list()
+          return slugs.length === 2
+        })
+        .toBe(true)
+
+      const before = await list()
+      const from = before[1]
+      const to = before[0]
+      if (!from || !to) throw new Error("Failed to resolve initial workspace order")
+
+      await drag(from, to)
+
+      await expect.poll(async () => await list()).toEqual([from, to])
+    } finally {
+      await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
+    }
+  })
+})

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

@@ -3,6 +3,17 @@ export const terminalSelector = '[data-component="terminal"]'
 
 export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
 export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
+export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
+export const settingsThemeSelector = '[data-action="settings-theme"]'
+export const settingsFontSelector = '[data-action="settings-font"]'
+export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]'
+export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
+export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
+export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
+export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
+export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
+export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
+export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
 
 export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
 
@@ -16,6 +27,9 @@ export const projectMenuTriggerSelector = (slug: string) =>
 
 export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
 
+export const projectWorkspacesToggleSelector = (slug: string) =>
+  `[data-action="project-workspaces-toggle"][data-project="${slug}"]`
+
 export const titlebarRightSelector = "#opencode-titlebar-right"
 
 export const popoverBodySelector = '[data-slot="popover-body"]'
@@ -28,8 +42,16 @@ export const inlineInputSelector = '[data-component="inline-input"]'
 
 export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
 
+export const workspaceItemSelector = (slug: string) =>
+  `${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]`
+
+export const workspaceMenuTriggerSelector = (slug: string) =>
+  `${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
+
 export const listItemSelector = '[data-slot="list-item"]'
 
 export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
 
 export const listItemKeySelector = (key: string) => `${listItemSelector}[data-key="${key}"]`
+
+export const keybindButtonSelector = (id: string) => `[data-keybind-id="${id}"]`

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

@@ -11,57 +11,98 @@ import { sessionItemSelector, inlineInputSelector } from "../selectors"
 
 const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
 
-test("sidebar session can be renamed", async ({ page, sdk, gotoSession }) => {
+type Sdk = Parameters<typeof withSession>[0]
+
+async function seedMessage(sdk: Sdk, sessionID: string) {
+  await sdk.session.promptAsync({
+    sessionID,
+    noReply: true,
+    parts: [{ type: "text", text: "e2e seed" }],
+  })
+
+  await expect
+    .poll(
+      async () => {
+        const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
+        return messages.length
+      },
+      { timeout: 30_000 },
+    )
+    .toBeGreaterThan(0)
+}
+
+test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
   const stamp = Date.now()
   const originalTitle = `e2e rename test ${stamp}`
   const newTitle = `e2e renamed ${stamp}`
 
   await withSession(sdk, originalTitle, async (session) => {
+    await seedMessage(sdk, session.id)
     await gotoSession(session.id)
-    await openSidebar(page)
 
     const menu = await openSessionMoreMenu(page, session.id)
     await clickMenuItem(menu, /rename/i)
 
-    const input = page.locator(sessionItemSelector(session.id)).locator(inlineInputSelector).first()
+    const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
     await expect(input).toBeVisible()
     await input.fill(newTitle)
     await input.press("Enter")
 
-    await expect(page.locator(sessionItemSelector(session.id)).locator("a").first()).toContainText(newTitle)
+    await expect(page.getByRole("heading", { level: 1 }).first()).toContainText(newTitle)
   })
 })
 
-test("sidebar session can be archived", async ({ page, sdk, gotoSession }) => {
+test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => {
   const stamp = Date.now()
   const title = `e2e archive test ${stamp}`
 
   await withSession(sdk, title, async (session) => {
+    await seedMessage(sdk, session.id)
     await gotoSession(session.id)
-    await openSidebar(page)
-
-    const sessionEl = page.locator(sessionItemSelector(session.id))
     const menu = await openSessionMoreMenu(page, session.id)
     await clickMenuItem(menu, /archive/i)
 
-    await expect(sessionEl).not.toBeVisible()
+    await expect
+      .poll(
+        async () => {
+          const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+          return data?.time?.archived
+        },
+        { timeout: 30_000 },
+      )
+      .not.toBeUndefined()
+
+    await openSidebar(page)
+    await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
   })
 })
 
-test("sidebar session can be deleted", async ({ page, sdk, gotoSession }) => {
+test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => {
   const stamp = Date.now()
   const title = `e2e delete test ${stamp}`
 
   await withSession(sdk, title, async (session) => {
+    await seedMessage(sdk, session.id)
     await gotoSession(session.id)
-    await openSidebar(page)
-
-    const sessionEl = page.locator(sessionItemSelector(session.id))
     const menu = await openSessionMoreMenu(page, session.id)
     await clickMenuItem(menu, /delete/i)
     await confirmDialog(page, /delete/i)
 
-    await expect(sessionEl).not.toBeVisible()
+    await expect
+      .poll(
+        async () => {
+          const data = await sdk.session
+            .get({ sessionID: session.id })
+            .then((r) => r.data)
+            .catch(() => undefined)
+          return data?.id
+        },
+        { timeout: 30_000 },
+      )
+      .toBeUndefined()
+
+    await openSidebar(page)
+    await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
   })
 })
 
@@ -72,6 +113,7 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
   const title = `e2e share test ${stamp}`
 
   await withSession(sdk, title, async (session) => {
+    await seedMessage(sdk, session.id)
     await gotoSession(session.id)
 
     const { rightSection, popoverBody } = await openSharePopover(page)

+ 317 - 0
packages/app/e2e/settings/settings-keybinds.spec.ts

@@ -0,0 +1,317 @@
+import { test, expect } from "../fixtures"
+import { openSettings, closeDialog, withSession } from "../actions"
+import { keybindButtonSelector } from "../selectors"
+import { modKey } from "../utils"
+
+test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+  const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
+  await expect(keybindButton).toBeVisible()
+
+  const initialKeybind = await keybindButton.textContent()
+  expect(initialKeybind).toContain("B")
+
+  await keybindButton.click()
+  await expect(keybindButton).toHaveText(/press/i)
+
+  await page.keyboard.press(`${modKey}+Shift+KeyH`)
+  await page.waitForTimeout(100)
+
+  const newKeybind = await keybindButton.textContent()
+  expect(newKeybind).toContain("H")
+
+  const stored = await page.evaluate(() => {
+    const raw = localStorage.getItem("settings.v3")
+    return raw ? JSON.parse(raw) : null
+  })
+  expect(stored?.keybinds?.["sidebar.toggle"]).toBe("mod+shift+h")
+
+  await closeDialog(page, dialog)
+
+  const main = page.locator("main")
+  const initialClasses = (await main.getAttribute("class")) ?? ""
+  const initiallyClosed = initialClasses.includes("xl:border-l")
+
+  await page.keyboard.press(`${modKey}+Shift+H`)
+  await page.waitForTimeout(100)
+
+  const afterToggleClasses = (await main.getAttribute("class")) ?? ""
+  const afterToggleClosed = afterToggleClasses.includes("xl:border-l")
+  expect(afterToggleClosed).toBe(!initiallyClosed)
+
+  await page.keyboard.press(`${modKey}+Shift+H`)
+  await page.waitForTimeout(100)
+
+  const finalClasses = (await main.getAttribute("class")) ?? ""
+  const finalClosed = finalClasses.includes("xl:border-l")
+  expect(finalClosed).toBe(initiallyClosed)
+})
+
+test("resetting all keybinds to defaults works", async ({ page, gotoSession }) => {
+  await page.addInitScript(() => {
+    localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "sidebar.toggle": "mod+shift+x" } }))
+  })
+
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+  const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
+  await expect(keybindButton).toBeVisible()
+
+  const customKeybind = await keybindButton.textContent()
+  expect(customKeybind).toContain("X")
+
+  const resetButton = dialog.getByRole("button", { name: "Reset to defaults" })
+  await expect(resetButton).toBeVisible()
+  await expect(resetButton).toBeEnabled()
+  await resetButton.click()
+  await page.waitForTimeout(100)
+
+  const restoredKeybind = await keybindButton.textContent()
+  expect(restoredKeybind).toContain("B")
+
+  const stored = await page.evaluate(() => {
+    const raw = localStorage.getItem("settings.v3")
+    return raw ? JSON.parse(raw) : null
+  })
+  expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined()
+
+  await closeDialog(page, dialog)
+})
+
+test("clearing a keybind works", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+  const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
+  await expect(keybindButton).toBeVisible()
+
+  const initialKeybind = await keybindButton.textContent()
+  expect(initialKeybind).toContain("B")
+
+  await keybindButton.click()
+  await expect(keybindButton).toHaveText(/press/i)
+
+  await page.keyboard.press("Delete")
+  await page.waitForTimeout(100)
+
+  const clearedKeybind = await keybindButton.textContent()
+  expect(clearedKeybind).toMatch(/unassigned|press/i)
+
+  const stored = await page.evaluate(() => {
+    const raw = localStorage.getItem("settings.v3")
+    return raw ? JSON.parse(raw) : null
+  })
+  expect(stored?.keybinds?.["sidebar.toggle"]).toBe("none")
+
+  await closeDialog(page, dialog)
+
+  await page.keyboard.press(`${modKey}+B`)
+  await page.waitForTimeout(100)
+
+  const stillOnSession = page.url().includes("/session")
+  expect(stillOnSession).toBe(true)
+})
+
+test("changing settings open keybind works", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+  const keybindButton = dialog.locator(keybindButtonSelector("settings.open"))
+  await expect(keybindButton).toBeVisible()
+
+  const initialKeybind = await keybindButton.textContent()
+  expect(initialKeybind).toContain(",")
+
+  await keybindButton.click()
+  await expect(keybindButton).toHaveText(/press/i)
+
+  await page.keyboard.press(`${modKey}+Slash`)
+  await page.waitForTimeout(100)
+
+  const newKeybind = await keybindButton.textContent()
+  expect(newKeybind).toContain("/")
+
+  const stored = await page.evaluate(() => {
+    const raw = localStorage.getItem("settings.v3")
+    return raw ? JSON.parse(raw) : null
+  })
+  expect(stored?.keybinds?.["settings.open"]).toBe("mod+/")
+
+  await closeDialog(page, dialog)
+
+  const settingsDialog = page.getByRole("dialog")
+  await expect(settingsDialog).toHaveCount(0)
+
+  await page.keyboard.press(`${modKey}+Slash`)
+  await page.waitForTimeout(100)
+
+  await expect(settingsDialog).toBeVisible()
+
+  await closeDialog(page, settingsDialog)
+})
+
+test("changing new session keybind works", async ({ page, sdk, gotoSession }) => {
+  await withSession(sdk, "test session for keybind", async (session) => {
+    await gotoSession(session.id)
+
+    const initialUrl = page.url()
+    expect(initialUrl).toContain(`/session/${session.id}`)
+
+    const dialog = await openSettings(page)
+    await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+    const keybindButton = dialog.locator(keybindButtonSelector("session.new"))
+    await expect(keybindButton).toBeVisible()
+
+    await keybindButton.click()
+    await expect(keybindButton).toHaveText(/press/i)
+
+    await page.keyboard.press(`${modKey}+Shift+KeyN`)
+    await page.waitForTimeout(100)
+
+    const newKeybind = await keybindButton.textContent()
+    expect(newKeybind).toContain("N")
+
+    const stored = await page.evaluate(() => {
+      const raw = localStorage.getItem("settings.v3")
+      return raw ? JSON.parse(raw) : null
+    })
+    expect(stored?.keybinds?.["session.new"]).toBe("mod+shift+n")
+
+    await closeDialog(page, dialog)
+
+    await page.keyboard.press(`${modKey}+Shift+N`)
+    await page.waitForTimeout(200)
+
+    const newUrl = page.url()
+    expect(newUrl).toMatch(/\/session\/?$/)
+    expect(newUrl).not.toContain(session.id)
+  })
+})
+
+test("changing file open keybind works", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+  const keybindButton = dialog.locator(keybindButtonSelector("file.open"))
+  await expect(keybindButton).toBeVisible()
+
+  const initialKeybind = await keybindButton.textContent()
+  expect(initialKeybind).toContain("P")
+
+  await keybindButton.click()
+  await expect(keybindButton).toHaveText(/press/i)
+
+  await page.keyboard.press(`${modKey}+Shift+KeyF`)
+  await page.waitForTimeout(100)
+
+  const newKeybind = await keybindButton.textContent()
+  expect(newKeybind).toContain("F")
+
+  const stored = await page.evaluate(() => {
+    const raw = localStorage.getItem("settings.v3")
+    return raw ? JSON.parse(raw) : null
+  })
+  expect(stored?.keybinds?.["file.open"]).toBe("mod+shift+f")
+
+  await closeDialog(page, dialog)
+
+  const filePickerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder(/search files/i) })
+  await expect(filePickerDialog).toHaveCount(0)
+
+  await page.keyboard.press(`${modKey}+Shift+F`)
+  await page.waitForTimeout(100)
+
+  await expect(filePickerDialog).toBeVisible()
+
+  await page.keyboard.press("Escape")
+  await expect(filePickerDialog).toHaveCount(0)
+})
+
+test("changing terminal toggle keybind works", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+  const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle"))
+  await expect(keybindButton).toBeVisible()
+
+  await keybindButton.click()
+  await expect(keybindButton).toHaveText(/press/i)
+
+  await page.keyboard.press(`${modKey}+KeyY`)
+  await page.waitForTimeout(100)
+
+  const newKeybind = await keybindButton.textContent()
+  expect(newKeybind).toContain("Y")
+
+  const stored = await page.evaluate(() => {
+    const raw = localStorage.getItem("settings.v3")
+    return raw ? JSON.parse(raw) : null
+  })
+  expect(stored?.keybinds?.["terminal.toggle"]).toBe("mod+y")
+
+  await closeDialog(page, dialog)
+
+  await page.keyboard.press(`${modKey}+Y`)
+  await page.waitForTimeout(100)
+
+  const pageStable = await page.evaluate(() => document.readyState === "complete")
+  expect(pageStable).toBe(true)
+})
+
+test("changing command palette keybind works", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+  const keybindButton = dialog.locator(keybindButtonSelector("command.palette"))
+  await expect(keybindButton).toBeVisible()
+
+  const initialKeybind = await keybindButton.textContent()
+  expect(initialKeybind).toContain("P")
+
+  await keybindButton.click()
+  await expect(keybindButton).toHaveText(/press/i)
+
+  await page.keyboard.press(`${modKey}+Shift+KeyK`)
+  await page.waitForTimeout(100)
+
+  const newKeybind = await keybindButton.textContent()
+  expect(newKeybind).toContain("K")
+
+  const stored = await page.evaluate(() => {
+    const raw = localStorage.getItem("settings.v3")
+    return raw ? JSON.parse(raw) : null
+  })
+  expect(stored?.keybinds?.["command.palette"]).toBe("mod+shift+k")
+
+  await closeDialog(page, dialog)
+
+  const palette = page.getByRole("dialog").filter({ has: page.getByRole("textbox").first() })
+  await expect(palette).toHaveCount(0)
+
+  await page.keyboard.press(`${modKey}+Shift+K`)
+  await page.waitForTimeout(100)
+
+  await expect(palette).toBeVisible()
+  await expect(palette.getByRole("textbox").first()).toBeVisible()
+
+  await page.keyboard.press("Escape")
+  await expect(palette).toHaveCount(0)
+})

+ 0 - 28
packages/app/e2e/settings/settings-language.spec.ts

@@ -1,28 +0,0 @@
-import { test, expect } from "../fixtures"
-import { settingsLanguageSelectSelector } from "../selectors"
-import { openSettings } from "../actions"
-
-test("smoke changing language updates settings labels", async ({ page, gotoSession }) => {
-  await page.addInitScript(() => {
-    localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" }))
-  })
-
-  await gotoSession()
-
-  const dialog = await openSettings(page)
-
-  const heading = dialog.getByRole("heading", { level: 2 })
-  await expect(heading).toHaveText("General")
-
-  const select = dialog.locator(settingsLanguageSelectSelector)
-  await expect(select).toBeVisible()
-  await select.locator('[data-slot="select-select-trigger"]').click()
-
-  await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click()
-
-  await expect(heading).toHaveText("Allgemein")
-
-  await select.locator('[data-slot="select-select-trigger"]').click()
-  await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click()
-  await expect(heading).toHaveText("General")
-})

+ 122 - 0
packages/app/e2e/settings/settings-models.spec.ts

@@ -0,0 +1,122 @@
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { closeDialog, openSettings } from "../actions"
+
+test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  await page.locator(promptSelector).click()
+  await page.keyboard.type("/model")
+
+  const command = page.locator('[data-slash-id="model.choose"]')
+  await expect(command).toBeVisible()
+  await command.hover()
+  await page.keyboard.press("Enter")
+
+  const picker = page.getByRole("dialog")
+  await expect(picker).toBeVisible()
+
+  const target = picker.locator('[data-slot="list-item"]').first()
+  await expect(target).toBeVisible()
+
+  const key = await target.getAttribute("data-key")
+  if (!key) throw new Error("Failed to resolve model key from list item")
+
+  const name = (await target.locator("span").first().innerText()).trim()
+  if (!name) throw new Error("Failed to resolve model name from list item")
+
+  await page.keyboard.press("Escape")
+  await expect(picker).toHaveCount(0)
+
+  const settings = await openSettings(page)
+
+  await settings.getByRole("tab", { name: "Models" }).click()
+  const search = settings.getByPlaceholder("Search models")
+  await expect(search).toBeVisible()
+  await search.fill(name)
+
+  const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
+  const input = toggle.locator('[data-slot="switch-input"]')
+  await expect(toggle).toBeVisible()
+  await expect(input).toHaveAttribute("aria-checked", "true")
+  await toggle.locator('[data-slot="switch-control"]').click()
+  await expect(input).toHaveAttribute("aria-checked", "false")
+
+  await closeDialog(page, settings)
+
+  await page.locator(promptSelector).click()
+  await page.keyboard.type("/model")
+  await expect(command).toBeVisible()
+  await command.hover()
+  await page.keyboard.press("Enter")
+
+  const pickerAgain = page.getByRole("dialog")
+  await expect(pickerAgain).toBeVisible()
+  await expect(pickerAgain.locator('[data-slot="list-item"]').first()).toBeVisible()
+
+  await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toHaveCount(0)
+
+  await page.keyboard.press("Escape")
+  await expect(pickerAgain).toHaveCount(0)
+})
+
+test("showing a hidden model restores it to the model picker", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  await page.locator(promptSelector).click()
+  await page.keyboard.type("/model")
+
+  const command = page.locator('[data-slash-id="model.choose"]')
+  await expect(command).toBeVisible()
+  await command.hover()
+  await page.keyboard.press("Enter")
+
+  const picker = page.getByRole("dialog")
+  await expect(picker).toBeVisible()
+
+  const target = picker.locator('[data-slot="list-item"]').first()
+  await expect(target).toBeVisible()
+
+  const key = await target.getAttribute("data-key")
+  if (!key) throw new Error("Failed to resolve model key from list item")
+
+  const name = (await target.locator("span").first().innerText()).trim()
+  if (!name) throw new Error("Failed to resolve model name from list item")
+
+  await page.keyboard.press("Escape")
+  await expect(picker).toHaveCount(0)
+
+  const settings = await openSettings(page)
+
+  await settings.getByRole("tab", { name: "Models" }).click()
+  const search = settings.getByPlaceholder("Search models")
+  await expect(search).toBeVisible()
+  await search.fill(name)
+
+  const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
+  const input = toggle.locator('[data-slot="switch-input"]')
+  await expect(toggle).toBeVisible()
+  await expect(input).toHaveAttribute("aria-checked", "true")
+
+  await toggle.locator('[data-slot="switch-control"]').click()
+  await expect(input).toHaveAttribute("aria-checked", "false")
+
+  await toggle.locator('[data-slot="switch-control"]').click()
+  await expect(input).toHaveAttribute("aria-checked", "true")
+
+  await closeDialog(page, settings)
+
+  await page.locator(promptSelector).click()
+  await page.keyboard.type("/model")
+  await expect(command).toBeVisible()
+  await command.hover()
+  await page.keyboard.press("Enter")
+
+  const pickerAgain = page.getByRole("dialog")
+  await expect(pickerAgain).toBeVisible()
+
+  await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toBeVisible()
+
+  await page.keyboard.press("Escape")
+  await expect(pickerAgain).toHaveCount(0)
+})

+ 121 - 15
packages/app/e2e/settings/settings-providers.spec.ts

@@ -1,30 +1,136 @@
 import { test, expect } from "../fixtures"
-import { promptSelector } from "../selectors"
-import { closeDialog, openSettings, clickListItem } from "../actions"
+import { closeDialog, openSettings } from "../actions"
 
-test("smoke providers settings opens provider selector", async ({ page, gotoSession }) => {
+test("custom provider form can be filled and validates input", async ({ page, gotoSession }) => {
   await gotoSession()
 
-  const dialog = await openSettings(page)
+  const settings = await openSettings(page)
+  await settings.getByRole("tab", { name: "Providers" }).click()
 
-  await dialog.getByRole("tab", { name: "Providers" }).click()
-  await expect(dialog.getByText("Connected providers", { exact: true })).toBeVisible()
-  await expect(dialog.getByText("Popular providers", { exact: true })).toBeVisible()
+  const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
+  await expect(customProviderSection).toBeVisible()
 
-  await dialog.getByRole("button", { name: "Show more providers" }).click()
+  const connectButton = customProviderSection.getByRole("button", { name: "Connect" })
+  await connectButton.click()
 
-  const providerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder("Search providers") })
+  const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
+  await expect(providerDialog).toBeVisible()
+
+  await providerDialog.getByLabel("Provider ID").fill("test-provider")
+  await providerDialog.getByLabel("Display name").fill("Test Provider")
+  await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/fake")
+  await providerDialog.getByLabel("API key").fill("fake-key")
+
+  await providerDialog.getByPlaceholder("model-id").first().fill("test-model")
+  await providerDialog.getByPlaceholder("Display Name").first().fill("Test Model")
+
+  await expect(providerDialog.getByRole("textbox", { name: "Provider ID" })).toHaveValue("test-provider")
+  await expect(providerDialog.getByRole("textbox", { name: "Display name" })).toHaveValue("Test Provider")
+  await expect(providerDialog.getByRole("textbox", { name: "Base URL" })).toHaveValue("http://localhost:9999/fake")
+  await expect(providerDialog.getByRole("textbox", { name: "API key" })).toHaveValue("fake-key")
+  await expect(providerDialog.getByPlaceholder("model-id").first()).toHaveValue("test-model")
+  await expect(providerDialog.getByPlaceholder("Display Name").first()).toHaveValue("Test Model")
+
+  await page.keyboard.press("Escape")
+  await expect(providerDialog).toHaveCount(0)
+
+  await closeDialog(page, settings)
+})
+
+test("custom provider form shows validation errors", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const settings = await openSettings(page)
+  await settings.getByRole("tab", { name: "Providers" }).click()
 
+  const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
+  await customProviderSection.getByRole("button", { name: "Connect" }).click()
+
+  const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
   await expect(providerDialog).toBeVisible()
-  await expect(providerDialog.getByPlaceholder("Search providers")).toBeVisible()
-  await expect(providerDialog.locator('[data-slot="list-item"]').first()).toBeVisible()
+
+  await providerDialog.getByLabel("Provider ID").fill("invalid provider id")
+  await providerDialog.getByLabel("Base URL").fill("not-a-url")
+
+  await providerDialog.getByRole("button", { name: /submit|save/i }).click()
+
+  await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /lowercase/i })).toBeVisible()
+  await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /http/i })).toBeVisible()
 
   await page.keyboard.press("Escape")
   await expect(providerDialog).toHaveCount(0)
-  await expect(page.locator(promptSelector)).toBeVisible()
 
-  const stillOpen = await dialog.isVisible().catch(() => false)
-  if (!stillOpen) return
+  await closeDialog(page, settings)
+})
+
+test("custom provider form can add and remove models", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const settings = await openSettings(page)
+  await settings.getByRole("tab", { name: "Providers" }).click()
+
+  const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
+  await customProviderSection.getByRole("button", { name: "Connect" }).click()
+
+  const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
+  await expect(providerDialog).toBeVisible()
+
+  await providerDialog.getByLabel("Provider ID").fill("multi-model-test")
+  await providerDialog.getByLabel("Display name").fill("Multi Model Test")
+  await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/multi")
+
+  await providerDialog.getByPlaceholder("model-id").first().fill("model-1")
+  await providerDialog.getByPlaceholder("Display Name").first().fill("Model 1")
+
+  const idInputsBefore = await providerDialog.getByPlaceholder("model-id").count()
+  await providerDialog.getByRole("button", { name: "Add model" }).click()
+  const idInputsAfter = await providerDialog.getByPlaceholder("model-id").count()
+  expect(idInputsAfter).toBe(idInputsBefore + 1)
+
+  await providerDialog.getByPlaceholder("model-id").nth(1).fill("model-2")
+  await providerDialog.getByPlaceholder("Display Name").nth(1).fill("Model 2")
+
+  await expect(providerDialog.getByPlaceholder("model-id").nth(1)).toHaveValue("model-2")
+  await expect(providerDialog.getByPlaceholder("Display Name").nth(1)).toHaveValue("Model 2")
+
+  await page.keyboard.press("Escape")
+  await expect(providerDialog).toHaveCount(0)
+
+  await closeDialog(page, settings)
+})
+
+test("custom provider form can add and remove headers", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const settings = await openSettings(page)
+  await settings.getByRole("tab", { name: "Providers" }).click()
+
+  const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
+  await customProviderSection.getByRole("button", { name: "Connect" }).click()
+
+  const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
+  await expect(providerDialog).toBeVisible()
+
+  await providerDialog.getByLabel("Provider ID").fill("header-test")
+  await providerDialog.getByLabel("Display name").fill("Header Test")
+  await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/headers")
+
+  await providerDialog.getByPlaceholder("model-id").first().fill("model-x")
+  await providerDialog.getByPlaceholder("Display Name").first().fill("Model X")
+
+  const headerInputsBefore = await providerDialog.getByPlaceholder("Header-Name").count()
+  await providerDialog.getByRole("button", { name: "Add header" }).click()
+  const headerInputsAfter = await providerDialog.getByPlaceholder("Header-Name").count()
+  expect(headerInputsAfter).toBe(headerInputsBefore + 1)
+
+  await providerDialog.getByPlaceholder("Header-Name").first().fill("Authorization")
+  await providerDialog.getByPlaceholder("value").first().fill("Bearer token123")
+
+  await expect(providerDialog.getByPlaceholder("Header-Name").first()).toHaveValue("Authorization")
+  await expect(providerDialog.getByPlaceholder("value").first()).toHaveValue("Bearer token123")
+
+  await page.keyboard.press("Escape")
+  await expect(providerDialog).toHaveCount(0)
 
-  await closeDialog(page, dialog)
+  await closeDialog(page, settings)
 })

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

@@ -1,5 +1,17 @@
-import { test, expect } from "../fixtures"
+import { test, expect, settingsKey } from "../fixtures"
 import { closeDialog, openSettings } from "../actions"
+import {
+  settingsColorSchemeSelector,
+  settingsFontSelector,
+  settingsLanguageSelectSelector,
+  settingsNotificationsAgentSelector,
+  settingsNotificationsErrorsSelector,
+  settingsNotificationsPermissionsSelector,
+  settingsReleaseNotesSelector,
+  settingsSoundsAgentSelector,
+  settingsThemeSelector,
+  settingsUpdatesStartupSelector,
+} from "../selectors"
 
 test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => {
   await gotoSession()
@@ -12,3 +24,269 @@ test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSe
 
   await closeDialog(page, dialog)
 })
+
+test("changing language updates settings labels", async ({ page, gotoSession }) => {
+  await page.addInitScript(() => {
+    localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" }))
+  })
+
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+
+  const heading = dialog.getByRole("heading", { level: 2 })
+  await expect(heading).toHaveText("General")
+
+  const select = dialog.locator(settingsLanguageSelectSelector)
+  await expect(select).toBeVisible()
+  await select.locator('[data-slot="select-select-trigger"]').click()
+
+  await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click()
+
+  await expect(heading).toHaveText("Allgemein")
+
+  await select.locator('[data-slot="select-select-trigger"]').click()
+  await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click()
+  await expect(heading).toHaveText("General")
+})
+
+test("changing color scheme persists in localStorage", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const select = dialog.locator(settingsColorSchemeSelector)
+  await expect(select).toBeVisible()
+
+  await select.locator('[data-slot="select-select-trigger"]').click()
+  await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
+
+  const colorScheme = await page.evaluate(() => {
+    return document.documentElement.getAttribute("data-color-scheme")
+  })
+  expect(colorScheme).toBe("dark")
+
+  await select.locator('[data-slot="select-select-trigger"]').click()
+  await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Light" }).click()
+
+  const lightColorScheme = await page.evaluate(() => {
+    return document.documentElement.getAttribute("data-color-scheme")
+  })
+  expect(lightColorScheme).toBe("light")
+})
+
+test("changing theme persists in localStorage", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const select = dialog.locator(settingsThemeSelector)
+  await expect(select).toBeVisible()
+
+  await select.locator('[data-slot="select-select-trigger"]').click()
+
+  const items = page.locator('[data-slot="select-select-item"]')
+  const count = await items.count()
+  expect(count).toBeGreaterThan(1)
+
+  const firstTheme = await items.nth(1).locator('[data-slot="select-select-item-label"]').textContent()
+  expect(firstTheme).toBeTruthy()
+
+  await items.nth(1).click()
+
+  await page.keyboard.press("Escape")
+
+  const storedThemeId = await page.evaluate(() => {
+    return localStorage.getItem("opencode-theme-id")
+  })
+
+  expect(storedThemeId).not.toBeNull()
+  expect(storedThemeId).not.toBe("oc-1")
+
+  const dataTheme = await page.evaluate(() => {
+    return document.documentElement.getAttribute("data-theme")
+  })
+  expect(dataTheme).toBe(storedThemeId)
+})
+
+test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const select = dialog.locator(settingsFontSelector)
+  await expect(select).toBeVisible()
+
+  const initialFontFamily = await page.evaluate(() => {
+    return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
+  })
+  expect(initialFontFamily).toContain("IBM Plex Mono")
+
+  await select.locator('[data-slot="select-select-trigger"]').click()
+
+  const items = page.locator('[data-slot="select-select-item"]')
+  await items.nth(2).click()
+
+  await page.waitForTimeout(100)
+
+  const stored = await page.evaluate((key) => {
+    const raw = localStorage.getItem(key)
+    return raw ? JSON.parse(raw) : null
+  }, settingsKey)
+
+  expect(stored?.appearance?.font).not.toBe("ibm-plex-mono")
+
+  const newFontFamily = await page.evaluate(() => {
+    return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
+  })
+  expect(newFontFamily).not.toBe(initialFontFamily)
+})
+
+test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const switchContainer = dialog.locator(settingsNotificationsAgentSelector)
+  await expect(switchContainer).toBeVisible()
+
+  const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
+  const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+  expect(initialState).toBe(true)
+
+  await switchContainer.locator('[data-slot="switch-control"]').click()
+  await page.waitForTimeout(100)
+
+  const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+  expect(newState).toBe(false)
+
+  const stored = await page.evaluate((key) => {
+    const raw = localStorage.getItem(key)
+    return raw ? JSON.parse(raw) : null
+  }, settingsKey)
+
+  expect(stored?.notifications?.agent).toBe(false)
+})
+
+test("toggling notification permissions switch updates localStorage", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const switchContainer = dialog.locator(settingsNotificationsPermissionsSelector)
+  await expect(switchContainer).toBeVisible()
+
+  const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
+  const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+  expect(initialState).toBe(true)
+
+  await switchContainer.locator('[data-slot="switch-control"]').click()
+  await page.waitForTimeout(100)
+
+  const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+  expect(newState).toBe(false)
+
+  const stored = await page.evaluate((key) => {
+    const raw = localStorage.getItem(key)
+    return raw ? JSON.parse(raw) : null
+  }, settingsKey)
+
+  expect(stored?.notifications?.permissions).toBe(false)
+})
+
+test("toggling notification errors switch updates localStorage", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const switchContainer = dialog.locator(settingsNotificationsErrorsSelector)
+  await expect(switchContainer).toBeVisible()
+
+  const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
+  const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+  expect(initialState).toBe(false)
+
+  await switchContainer.locator('[data-slot="switch-control"]').click()
+  await page.waitForTimeout(100)
+
+  const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+  expect(newState).toBe(true)
+
+  const stored = await page.evaluate((key) => {
+    const raw = localStorage.getItem(key)
+    return raw ? JSON.parse(raw) : null
+  }, settingsKey)
+
+  expect(stored?.notifications?.errors).toBe(true)
+})
+
+test("changing sound agent selection persists in localStorage", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const select = dialog.locator(settingsSoundsAgentSelector)
+  await expect(select).toBeVisible()
+
+  await select.locator('[data-slot="select-select-trigger"]').click()
+
+  const items = page.locator('[data-slot="select-select-item"]')
+  await items.nth(2).click()
+
+  const stored = await page.evaluate((key) => {
+    const raw = localStorage.getItem(key)
+    return raw ? JSON.parse(raw) : null
+  }, settingsKey)
+
+  expect(stored?.sounds?.agent).not.toBe("staplebops-01")
+})
+
+test("toggling updates startup switch updates localStorage", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const switchContainer = dialog.locator(settingsUpdatesStartupSelector)
+  await expect(switchContainer).toBeVisible()
+
+  const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
+
+  const isDisabled = await toggleInput.evaluate((el: HTMLInputElement) => el.disabled)
+  if (isDisabled) {
+    test.skip()
+    return
+  }
+
+  const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+  expect(initialState).toBe(true)
+
+  await switchContainer.locator('[data-slot="switch-control"]').click()
+  await page.waitForTimeout(100)
+
+  const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+  expect(newState).toBe(false)
+
+  const stored = await page.evaluate((key) => {
+    const raw = localStorage.getItem(key)
+    return raw ? JSON.parse(raw) : null
+  }, settingsKey)
+
+  expect(stored?.updates?.startup).toBe(false)
+})
+
+test("toggling release notes switch updates localStorage", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const dialog = await openSettings(page)
+  const switchContainer = dialog.locator(settingsReleaseNotesSelector)
+  await expect(switchContainer).toBeVisible()
+
+  const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
+  const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+  expect(initialState).toBe(true)
+
+  await switchContainer.locator('[data-slot="switch-control"]').click()
+  await page.waitForTimeout(100)
+
+  const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+  expect(newState).toBe(false)
+
+  const stored = await page.evaluate((key) => {
+    const raw = localStorage.getItem(key)
+    return raw ? JSON.parse(raw) : null
+  }, settingsKey)
+
+  expect(stored?.general?.releaseNotes).toBe(false)
+})

+ 94 - 0
packages/app/e2e/status/status-popover.spec.ts

@@ -0,0 +1,94 @@
+import { test, expect } from "../fixtures"
+import { openStatusPopover } from "../actions"
+
+test("status popover opens and shows tabs", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const { popoverBody } = await openStatusPopover(page)
+
+  await expect(popoverBody.getByRole("tab", { name: /servers/i })).toBeVisible()
+  await expect(popoverBody.getByRole("tab", { name: /mcp/i })).toBeVisible()
+  await expect(popoverBody.getByRole("tab", { name: /lsp/i })).toBeVisible()
+  await expect(popoverBody.getByRole("tab", { name: /plugins/i })).toBeVisible()
+
+  await page.keyboard.press("Escape")
+  await expect(popoverBody).toHaveCount(0)
+})
+
+test("status popover servers tab shows current server", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const { popoverBody } = await openStatusPopover(page)
+
+  const serversTab = popoverBody.getByRole("tab", { name: /servers/i })
+  await expect(serversTab).toHaveAttribute("aria-selected", "true")
+
+  const serverList = popoverBody.locator('[role="tabpanel"]').first()
+  await expect(serverList.locator("button").first()).toBeVisible()
+})
+
+test("status popover can switch to mcp tab", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const { popoverBody } = await openStatusPopover(page)
+
+  const mcpTab = popoverBody.getByRole("tab", { name: /mcp/i })
+  await mcpTab.click()
+
+  const ariaSelected = await mcpTab.getAttribute("aria-selected")
+  expect(ariaSelected).toBe("true")
+
+  const mcpContent = popoverBody.locator('[role="tabpanel"]:visible').first()
+  await expect(mcpContent).toBeVisible()
+})
+
+test("status popover can switch to lsp tab", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const { popoverBody } = await openStatusPopover(page)
+
+  const lspTab = popoverBody.getByRole("tab", { name: /lsp/i })
+  await lspTab.click()
+
+  const ariaSelected = await lspTab.getAttribute("aria-selected")
+  expect(ariaSelected).toBe("true")
+
+  const lspContent = popoverBody.locator('[role="tabpanel"]:visible').first()
+  await expect(lspContent).toBeVisible()
+})
+
+test("status popover can switch to plugins tab", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const { popoverBody } = await openStatusPopover(page)
+
+  const pluginsTab = popoverBody.getByRole("tab", { name: /plugins/i })
+  await pluginsTab.click()
+
+  const ariaSelected = await pluginsTab.getAttribute("aria-selected")
+  expect(ariaSelected).toBe("true")
+
+  const pluginsContent = popoverBody.locator('[role="tabpanel"]:visible').first()
+  await expect(pluginsContent).toBeVisible()
+})
+
+test("status popover closes on escape", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const { popoverBody } = await openStatusPopover(page)
+  await expect(popoverBody).toBeVisible()
+
+  await page.keyboard.press("Escape")
+  await expect(popoverBody).toHaveCount(0)
+})
+
+test("status popover closes when clicking outside", async ({ page, gotoSession }) => {
+  await gotoSession()
+
+  const { popoverBody } = await openStatusPopover(page)
+  await expect(popoverBody).toBeVisible()
+
+  await page.getByRole("main").click({ position: { x: 5, y: 5 } })
+
+  await expect(popoverBody).toHaveCount(0)
+})

+ 7 - 4
packages/app/package.json

@@ -1,11 +1,12 @@
 {
   "name": "@opencode-ai/app",
-  "version": "1.1.48",
+  "version": "1.1.53",
   "description": "",
   "type": "module",
   "exports": {
     ".": "./src/index.ts",
-    "./vite": "./vite.js"
+    "./vite": "./vite.js",
+    "./index.css": "./src/index.css"
   },
   "scripts": {
     "typecheck": "tsgo -b",
@@ -13,7 +14,9 @@
     "dev": "vite",
     "build": "vite build",
     "serve": "vite preview",
-    "test": "playwright test",
+    "test": "bun run test:unit",
+    "test:unit": "bun test --preload ./happydom.ts ./src",
+    "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
     "test:e2e": "playwright test",
     "test:e2e:local": "bun script/e2e-local.ts",
     "test:e2e:ui": "playwright test --ui",
@@ -54,7 +57,7 @@
     "@thisbeyond/solid-dnd": "0.7.5",
     "diff": "catalog:",
     "fuzzysort": "catalog:",
-    "ghostty-web": "0.3.0",
+    "ghostty-web": "0.4.0",
     "luxon": "catalog:",
     "marked": "catalog:",
     "marked-shiki": "catalog:",

+ 1 - 1
packages/app/src/addons/serialize.test.ts

@@ -36,7 +36,7 @@ function writeAndWait(term: Terminal, data: string): Promise<void> {
   })
 }
 
-describe.skip("SerializeAddon", () => {
+describe("SerializeAddon", () => {
   describe("ANSI color preservation", () => {
     test("should preserve text attributes (bold, italic, underline)", async () => {
       const { term, addon } = createTerminal()

+ 65 - 22
packages/app/src/addons/serialize.ts

@@ -56,6 +56,39 @@ interface IBufferCell {
   isDim(): boolean
 }
 
+type TerminalBuffers = {
+  active?: IBuffer
+  normal?: IBuffer
+  alternate?: IBuffer
+}
+
+const isRecord = (value: unknown): value is Record<string, unknown> => {
+  return typeof value === "object" && value !== null
+}
+
+const isBuffer = (value: unknown): value is IBuffer => {
+  if (!isRecord(value)) return false
+  if (typeof value.length !== "number") return false
+  if (typeof value.cursorX !== "number") return false
+  if (typeof value.cursorY !== "number") return false
+  if (typeof value.baseY !== "number") return false
+  if (typeof value.viewportY !== "number") return false
+  if (typeof value.getLine !== "function") return false
+  if (typeof value.getNullCell !== "function") return false
+  return true
+}
+
+const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined => {
+  if (!isRecord(value)) return
+  const raw = value.buffer
+  if (!isRecord(raw)) return
+  const active = isBuffer(raw.active) ? raw.active : undefined
+  const normal = isBuffer(raw.normal) ? raw.normal : undefined
+  const alternate = isBuffer(raw.alternate) ? raw.alternate : undefined
+  if (!active && !normal) return
+  return { active, normal, alternate }
+}
+
 // ============================================================================
 // Types
 // ============================================================================
@@ -241,19 +274,19 @@ class StringSerializeHandler extends BaseSerializeHandler {
   protected _rowEnd(row: number, isLastRow: boolean): void {
     let rowSeparator = ""
 
-    if (this._nullCellCount > 0) {
+    const nextLine = isLastRow ? undefined : this._buffer.getLine(row + 1)
+    const wrapped = !!nextLine?.isWrapped
+
+    if (this._nullCellCount > 0 && wrapped) {
       this._currentRow += " ".repeat(this._nullCellCount)
-      this._nullCellCount = 0
     }
 
-    if (!isLastRow) {
-      const nextLine = this._buffer.getLine(row + 1)
+    this._nullCellCount = 0
 
-      if (!nextLine?.isWrapped) {
-        rowSeparator = "\r\n"
-        this._lastCursorRow = row + 1
-        this._lastCursorCol = 0
-      }
+    if (!isLastRow && !wrapped) {
+      rowSeparator = "\r\n"
+      this._lastCursorRow = row + 1
+      this._lastCursorCol = 0
     }
 
     this._allRows[this._rowIndex] = this._currentRow
@@ -389,7 +422,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
 
     const sgrSeq = this._diffStyle(cell, this._cursorStyle)
 
-    const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0
+    const styleChanged = sgrSeq.length > 0
 
     if (styleChanged) {
       if (this._nullCellCount > 0) {
@@ -442,12 +475,24 @@ class StringSerializeHandler extends BaseSerializeHandler {
       }
     }
 
-    if (!excludeFinalCursorPosition) {
-      const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
-      const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
-      const cursorCol = this._buffer.cursorX + 1
-      content += `\u001b[${cursorRow};${cursorCol}H`
-    }
+    if (excludeFinalCursorPosition) return content
+
+    const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
+    const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
+    const cursorCol = this._buffer.cursorX + 1
+    content += `\u001b[${cursorRow};${cursorCol}H`
+
+    const line = this._buffer.getLine(absoluteCursorRow)
+    const cell = line?.getCell(this._buffer.cursorX)
+    const style = (() => {
+      if (!cell) return this._buffer.getNullCell()
+      if (cell.getWidth() !== 0) return cell
+      if (this._buffer.cursorX > 0) return line?.getCell(this._buffer.cursorX - 1) ?? cell
+      return cell
+    })()
+
+    const sgrSeq = this._diffStyle(style, this._cursorStyle)
+    if (sgrSeq.length) content += `\u001b[${sgrSeq.join(";")}m`
 
     return content
   }
@@ -486,14 +531,13 @@ export class SerializeAddon implements ITerminalAddon {
       throw new Error("Cannot use addon until it has been loaded")
     }
 
-    const terminal = this._terminal as any
-    const buffer = terminal.buffer
+    const buffer = getTerminalBuffers(this._terminal)
 
     if (!buffer) {
       return ""
     }
 
-    const normalBuffer = buffer.normal || buffer.active
+    const normalBuffer = buffer.normal ?? buffer.active
     const altBuffer = buffer.alternate
 
     if (!normalBuffer) {
@@ -521,14 +565,13 @@ export class SerializeAddon implements ITerminalAddon {
       throw new Error("Cannot use addon until it has been loaded")
     }
 
-    const terminal = this._terminal as any
-    const buffer = terminal.buffer
+    const buffer = getTerminalBuffers(this._terminal)
 
     if (!buffer) {
       return ""
     }
 
-    const activeBuffer = buffer.active || buffer.normal
+    const activeBuffer = buffer.active ?? buffer.normal
     if (!activeBuffer) {
       return ""
     }

+ 7 - 4
packages/app/src/app.tsx

@@ -30,7 +30,7 @@ import { HighlightsProvider } from "@/context/highlights"
 import Layout from "@/pages/layout"
 import DirectoryLayout from "@/pages/directory-layout"
 import { ErrorPage } from "./pages/error"
-import { Suspense } from "solid-js"
+import { Suspense, JSX } from "solid-js"
 
 const Home = lazy(() => import("@/pages/home"))
 const Session = lazy(() => import("@/pages/session"))
@@ -84,7 +84,7 @@ function ServerKey(props: ParentProps) {
   )
 }
 
-export function AppInterface(props: { defaultUrl?: string }) {
+export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element }) {
   const platform = usePlatform()
 
   const stored = (() => {
@@ -111,7 +111,7 @@ export function AppInterface(props: { defaultUrl?: string }) {
         <GlobalSDKProvider>
           <GlobalSyncProvider>
             <Router
-              root={(props) => (
+              root={(routerProps) => (
                 <SettingsProvider>
                   <PermissionProvider>
                     <LayoutProvider>
@@ -119,7 +119,10 @@ export function AppInterface(props: { defaultUrl?: string }) {
                         <ModelsProvider>
                           <CommandProvider>
                             <HighlightsProvider>
-                              <Layout>{props.children}</Layout>
+                              <Layout>
+                                {props.children}
+                                {routerProps.children}
+                              </Layout>
                             </HighlightsProvider>
                           </CommandProvider>
                         </ModelsProvider>

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

@@ -124,16 +124,16 @@ export function DialogCustomProvider(props: Props) {
     const key = apiKey && !env ? apiKey : undefined
 
     const idError = !providerID
-      ? "Provider ID is required"
+      ? language.t("provider.custom.error.providerID.required")
       : !PROVIDER_ID.test(providerID)
-        ? "Use lowercase letters, numbers, hyphens, or underscores"
+        ? language.t("provider.custom.error.providerID.format")
         : undefined
 
-    const nameError = !name ? "Display name is required" : undefined
+    const nameError = !name ? language.t("provider.custom.error.name.required") : undefined
     const urlError = !baseURL
-      ? "Base URL is required"
+      ? language.t("provider.custom.error.baseURL.required")
       : !/^https?:\/\//.test(baseURL)
-        ? "Must start with http:// or https://"
+        ? language.t("provider.custom.error.baseURL.format")
         : undefined
 
     const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID)
@@ -141,21 +141,21 @@ export function DialogCustomProvider(props: Props) {
     const existsError = idError
       ? undefined
       : existingProvider && !disabled
-        ? "That provider ID already exists"
+        ? language.t("provider.custom.error.providerID.exists")
         : undefined
 
     const seenModels = new Set<string>()
     const modelErrors = form.models.map((m) => {
       const id = m.id.trim()
       const modelIdError = !id
-        ? "Required"
+        ? language.t("provider.custom.error.required")
         : seenModels.has(id)
-          ? "Duplicate"
+          ? language.t("provider.custom.error.duplicate")
           : (() => {
               seenModels.add(id)
               return undefined
             })()
-      const modelNameError = !m.name.trim() ? "Required" : undefined
+      const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined
       return { id: modelIdError, name: modelNameError }
     })
     const modelsValid = modelErrors.every((m) => !m.id && !m.name)
@@ -168,14 +168,14 @@ export function DialogCustomProvider(props: Props) {
 
       if (!key && !value) return {}
       const keyError = !key
-        ? "Required"
+        ? language.t("provider.custom.error.required")
         : seenHeaders.has(key.toLowerCase())
-          ? "Duplicate"
+          ? language.t("provider.custom.error.duplicate")
           : (() => {
               seenHeaders.add(key.toLowerCase())
               return undefined
             })()
-      const valueError = !value ? "Required" : undefined
+      const valueError = !value ? language.t("provider.custom.error.required") : undefined
       return { key: keyError, value: valueError }
     })
     const headersValid = headerErrors.every((h) => !h.key && !h.value)
@@ -278,64 +278,64 @@ export function DialogCustomProvider(props: Props) {
       <div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
         <div class="px-2.5 flex gap-4 items-center">
           <ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
-          <div class="text-16-medium text-text-strong">Custom provider</div>
+          <div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
         </div>
 
         <form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
           <p class="text-14-regular text-text-base">
-            Configure an OpenAI-compatible provider. See the{" "}
+            {language.t("provider.custom.description.prefix")}
             <Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
-              provider config docs
+              {language.t("provider.custom.description.link")}
             </Link>
-            .
+            {language.t("provider.custom.description.suffix")}
           </p>
 
           <div class="flex flex-col gap-4">
             <TextField
               autofocus
-              label="Provider ID"
-              placeholder="myprovider"
-              description="Lowercase letters, numbers, hyphens, or underscores"
+              label={language.t("provider.custom.field.providerID.label")}
+              placeholder={language.t("provider.custom.field.providerID.placeholder")}
+              description={language.t("provider.custom.field.providerID.description")}
               value={form.providerID}
               onChange={setForm.bind(null, "providerID")}
               validationState={errors.providerID ? "invalid" : undefined}
               error={errors.providerID}
             />
             <TextField
-              label="Display name"
-              placeholder="My AI Provider"
+              label={language.t("provider.custom.field.name.label")}
+              placeholder={language.t("provider.custom.field.name.placeholder")}
               value={form.name}
               onChange={setForm.bind(null, "name")}
               validationState={errors.name ? "invalid" : undefined}
               error={errors.name}
             />
             <TextField
-              label="Base URL"
-              placeholder="https://api.myprovider.com/v1"
+              label={language.t("provider.custom.field.baseURL.label")}
+              placeholder={language.t("provider.custom.field.baseURL.placeholder")}
               value={form.baseURL}
               onChange={setForm.bind(null, "baseURL")}
               validationState={errors.baseURL ? "invalid" : undefined}
               error={errors.baseURL}
             />
             <TextField
-              label="API key"
-              placeholder="API key"
-              description="Optional. Leave empty if you manage auth via headers."
+              label={language.t("provider.custom.field.apiKey.label")}
+              placeholder={language.t("provider.custom.field.apiKey.placeholder")}
+              description={language.t("provider.custom.field.apiKey.description")}
               value={form.apiKey}
               onChange={setForm.bind(null, "apiKey")}
             />
           </div>
 
           <div class="flex flex-col gap-3">
-            <label class="text-12-medium text-text-weak">Models</label>
+            <label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
             <For each={form.models}>
               {(m, i) => (
                 <div class="flex gap-2 items-start">
                   <div class="flex-1">
                     <TextField
-                      label="ID"
+                      label={language.t("provider.custom.models.id.label")}
                       hideLabel
-                      placeholder="model-id"
+                      placeholder={language.t("provider.custom.models.id.placeholder")}
                       value={m.id}
                       onChange={(v) => setForm("models", i(), "id", v)}
                       validationState={errors.models[i()]?.id ? "invalid" : undefined}
@@ -344,9 +344,9 @@ export function DialogCustomProvider(props: Props) {
                   </div>
                   <div class="flex-1">
                     <TextField
-                      label="Name"
+                      label={language.t("provider.custom.models.name.label")}
                       hideLabel
-                      placeholder="Display Name"
+                      placeholder={language.t("provider.custom.models.name.placeholder")}
                       value={m.name}
                       onChange={(v) => setForm("models", i(), "name", v)}
                       validationState={errors.models[i()]?.name ? "invalid" : undefined}
@@ -360,26 +360,26 @@ export function DialogCustomProvider(props: Props) {
                     class="mt-1.5"
                     onClick={() => removeModel(i())}
                     disabled={form.models.length <= 1}
-                    aria-label="Remove model"
+                    aria-label={language.t("provider.custom.models.remove")}
                   />
                 </div>
               )}
             </For>
             <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
-              Add model
+              {language.t("provider.custom.models.add")}
             </Button>
           </div>
 
           <div class="flex flex-col gap-3">
-            <label class="text-12-medium text-text-weak">Headers (optional)</label>
+            <label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
             <For each={form.headers}>
               {(h, i) => (
                 <div class="flex gap-2 items-start">
                   <div class="flex-1">
                     <TextField
-                      label="Header"
+                      label={language.t("provider.custom.headers.key.label")}
                       hideLabel
-                      placeholder="Header-Name"
+                      placeholder={language.t("provider.custom.headers.key.placeholder")}
                       value={h.key}
                       onChange={(v) => setForm("headers", i(), "key", v)}
                       validationState={errors.headers[i()]?.key ? "invalid" : undefined}
@@ -388,9 +388,9 @@ export function DialogCustomProvider(props: Props) {
                   </div>
                   <div class="flex-1">
                     <TextField
-                      label="Value"
+                      label={language.t("provider.custom.headers.value.label")}
                       hideLabel
-                      placeholder="value"
+                      placeholder={language.t("provider.custom.headers.value.placeholder")}
                       value={h.value}
                       onChange={(v) => setForm("headers", i(), "value", v)}
                       validationState={errors.headers[i()]?.value ? "invalid" : undefined}
@@ -404,18 +404,18 @@ export function DialogCustomProvider(props: Props) {
                     class="mt-1.5"
                     onClick={() => removeHeader(i())}
                     disabled={form.headers.length <= 1}
-                    aria-label="Remove header"
+                    aria-label={language.t("provider.custom.headers.remove")}
                   />
                 </div>
               )}
             </For>
             <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
-              Add header
+              {language.t("provider.custom.headers.add")}
             </Button>
           </div>
 
           <Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
-            {form.saving ? "Saving..." : language.t("common.submit")}
+            {form.saving ? language.t("common.saving") : language.t("common.submit")}
           </Button>
         </form>
       </div>

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

@@ -158,22 +158,22 @@ export function DialogEditProject(props: { project: LocalProject }) {
                   </Show>
                 </div>
                 <div
-                  class="absolute inset-0 size-16 bg-black/60 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
+                  class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
                   classList={{
                     "opacity-100": store.iconHover && !store.iconUrl,
                     "opacity-0": !(store.iconHover && !store.iconUrl),
                   }}
                 >
-                  <Icon name="cloud-upload" size="large" class="text-icon-invert-base" />
+                  <Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
                 </div>
                 <div
-                  class="absolute inset-0 size-16 bg-black/60 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
+                  class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
                   classList={{
                     "opacity-100": store.iconHover && !!store.iconUrl,
                     "opacity-0": !(store.iconHover && !!store.iconUrl),
                   }}
                 >
-                  <Icon name="trash" size="large" class="text-icon-invert-base" />
+                  <Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
                 </div>
               </div>
               <input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
@@ -223,7 +223,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
             value={store.startup}
             onChange={(v) => setStore("startup", v)}
             spellcheck={false}
-            class="max-h-40 w-full font-mono text-xs no-scrollbar"
+            class="max-h-14 w-full overflow-y-auto font-mono text-xs"
           />
         </div>
 

+ 150 - 32
packages/app/src/components/dialog-select-directory.tsx

@@ -4,10 +4,11 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { List } from "@opencode-ai/ui/list"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import fuzzysort from "fuzzysort"
-import { createMemo } from "solid-js"
+import { createMemo, createResource, createSignal } from "solid-js"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { useGlobalSync } from "@/context/global-sync"
 import { useLanguage } from "@/context/language"
+import type { ListRef } from "@opencode-ai/ui/list"
 
 interface DialogSelectDirectoryProps {
   title?: string
@@ -15,18 +16,47 @@ interface DialogSelectDirectoryProps {
   onSelect: (result: string | string[] | null) => void
 }
 
+type Row = {
+  absolute: string
+  search: string
+}
+
 export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
   const sync = useGlobalSync()
   const sdk = useGlobalSDK()
   const dialog = useDialog()
   const language = useLanguage()
 
-  const home = createMemo(() => sync.data.path.home)
+  const [filter, setFilter] = createSignal("")
+
+  let list: ListRef | undefined
+
+  const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
+
+  const [fallbackPath] = createResource(
+    () => (missingBase() ? true : undefined),
+    async () => {
+      return sdk.client.path
+        .get()
+        .then((x) => x.data)
+        .catch(() => undefined)
+    },
+    { initialValue: undefined },
+  )
+
+  const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
 
-  const start = createMemo(() => sync.data.path.home || sync.data.path.directory)
+  const start = createMemo(
+    () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
+  )
 
   const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
 
+  const clean = (value: string) => {
+    const first = (value ?? "").split(/\r?\n/)[0] ?? ""
+    return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
+  }
+
   function normalize(input: string) {
     const v = input.replaceAll("\\", "/")
     if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
@@ -64,24 +94,67 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
     return ""
   }
 
-  function display(path: string) {
+  function parentOf(input: string) {
+    const v = trimTrailing(input)
+    if (v === "/") return v
+    if (v === "//") return v
+    if (/^[A-Za-z]:\/$/.test(v)) return v
+
+    const i = v.lastIndexOf("/")
+    if (i <= 0) return "/"
+    if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
+    return v.slice(0, i)
+  }
+
+  function modeOf(input: string) {
+    const raw = normalizeDriveRoot(input.trim())
+    if (!raw) return "relative" as const
+    if (raw.startsWith("~")) return "tilde" as const
+    if (rootOf(raw)) return "absolute" as const
+    return "relative" as const
+  }
+
+  function display(path: string, input: string) {
     const full = trimTrailing(path)
+    if (modeOf(input) === "absolute") return full
+
+    return tildeOf(full) || full
+  }
+
+  function tildeOf(absolute: string) {
+    const full = trimTrailing(absolute)
     const h = home()
-    if (!h) return full
+    if (!h) return ""
 
     const hn = trimTrailing(h)
     const lc = full.toLowerCase()
     const hc = hn.toLowerCase()
     if (lc === hc) return "~"
     if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
-    return full
+    return ""
+  }
+
+  function row(absolute: string): Row {
+    const full = trimTrailing(absolute)
+    const tilde = tildeOf(full)
+
+    const withSlash = (value: string) => {
+      if (!value) return ""
+      if (value.endsWith("/")) return value
+      return value + "/"
+    }
+
+    const search = Array.from(
+      new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
+    ).join("\n")
+    return { absolute: full, search }
   }
 
-  function scoped(filter: string) {
+  function scoped(value: string) {
     const base = start()
     if (!base) return
 
-    const raw = normalizeDriveRoot(filter.trim())
+    const raw = normalizeDriveRoot(value)
     if (!raw) return { directory: trimTrailing(base), path: "" }
 
     const h = home()
@@ -122,21 +195,25 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
   }
 
   const directories = async (filter: string) => {
-    const input = scoped(filter)
-    if (!input) return [] as string[]
+    const value = clean(filter)
+    const scopedInput = scoped(value)
+    if (!scopedInput) return [] as string[]
 
-    const raw = normalizeDriveRoot(filter.trim())
+    const raw = normalizeDriveRoot(value)
     const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
 
-    const query = normalizeDriveRoot(input.path)
+    const query = normalizeDriveRoot(scopedInput.path)
 
-    if (!isPath) {
-      const results = await sdk.client.find
-        .files({ directory: input.directory, query, type: "directory", limit: 50 })
+    const find = () =>
+      sdk.client.find
+        .files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
         .then((x) => x.data ?? [])
         .catch(() => [])
 
-      return results.map((rel) => join(input.directory, rel)).slice(0, 50)
+    if (!isPath) {
+      const results = await find()
+
+      return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50)
     }
 
     const segments = query.replace(/^\/+/, "").split("/")
@@ -145,17 +222,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
 
     const cap = 12
     const branch = 4
-    let paths = [input.directory]
+    let paths = [scopedInput.directory]
     for (const part of head) {
       if (part === "..") {
-        paths = paths.map((p) => {
-          const v = trimTrailing(p)
-          if (v === "/") return v
-          if (/^[A-Za-z]:\/$/.test(v)) return v
-          const i = v.lastIndexOf("/")
-          if (i <= 0) return "/"
-          return v.slice(0, i)
-        })
+        paths = paths.map(parentOf)
         continue
       }
 
@@ -165,7 +235,27 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
     }
 
     const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
-    return Array.from(new Set(out)).slice(0, 50)
+    const deduped = Array.from(new Set(out))
+    const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
+    const expand = !raw.endsWith("/")
+    if (!expand || !tail) {
+      const items = base ? Array.from(new Set([base, ...deduped])) : deduped
+      return items.slice(0, 50)
+    }
+
+    const needle = tail.toLowerCase()
+    const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle)
+    const target = exact[0]
+    if (!target) return deduped.slice(0, 50)
+
+    const children = await match(target, "", 30)
+    const items = Array.from(new Set([...deduped, ...children]))
+    return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
+  }
+
+  const items = async (value: string) => {
+    const results = await directories(value)
+    return results.map(row)
   }
 
   function resolve(absolute: string) {
@@ -179,24 +269,52 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
         search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
         emptyMessage={language.t("dialog.directory.empty")}
         loadingMessage={language.t("common.loading")}
-        items={directories}
-        key={(x) => x}
+        items={items}
+        key={(x) => x.absolute}
+        filterKeys={["search"]}
+        ref={(r) => (list = r)}
+        onFilter={(value) => setFilter(clean(value))}
+        onKeyEvent={(e, item) => {
+          if (e.key !== "Tab") return
+          if (e.shiftKey) return
+          if (!item) return
+
+          e.preventDefault()
+          e.stopPropagation()
+
+          const value = display(item.absolute, filter())
+          list?.setFilter(value.endsWith("/") ? value : value + "/")
+        }}
         onSelect={(path) => {
           if (!path) return
-          resolve(path)
+          resolve(path.absolute)
         }}
       >
-        {(absolute) => {
-          const path = display(absolute)
+        {(item) => {
+          const path = display(item.absolute, filter())
+          if (path === "~") {
+            return (
+              <div class="w-full flex items-center justify-between rounded-md">
+                <div class="flex items-center gap-x-3 grow min-w-0">
+                  <FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
+                  <div class="flex items-center text-14-regular min-w-0">
+                    <span class="text-text-strong whitespace-nowrap">~</span>
+                    <span class="text-text-weak whitespace-nowrap">/</span>
+                  </div>
+                </div>
+              </div>
+            )
+          }
           return (
             <div class="w-full flex items-center justify-between rounded-md">
               <div class="flex items-center gap-x-3 grow min-w-0">
-                <FileIcon node={{ path: absolute, type: "directory" }} class="shrink-0 size-4" />
+                <FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
                 <div class="flex items-center text-14-regular min-w-0">
                   <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
                     {getDirectory(path)}
                   </span>
                   <span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
+                  <span class="text-text-weak whitespace-nowrap">/</span>
                 </div>
               </div>
             </div>

+ 180 - 18
packages/app/src/components/dialog-select-file.tsx

@@ -1,17 +1,23 @@
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { Icon } from "@opencode-ai/ui/icon"
 import { Keybind } from "@opencode-ai/ui/keybind"
 import { List } from "@opencode-ai/ui/list"
+import { base64Encode } from "@opencode-ai/util/encode"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { useParams } from "@solidjs/router"
-import { createMemo, createSignal, onCleanup, Show } from "solid-js"
+import { useNavigate, useParams } from "@solidjs/router"
+import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
 import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { useGlobalSync } from "@/context/global-sync"
 import { useLayout } from "@/context/layout"
 import { useFile } from "@/context/file"
 import { useLanguage } from "@/context/language"
+import { decode64 } from "@/utils/base64"
+import { getRelativeTime } from "@/utils/time"
 
-type EntryType = "command" | "file"
+type EntryType = "command" | "file" | "session"
 
 type Entry = {
   id: string
@@ -22,6 +28,10 @@ type Entry = {
   category: string
   option?: CommandOption
   path?: string
+  directory?: string
+  sessionID?: string
+  archived?: number
+  updated?: number
 }
 
 type DialogSelectFileMode = "all" | "files"
@@ -33,9 +43,13 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
   const file = useFile()
   const dialog = useDialog()
   const params = useParams()
+  const navigate = useNavigate()
+  const globalSDK = useGlobalSDK()
+  const globalSync = useGlobalSync()
   const filesOnly = () => props.mode === "files"
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const tabs = createMemo(() => layout.tabs(sessionKey))
+  const view = createMemo(() => layout.view(sessionKey))
   const state = { cleanup: undefined as (() => void) | void, committed: false }
   const [grouped, setGrouped] = createSignal(false)
   const common = [
@@ -73,6 +87,54 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
     path,
   })
 
+  const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
+  const project = createMemo(() => {
+    const directory = projectDirectory()
+    if (!directory) return
+    return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
+  })
+  const workspaces = createMemo(() => {
+    const directory = projectDirectory()
+    const current = project()
+    if (!current) return directory ? [directory] : []
+
+    const dirs = [current.worktree, ...(current.sandboxes ?? [])]
+    if (directory && !dirs.includes(directory)) return [...dirs, directory]
+    return dirs
+  })
+  const homedir = createMemo(() => globalSync.data.path.home)
+  const label = (directory: string) => {
+    const current = project()
+    const kind =
+      current && directory === current.worktree
+        ? language.t("workspace.type.local")
+        : language.t("workspace.type.sandbox")
+    const [store] = globalSync.child(directory, { bootstrap: false })
+    const home = homedir()
+    const path = home ? directory.replace(home, "~") : directory
+    const name = store.vcs?.branch ?? getFilename(directory)
+    return `${kind} : ${name || path}`
+  }
+
+  const sessionItem = (input: {
+    directory: string
+    id: string
+    title: string
+    description: string
+    archived?: number
+    updated?: number
+  }): Entry => ({
+    id: `session:${input.directory}:${input.id}`,
+    type: "session",
+    title: input.title,
+    description: input.description,
+    category: language.t("command.category.session"),
+    directory: input.directory,
+    sessionID: input.id,
+    archived: input.archived,
+    updated: input.updated,
+  })
+
   const list = createMemo(() => allowed().map(commandItem))
 
   const picks = createMemo(() => {
@@ -122,6 +184,69 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
     return out
   }
 
+  const sessionToken = { value: 0 }
+  let sessionInflight: Promise<Entry[]> | undefined
+  let sessionAll: Entry[] | undefined
+
+  const sessions = (text: string) => {
+    const query = text.trim()
+    if (!query) {
+      sessionToken.value += 1
+      sessionInflight = undefined
+      sessionAll = undefined
+      return [] as Entry[]
+    }
+
+    if (sessionAll) return sessionAll
+    if (sessionInflight) return sessionInflight
+
+    const current = sessionToken.value
+    const dirs = workspaces()
+    if (dirs.length === 0) return [] as Entry[]
+
+    sessionInflight = Promise.all(
+      dirs.map((directory) => {
+        const description = label(directory)
+        return globalSDK.client.session
+          .list({ directory, roots: true })
+          .then((x) =>
+            (x.data ?? [])
+              .filter((s) => !!s?.id)
+              .map((s) => ({
+                id: s.id,
+                title: s.title ?? language.t("command.session.new"),
+                description,
+                directory,
+                archived: s.time?.archived,
+                updated: s.time?.updated,
+              })),
+          )
+          .catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
+      }),
+    )
+      .then((results) => {
+        if (sessionToken.value !== current) return [] as Entry[]
+        const seen = new Set<string>()
+        const next = results
+          .flat()
+          .filter((item) => {
+            const key = `${item.directory}:${item.id}`
+            if (seen.has(key)) return false
+            seen.add(key)
+            return true
+          })
+          .map(sessionItem)
+        sessionAll = next
+        return next
+      })
+      .catch(() => [] as Entry[])
+      .finally(() => {
+        sessionInflight = undefined
+      })
+
+    return sessionInflight
+  }
+
   const items = async (text: string) => {
     const query = text.trim()
     setGrouped(query.length > 0)
@@ -146,9 +271,10 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
       const files = await file.searchFiles(query)
       return files.map(fileItem)
     }
-    const files = await file.searchFiles(query)
+
+    const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
     const entries = files.map(fileItem)
-    return [...list(), ...entries]
+    return [...list(), ...nextSessions, ...entries]
   }
 
   const handleMove = (item: Entry | undefined) => {
@@ -162,6 +288,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
     const value = file.tab(path)
     tabs().open(value)
     file.load(path)
+    if (!view().reviewPanel.opened()) view().reviewPanel.open()
     layout.fileTree.open()
     layout.fileTree.setTab("all")
     props.onOpenFile?.(path)
@@ -178,6 +305,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
       return
     }
 
+    if (item.type === "session") {
+      if (!item.directory || !item.sessionID) return
+      navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
+      return
+    }
+
     if (!item.path) return
     open(item.path)
   }
@@ -202,13 +335,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
         items={items}
         key={(item) => item.id}
         filterKeys={["title", "description", "category"]}
-        groupBy={(item) => item.category}
+        groupBy={grouped() ? (item) => item.category : () => ""}
         onMove={handleMove}
         onSelect={handleSelect}
       >
         {(item) => (
-          <Show
-            when={item.type === "command"}
+          <Switch
             fallback={
               <div class="w-full flex items-center justify-between rounded-md pl-1">
                 <div class="flex items-center gap-x-3 grow min-w-0">
@@ -223,18 +355,48 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
               </div>
             }
           >
-            <div class="w-full flex items-center justify-between gap-4">
-              <div class="flex items-center gap-2 min-w-0">
-                <span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
-                <Show when={item.description}>
-                  <span class="text-14-regular text-text-weak truncate">{item.description}</span>
+            <Match when={item.type === "command"}>
+              <div class="w-full flex items-center justify-between gap-4">
+                <div class="flex items-center gap-2 min-w-0">
+                  <span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
+                  <Show when={item.description}>
+                    <span class="text-14-regular text-text-weak truncate">{item.description}</span>
+                  </Show>
+                </div>
+                <Show when={item.keybind}>
+                  <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
+                </Show>
+              </div>
+            </Match>
+            <Match when={item.type === "session"}>
+              <div class="w-full flex items-center justify-between rounded-md pl-1">
+                <div class="flex items-center gap-x-3 grow min-w-0">
+                  <Icon name="bubble-5" size="small" class="shrink-0 text-icon-weak" />
+                  <div class="flex items-center gap-2 min-w-0">
+                    <span
+                      class="text-14-regular text-text-strong truncate"
+                      classList={{ "opacity-70": !!item.archived }}
+                    >
+                      {item.title}
+                    </span>
+                    <Show when={item.description}>
+                      <span
+                        class="text-14-regular text-text-weak truncate"
+                        classList={{ "opacity-70": !!item.archived }}
+                      >
+                        {item.description}
+                      </span>
+                    </Show>
+                  </div>
+                </div>
+                <Show when={item.updated}>
+                  <span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
+                    {getRelativeTime(new Date(item.updated!).toISOString())}
+                  </span>
                 </Show>
               </div>
-              <Show when={item.keybind}>
-                <Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
-              </Show>
-            </div>
-          </Show>
+            </Match>
+          </Switch>
         )}
       </List>
     </Dialog>

+ 8 - 11
packages/app/src/components/dialog-select-model.tsx

@@ -54,7 +54,6 @@ const ModelList: Component<{
           class="w-full"
           placement="right-start"
           gutter={12}
-          forceMount={false}
           value={
             <ModelTooltip
               model={item}
@@ -88,11 +87,13 @@ const ModelList: Component<{
   )
 }
 
-export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
+type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
+
+export function ModelSelectorPopover(props: {
   provider?: string
   children?: JSX.Element
-  triggerAs?: T
-  triggerProps?: ComponentProps<T>
+  triggerAs?: ValidComponent
+  triggerProps?: ModelSelectorTriggerProps
 }) {
   const [store, setStore] = createStore<{
     open: boolean
@@ -177,11 +178,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
       placement="top-start"
       gutter={8}
     >
-      <Kobalte.Trigger
-        ref={(el) => setStore("trigger", el)}
-        as={props.triggerAs ?? "div"}
-        {...(props.triggerProps as any)}
-      >
+      <Kobalte.Trigger ref={(el) => setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}>
         {props.children}
       </Kobalte.Trigger>
       <Kobalte.Portal>
@@ -214,7 +211,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
             class="p-1"
             action={
               <div class="flex items-center gap-1">
-                <Tooltip placement="top" forceMount={false} value={language.t("command.provider.connect")}>
+                <Tooltip placement="top" value={language.t("command.provider.connect")}>
                   <IconButton
                     icon="plus-small"
                     variant="ghost"
@@ -224,7 +221,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
                     onClick={handleConnectProvider}
                   />
                 </Tooltip>
-                <Tooltip placement="top" forceMount={false} value={language.t("dialog.model.manage")}>
+                <Tooltip placement="top" value={language.t("dialog.model.manage")}>
                   <IconButton
                     icon="sliders"
                     variant="ghost"

+ 23 - 78
packages/app/src/components/dialog-select-server.tsx

@@ -1,4 +1,4 @@
-import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } from "solid-js"
+import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
 import { createStore, reconcile } from "solid-js/store"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
@@ -6,17 +6,15 @@ import { List } from "@opencode-ai/ui/list"
 import { Button } from "@opencode-ai/ui/button"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { TextField } from "@opencode-ai/ui/text-field"
-import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
+import { normalizeServerUrl, useServer } from "@/context/server"
 import { usePlatform } from "@/context/platform"
-import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
 import { useNavigate } from "@solidjs/router"
 import { useLanguage } from "@/context/language"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { showToast } from "@opencode-ai/ui/toast"
-
-type ServerStatus = { healthy: boolean; version?: string }
+import { ServerRow } from "@/components/server/server-row"
+import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
 
 interface AddRowProps {
   value: string
@@ -40,19 +38,6 @@ interface EditRowProps {
   onBlur: () => void
 }
 
-async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
-  const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
-  const sdk = createOpencodeClient({
-    baseUrl: url,
-    fetch: platform.fetch,
-    signal,
-  })
-  return sdk.global
-    .health()
-    .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
-    .catch(() => ({ healthy: false }))
-}
-
 function AddRow(props: AddRowProps) {
   return (
     <div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
@@ -131,7 +116,7 @@ export function DialogSelectServer() {
   const globalSDK = useGlobalSDK()
   const language = useLanguage()
   const [store, setStore] = createStore({
-    status: {} as Record<string, ServerStatus | undefined>,
+    status: {} as Record<string, ServerHealth | undefined>,
     addServer: {
       url: "",
       adding: false,
@@ -165,6 +150,7 @@ export function DialogSelectServer() {
     { initialValue: null },
   )
   const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
+  const fetcher = platform.fetch ?? globalThis.fetch
 
   const looksComplete = (value: string) => {
     const normalized = normalizeServerUrl(value)
@@ -180,7 +166,7 @@ export function DialogSelectServer() {
     if (!looksComplete(value)) return
     const normalized = normalizeServerUrl(value)
     if (!normalized) return
-    const result = await checkHealth(normalized, platform)
+    const result = await checkServerHealth(normalized, fetcher)
     setStatus(result.healthy)
   }
 
@@ -227,7 +213,7 @@ export function DialogSelectServer() {
     if (!list.length) return list
     const active = current()
     const order = new Map(list.map((url, index) => [url, index] as const))
-    const rank = (value?: ServerStatus) => {
+    const rank = (value?: ServerHealth) => {
       if (value?.healthy === true) return 0
       if (value?.healthy === false) return 2
       return 1
@@ -242,10 +228,10 @@ export function DialogSelectServer() {
   })
 
   async function refreshHealth() {
-    const results: Record<string, ServerStatus> = {}
+    const results: Record<string, ServerHealth> = {}
     await Promise.all(
       items().map(async (url) => {
-        results[url] = await checkHealth(url, platform)
+        results[url] = await checkServerHealth(url, fetcher)
       }),
     )
     setStore("status", reconcile(results))
@@ -300,7 +286,7 @@ export function DialogSelectServer() {
 
     setStore("addServer", { adding: true, error: "" })
 
-    const result = await checkHealth(normalized, platform)
+    const result = await checkServerHealth(normalized, fetcher)
     setStore("addServer", { adding: false })
 
     if (!result.healthy) {
@@ -327,7 +313,7 @@ export function DialogSelectServer() {
 
     setStore("editServer", { busy: true, error: "" })
 
-    const result = await checkHealth(normalized, platform)
+    const result = await checkServerHealth(normalized, fetcher)
     setStore("editServer", { busy: false })
 
     if (!result.healthy) {
@@ -369,6 +355,9 @@ export function DialogSelectServer() {
 
   async function handleRemove(url: string) {
     server.remove(url)
+    if ((await platform.getDefaultServerUrl?.()) === url) {
+      platform.setDefaultServerUrl?.(null)
+    }
   }
 
   return (
@@ -410,35 +399,6 @@ export function DialogSelectServer() {
           }
         >
           {(i) => {
-            const [truncated, setTruncated] = createSignal(false)
-            let nameRef: HTMLSpanElement | undefined
-            let versionRef: HTMLSpanElement | undefined
-
-            const check = () => {
-              const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
-              const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
-              setTruncated(nameTruncated || versionTruncated)
-            }
-
-            createEffect(() => {
-              check()
-              window.addEventListener("resize", check)
-              onCleanup(() => window.removeEventListener("resize", check))
-            })
-
-            const tooltipValue = () => {
-              const name = serverDisplayName(i)
-              const version = store.status[i]?.version
-              return (
-                <span class="flex items-center gap-2">
-                  <span>{name}</span>
-                  <Show when={version}>
-                    <span class="text-text-invert-base">{version}</span>
-                  </Show>
-                </span>
-              )
-            }
-
             return (
               <div class="flex items-center gap-3 min-w-0 flex-1 group/item">
                 <Show
@@ -456,34 +416,19 @@ export function DialogSelectServer() {
                     />
                   }
                 >
-                  <Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
-                    <div
-                      class="flex items-center gap-3 px-4 min-w-0 flex-1"
-                      classList={{ "opacity-50": store.status[i]?.healthy === false }}
-                    >
-                      <div
-                        classList={{
-                          "size-1.5 rounded-full shrink-0": true,
-                          "bg-icon-success-base": store.status[i]?.healthy === true,
-                          "bg-icon-critical-base": store.status[i]?.healthy === false,
-                          "bg-border-weak-base": store.status[i] === undefined,
-                        }}
-                      />
-                      <span ref={nameRef} class="truncate">
-                        {serverDisplayName(i)}
-                      </span>
-                      <Show when={store.status[i]?.version}>
-                        <span ref={versionRef} class="text-text-weak text-14-regular truncate">
-                          {store.status[i]?.version}
-                        </span>
-                      </Show>
+                  <ServerRow
+                    url={i}
+                    status={store.status[i]}
+                    dimmed={store.status[i]?.healthy === false}
+                    class="flex items-center gap-3 px-4 min-w-0 flex-1"
+                    badge={
                       <Show when={defaultUrl() === i}>
                         <span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
                           {language.t("dialog.server.status.default")}
                         </span>
                       </Show>
-                    </div>
-                  </Tooltip>
+                    }
+                  />
                 </Show>
                 <Show when={store.editServer.id !== i}>
                   <div class="flex items-center justify-center gap-5 pl-4">

+ 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([])
+  })
+})

+ 110 - 16
packages/app/src/components/file-tree.tsx

@@ -8,6 +8,7 @@ import {
   createMemo,
   For,
   Match,
+  on,
   Show,
   splitProps,
   Switch,
@@ -18,6 +19,14 @@ import {
 import { Dynamic } from "solid-js/web"
 import type { FileNode } from "@opencode-ai/sdk/v2"
 
+function pathToFileUrl(filepath: string): string {
+  const encodedPath = filepath
+    .split("/")
+    .map((segment) => encodeURIComponent(segment))
+    .join("/")
+  return `file://${encodedPath}`
+}
+
 type Kind = "add" | "del" | "mix"
 
 type Filter = {
@@ -25,6 +34,34 @@ type Filter = {
   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: {
   path: string
   class?: string
@@ -111,29 +148,87 @@ export default function FileTree(props: {
 
   createEffect(() => {
     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(() => {
-    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 = file.tree.children(props.path)
     const current = filter()
     if (!current) return nodes
-    return nodes.filter((node) => {
+
+    const parent = (path: string) => {
+      const idx = path.lastIndexOf("/")
+      if (idx === -1) return ""
+      return path.slice(0, idx)
+    }
+
+    const leaf = (path: string) => {
+      const idx = path.lastIndexOf("/")
+      return idx === -1 ? path : path.slice(idx + 1)
+    }
+
+    const out = nodes.filter((node) => {
       if (node.type === "file") return current.files.has(node.path)
       return current.dirs.has(node.path)
     })
+
+    const seen = new Set(out.map((node) => node.path))
+
+    for (const dir of current.dirs) {
+      if (parent(dir) !== props.path) continue
+      if (seen.has(dir)) continue
+      out.push({
+        name: leaf(dir),
+        path: dir,
+        absolute: dir,
+        type: "directory",
+        ignored: false,
+      })
+      seen.add(dir)
+    }
+
+    for (const item of current.files) {
+      if (parent(item) !== props.path) continue
+      if (seen.has(item)) continue
+      out.push({
+        name: leaf(item),
+        path: item,
+        absolute: item,
+        type: "file",
+        ignored: false,
+      })
+      seen.add(item)
+    }
+
+    return out.toSorted((a, b) => {
+      if (a.type !== b.type) {
+        return a.type === "directory" ? -1 : 1
+      }
+      return a.name.localeCompare(b.name)
+    })
   })
 
   const Node = (
@@ -160,7 +255,7 @@ export default function FileTree(props: {
         onDragStart={(e: DragEvent) => {
           if (!draggable()) return
           e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
-          e.dataTransfer?.setData("text/uri-list", `file://${local.node.path}`)
+          e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
           if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
 
           const dragImage = document.createElement("div")
@@ -194,7 +289,7 @@ export default function FileTree(props: {
               : kind === "del"
                 ? "color: var(--icon-diff-delete-base)"
                 : kind === "mix"
-                  ? "color: var(--icon-diff-modified-base)"
+                  ? "color: var(--icon-warning-active)"
                   : undefined
           return (
             <span
@@ -221,7 +316,7 @@ export default function FileTree(props: {
                 ? "color: var(--icon-diff-add-base)"
                 : kind === "del"
                   ? "color: var(--icon-diff-delete-base)"
-                  : "color: var(--icon-diff-modified-base)"
+                  : "color: var(--icon-warning-active)"
 
             return (
               <span class="shrink-0 w-4 text-center text-12-medium" style={color}>
@@ -236,7 +331,7 @@ export default function FileTree(props: {
                 ? "background-color: var(--icon-diff-add-base)"
                 : kind === "del"
                   ? "background-color: var(--icon-diff-delete-base)"
-                  : "background-color: var(--icon-diff-modified-base)"
+                  : "background-color: var(--icon-warning-active)"
 
             return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={color} />
           }
@@ -274,7 +369,6 @@ export default function FileTree(props: {
 
             return (
               <Tooltip
-                forceMount={false}
                 openDelay={2000}
                 placement="bottom-start"
                 class="w-full"

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 90 - 900
packages/app/src/components/prompt-input.tsx


+ 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,
+  }
+}

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

@@ -0,0 +1,67 @@
+import { describe, expect, test } from "bun:test"
+import type { Prompt } from "@/context/prompt"
+import { buildRequestParts } from "./build-request-parts"
+
+describe("buildRequestParts", () => {
+  test("builds typed request and optimistic parts without cast path", () => {
+    const prompt: Prompt = [
+      { type: "text", content: "hello", start: 0, end: 5 },
+      {
+        type: "file",
+        path: "src/foo.ts",
+        content: "@src/foo.ts",
+        start: 5,
+        end: 16,
+        selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
+      },
+      { type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
+    ]
+
+    const result = buildRequestParts({
+      prompt,
+      context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
+      images: [
+        { type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
+      ],
+      text: "hello @src/foo.ts @planner",
+      messageID: "msg_1",
+      sessionID: "ses_1",
+      sessionDirectory: "/repo",
+    })
+
+    expect(result.requestParts[0]?.type).toBe("text")
+    expect(result.requestParts.some((part) => part.type === "agent")).toBe(true)
+    expect(
+      result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
+    ).toBe(true)
+    expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
+
+    expect(result.optimisticParts).toHaveLength(result.requestParts.length)
+    expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
+  })
+
+  test("deduplicates context files when prompt already includes same path", () => {
+    const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
+
+    const result = buildRequestParts({
+      prompt,
+      context: [
+        { key: "ctx:dup", type: "file", path: "src/foo.ts" },
+        { key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
+      ],
+      images: [],
+      text: "@src/foo.ts",
+      messageID: "msg_2",
+      sessionID: "ses_2",
+      sessionDirectory: "/repo",
+    })
+
+    const fooFiles = result.requestParts.filter(
+      (part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"),
+    )
+    const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic)
+
+    expect(fooFiles).toHaveLength(2)
+    expect(synthetic).toHaveLength(1)
+  })
+})

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

@@ -0,0 +1,180 @@
+import { getFilename } from "@opencode-ai/util/path"
+import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
+import type { FileSelection } from "@/context/file"
+import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
+import { Identifier } from "@/utils/id"
+
+type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
+
+type ContextFile = {
+  key: string
+  type: "file"
+  path: string
+  selection?: FileSelection
+  comment?: string
+  commentID?: string
+  commentOrigin?: "review" | "file"
+  preview?: string
+}
+
+type BuildRequestPartsInput = {
+  prompt: Prompt
+  context: ContextFile[]
+  images: ImageAttachmentPart[]
+  text: string
+  messageID: string
+  sessionID: string
+  sessionDirectory: string
+}
+
+const absolute = (directory: string, path: string) =>
+  path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/")
+
+const encodeFilePath = (filepath: string): string =>
+  filepath
+    .split("/")
+    .map((segment) => encodeURIComponent(segment))
+    .join("/")
+
+const fileQuery = (selection: FileSelection | undefined) =>
+  selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
+
+const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
+const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
+
+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 toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
+  if (part.type === "text") {
+    return {
+      id: part.id,
+      type: "text",
+      text: part.text,
+      synthetic: part.synthetic,
+      ignored: part.ignored,
+      time: part.time,
+      metadata: part.metadata,
+      sessionID,
+      messageID,
+    }
+  }
+  if (part.type === "file") {
+    return {
+      id: part.id,
+      type: "file",
+      mime: part.mime,
+      filename: part.filename,
+      url: part.url,
+      source: part.source,
+      sessionID,
+      messageID,
+    }
+  }
+  return {
+    id: part.id,
+    type: "agent",
+    name: part.name,
+    source: part.source,
+    sessionID,
+    messageID,
+  }
+}
+
+export function buildRequestParts(input: BuildRequestPartsInput) {
+  const requestParts: PromptRequestPart[] = [
+    {
+      id: Identifier.ascending("part"),
+      type: "text",
+      text: input.text,
+    },
+  ]
+
+  const files = input.prompt.filter(isFileAttachment).map((attachment) => {
+    const path = absolute(input.sessionDirectory, attachment.path)
+    return {
+      id: Identifier.ascending("part"),
+      type: "file",
+      mime: "text/plain",
+      url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
+      filename: getFilename(attachment.path),
+      source: {
+        type: "file",
+        text: {
+          value: attachment.content,
+          start: attachment.start,
+          end: attachment.end,
+        },
+        path,
+      },
+    } satisfies PromptRequestPart
+  })
+
+  const agents = input.prompt.filter(isAgentAttachment).map((attachment) => {
+    return {
+      id: Identifier.ascending("part"),
+      type: "agent",
+      name: attachment.name,
+      source: {
+        value: attachment.content,
+        start: attachment.start,
+        end: attachment.end,
+      },
+    } satisfies PromptRequestPart
+  })
+
+  const used = new Set(files.map((part) => part.url))
+  const context = input.context.flatMap((item) => {
+    const path = absolute(input.sessionDirectory, item.path)
+    const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
+    const comment = item.comment?.trim()
+    if (!comment && used.has(url)) return []
+    used.add(url)
+
+    const filePart = {
+      id: Identifier.ascending("part"),
+      type: "file",
+      mime: "text/plain",
+      url,
+      filename: getFilename(item.path),
+    } satisfies PromptRequestPart
+
+    if (!comment) return [filePart]
+
+    return [
+      {
+        id: Identifier.ascending("part"),
+        type: "text",
+        text: commentNote(item.path, item.selection, comment),
+        synthetic: true,
+      } satisfies PromptRequestPart,
+      filePart,
+    ]
+  })
+
+  const images = input.images.map((attachment) => {
+    return {
+      id: Identifier.ascending("part"),
+      type: "file",
+      mime: attachment.mime,
+      url: attachment.dataUrl,
+      filename: attachment.filename,
+    } satisfies PromptRequestPart
+  })
+
+  requestParts.push(...files, ...context, ...agents, ...images)
+
+  return {
+    requestParts,
+    optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)),
+  }
+}

+ 82 - 0
packages/app/src/components/prompt-input/context-items.tsx

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

+ 20 - 0
packages/app/src/components/prompt-input/drag-overlay.tsx

@@ -0,0 +1,20 @@
+import { Component, Show } from "solid-js"
+import { Icon } from "@opencode-ai/ui/icon"
+
+type PromptDragOverlayProps = {
+  dragging: boolean
+  label: string
+}
+
+export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
+  return (
+    <Show when={props.dragging}>
+      <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
+        <div class="flex flex-col items-center gap-2 text-text-weak">
+          <Icon name="photo" class="size-8" />
+          <span class="text-14-regular">{props.label}</span>
+        </div>
+      </div>
+    </Show>
+  )
+}

+ 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,
+  }
+}

+ 51 - 0
packages/app/src/components/prompt-input/image-attachments.tsx

@@ -0,0 +1,51 @@
+import { Component, For, Show } from "solid-js"
+import { Icon } from "@opencode-ai/ui/icon"
+import type { ImageAttachmentPart } from "@/context/prompt"
+
+type PromptImageAttachmentsProps = {
+  attachments: ImageAttachmentPart[]
+  onOpen: (attachment: ImageAttachmentPart) => void
+  onRemove: (id: string) => void
+  removeLabel: string
+}
+
+export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
+  return (
+    <Show when={props.attachments.length > 0}>
+      <div class="flex flex-wrap gap-2 px-3 pt-3">
+        <For each={props.attachments}>
+          {(attachment) => (
+            <div class="relative group">
+              <Show
+                when={attachment.mime.startsWith("image/")}
+                fallback={
+                  <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
+                    <Icon name="folder" class="size-6 text-text-weak" />
+                  </div>
+                }
+              >
+                <img
+                  src={attachment.dataUrl}
+                  alt={attachment.filename}
+                  class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
+                  onClick={() => props.onOpen(attachment)}
+                />
+              </Show>
+              <button
+                type="button"
+                onClick={() => props.onRemove(attachment.id)}
+                class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
+                aria-label={props.removeLabel}
+              >
+                <Icon name="close" class="size-3 text-text-weak" />
+              </button>
+              <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
+                <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
+              </div>
+            </div>
+          )}
+        </For>
+      </div>
+    </Show>
+  )
+}

+ 35 - 0
packages/app/src/components/prompt-input/placeholder.test.ts

@@ -0,0 +1,35 @@
+import { describe, expect, test } from "bun:test"
+import { promptPlaceholder } from "./placeholder"
+
+describe("promptPlaceholder", () => {
+  const t = (key: string, params?: Record<string, string>) => `${key}${params?.example ? `:${params.example}` : ""}`
+
+  test("returns shell placeholder in shell mode", () => {
+    const value = promptPlaceholder({
+      mode: "shell",
+      commentCount: 0,
+      example: "example",
+      t,
+    })
+    expect(value).toBe("prompt.placeholder.shell")
+  })
+
+  test("returns summarize placeholders for comment context", () => {
+    expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe(
+      "prompt.placeholder.summarizeComment",
+    )
+    expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe(
+      "prompt.placeholder.summarizeComments",
+    )
+  })
+
+  test("returns default placeholder with example", () => {
+    const value = promptPlaceholder({
+      mode: "normal",
+      commentCount: 0,
+      example: "translated-example",
+      t,
+    })
+    expect(value).toBe("prompt.placeholder.normal:translated-example")
+  })
+})

+ 13 - 0
packages/app/src/components/prompt-input/placeholder.ts

@@ -0,0 +1,13 @@
+type PromptPlaceholderInput = {
+  mode: "normal" | "shell"
+  commentCount: number
+  example: string
+  t: (key: string, params?: Record<string, string>) => string
+}
+
+export function promptPlaceholder(input: PromptPlaceholderInput) {
+  if (input.mode === "shell") return input.t("prompt.placeholder.shell")
+  if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
+  if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
+  return input.t("prompt.placeholder.normal", { example: input.example })
+}

+ 144 - 0
packages/app/src/components/prompt-input/slash-popover.tsx

@@ -0,0 +1,144 @@
+import { Component, For, Match, Show, Switch } from "solid-js"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { Icon } from "@opencode-ai/ui/icon"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+
+export type AtOption =
+  | { type: "agent"; name: string; display: string }
+  | { type: "file"; path: string; display: string; recent?: boolean }
+
+export interface SlashCommand {
+  id: string
+  trigger: string
+  title: string
+  description?: string
+  keybind?: string
+  type: "builtin" | "custom"
+  source?: "command" | "mcp" | "skill"
+}
+
+type PromptPopoverProps = {
+  popover: "at" | "slash" | null
+  setSlashPopoverRef: (el: HTMLDivElement) => void
+  atFlat: AtOption[]
+  atActive?: string
+  atKey: (item: AtOption) => string
+  setAtActive: (id: string) => void
+  onAtSelect: (item: AtOption) => void
+  slashFlat: SlashCommand[]
+  slashActive?: string
+  setSlashActive: (id: string) => void
+  onSlashSelect: (item: SlashCommand) => void
+  commandKeybind: (id: string) => string | undefined
+  t: (key: string) => string
+}
+
+export const PromptPopover: Component<PromptPopoverProps> = (props) => {
+  return (
+    <Show when={props.popover}>
+      <div
+        ref={(el) => {
+          if (props.popover === "slash") props.setSlashPopoverRef(el)
+        }}
+        class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
+                 overflow-auto no-scrollbar flex flex-col p-2 rounded-md
+                 border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
+        onMouseDown={(e) => e.preventDefault()}
+      >
+        <Switch>
+          <Match when={props.popover === "at"}>
+            <Show
+              when={props.atFlat.length > 0}
+              fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
+            >
+              <For each={props.atFlat.slice(0, 10)}>
+                {(item) => (
+                  <button
+                    classList={{
+                      "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
+                      "bg-surface-raised-base-hover": props.atActive === props.atKey(item),
+                    }}
+                    onClick={() => props.onAtSelect(item)}
+                    onMouseEnter={() => props.setAtActive(props.atKey(item))}
+                  >
+                    <Show
+                      when={item.type === "agent"}
+                      fallback={
+                        <>
+                          <FileIcon
+                            node={{ path: item.type === "file" ? item.path : "", type: "file" }}
+                            class="shrink-0 size-4"
+                          />
+                          <div class="flex items-center text-14-regular min-w-0">
+                            <span class="text-text-weak whitespace-nowrap truncate min-w-0">
+                              {item.type === "file"
+                                ? item.path.endsWith("/")
+                                  ? item.path
+                                  : getDirectory(item.path)
+                                : ""}
+                            </span>
+                            <Show when={item.type === "file" && !item.path.endsWith("/")}>
+                              <span class="text-text-strong whitespace-nowrap">
+                                {item.type === "file" ? getFilename(item.path) : ""}
+                              </span>
+                            </Show>
+                          </div>
+                        </>
+                      }
+                    >
+                      <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
+                      <span class="text-14-regular text-text-strong whitespace-nowrap">
+                        @{item.type === "agent" ? item.name : ""}
+                      </span>
+                    </Show>
+                  </button>
+                )}
+              </For>
+            </Show>
+          </Match>
+          <Match when={props.popover === "slash"}>
+            <Show
+              when={props.slashFlat.length > 0}
+              fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyCommands")}</div>}
+            >
+              <For each={props.slashFlat}>
+                {(cmd) => (
+                  <button
+                    data-slash-id={cmd.id}
+                    classList={{
+                      "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
+                      "bg-surface-raised-base-hover": props.slashActive === cmd.id,
+                    }}
+                    onClick={() => props.onSlashSelect(cmd)}
+                    onMouseEnter={() => props.setSlashActive(cmd.id)}
+                  >
+                    <div class="flex items-center gap-2 min-w-0">
+                      <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
+                      <Show when={cmd.description}>
+                        <span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
+                      </Show>
+                    </div>
+                    <div class="flex items-center gap-2 shrink-0">
+                      <Show when={cmd.type === "custom" && cmd.source !== "command"}>
+                        <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
+                          {cmd.source === "skill"
+                            ? props.t("prompt.slash.badge.skill")
+                            : cmd.source === "mcp"
+                              ? props.t("prompt.slash.badge.mcp")
+                              : props.t("prompt.slash.badge.custom")}
+                        </span>
+                      </Show>
+                      <Show when={props.commandKeybind(cmd.id)}>
+                        <span class="text-12-regular text-text-subtle">{props.commandKeybind(cmd.id)}</span>
+                      </Show>
+                    </div>
+                  </button>
+                )}
+              </For>
+            </Show>
+          </Match>
+        </Switch>
+      </div>
+    </Show>
+  )
+}

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

@@ -0,0 +1,411 @@
+import { Accessor } from "solid-js"
+import { useNavigate, useParams } from "@solidjs/router"
+import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
+import { showToast } from "@opencode-ai/ui/toast"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { useLocal } from "@/context/local"
+import { usePrompt, 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"
+import { buildRequestParts } from "./build-request-parts"
+
+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 context = prompt.context.items().slice()
+    const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
+
+    const messageID = Identifier.ascending("message")
+    const { requestParts, optimisticParts } = buildRequestParts({
+      prompt: currentPrompt,
+      context,
+      images,
+      text,
+      sessionID: session.id,
+      messageID,
+      sessionDirectory,
+    })
+
+    const optimisticMessage: Message = {
+      id: messageID,
+      sessionID: session.id,
+      role: "user",
+      time: { created: Date.now() },
+      agent,
+      model,
+    }
+
+    const addOptimisticMessage = () =>
+      sync.session.optimistic.add({
+        directory: sessionDirectory,
+        sessionID: session.id,
+        message: optimisticMessage,
+        parts: optimisticParts,
+      })
+
+    const removeOptimisticMessage = () =>
+      sync.session.optimistic.remove({
+        directory: sessionDirectory,
+        sessionID: session.id,
+        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,
+  }
+}

+ 295 - 0
packages/app/src/components/question-dock.tsx

@@ -0,0 +1,295 @@
+import { For, Show, createMemo, type Component } from "solid-js"
+import { createStore } from "solid-js/store"
+import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { showToast } from "@opencode-ai/ui/toast"
+import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
+import { useLanguage } from "@/context/language"
+import { useSDK } from "@/context/sdk"
+
+export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
+  const sdk = useSDK()
+  const language = useLanguage()
+
+  const questions = createMemo(() => props.request.questions)
+  const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
+
+  const [store, setStore] = createStore({
+    tab: 0,
+    answers: [] as QuestionAnswer[],
+    custom: [] as string[],
+    editing: false,
+    sending: false,
+  })
+
+  const question = createMemo(() => questions()[store.tab])
+  const confirm = createMemo(() => !single() && store.tab === questions().length)
+  const options = createMemo(() => question()?.options ?? [])
+  const input = createMemo(() => store.custom[store.tab] ?? "")
+  const multi = createMemo(() => question()?.multiple === true)
+  const customPicked = createMemo(() => {
+    const value = input()
+    if (!value) return false
+    return store.answers[store.tab]?.includes(value) ?? false
+  })
+
+  const fail = (err: unknown) => {
+    const message = err instanceof Error ? err.message : String(err)
+    showToast({ title: language.t("common.requestFailed"), description: message })
+  }
+
+  const reply = (answers: QuestionAnswer[]) => {
+    if (store.sending) return
+
+    setStore("sending", true)
+    sdk.client.question
+      .reply({ requestID: props.request.id, answers })
+      .catch(fail)
+      .finally(() => setStore("sending", false))
+  }
+
+  const reject = () => {
+    if (store.sending) return
+
+    setStore("sending", true)
+    sdk.client.question
+      .reject({ requestID: props.request.id })
+      .catch(fail)
+      .finally(() => setStore("sending", false))
+  }
+
+  const submit = () => {
+    reply(questions().map((_, i) => store.answers[i] ?? []))
+  }
+
+  const pick = (answer: string, custom: boolean = false) => {
+    const answers = [...store.answers]
+    answers[store.tab] = [answer]
+    setStore("answers", answers)
+
+    if (custom) {
+      const inputs = [...store.custom]
+      inputs[store.tab] = answer
+      setStore("custom", inputs)
+    }
+
+    if (single()) {
+      reply([[answer]])
+      return
+    }
+
+    setStore("tab", store.tab + 1)
+  }
+
+  const toggle = (answer: string) => {
+    const existing = store.answers[store.tab] ?? []
+    const next = [...existing]
+    const index = next.indexOf(answer)
+    if (index === -1) next.push(answer)
+    if (index !== -1) next.splice(index, 1)
+
+    const answers = [...store.answers]
+    answers[store.tab] = next
+    setStore("answers", answers)
+  }
+
+  const selectTab = (index: number) => {
+    setStore("tab", index)
+    setStore("editing", false)
+  }
+
+  const selectOption = (optIndex: number) => {
+    if (store.sending) return
+
+    if (optIndex === options().length) {
+      setStore("editing", true)
+      return
+    }
+
+    const opt = options()[optIndex]
+    if (!opt) return
+    if (multi()) {
+      toggle(opt.label)
+      return
+    }
+    pick(opt.label)
+  }
+
+  const handleCustomSubmit = (e: Event) => {
+    e.preventDefault()
+    if (store.sending) return
+
+    const value = input().trim()
+    if (!value) {
+      setStore("editing", false)
+      return
+    }
+
+    if (multi()) {
+      const existing = store.answers[store.tab] ?? []
+      const next = [...existing]
+      if (!next.includes(value)) next.push(value)
+
+      const answers = [...store.answers]
+      answers[store.tab] = next
+      setStore("answers", answers)
+      setStore("editing", false)
+      return
+    }
+
+    pick(value, true)
+    setStore("editing", false)
+  }
+
+  return (
+    <div data-component="question-prompt">
+      <Show when={!single()}>
+        <div data-slot="question-tabs">
+          <For each={questions()}>
+            {(q, index) => {
+              const active = () => index() === store.tab
+              const answered = () => (store.answers[index()]?.length ?? 0) > 0
+              return (
+                <button
+                  data-slot="question-tab"
+                  data-active={active()}
+                  data-answered={answered()}
+                  disabled={store.sending}
+                  onClick={() => selectTab(index())}
+                >
+                  {q.header}
+                </button>
+              )
+            }}
+          </For>
+          <button
+            data-slot="question-tab"
+            data-active={confirm()}
+            disabled={store.sending}
+            onClick={() => selectTab(questions().length)}
+          >
+            {language.t("ui.common.confirm")}
+          </button>
+        </div>
+      </Show>
+
+      <Show when={!confirm()}>
+        <div data-slot="question-content">
+          <div data-slot="question-text">
+            {question()?.question}
+            {multi() ? " " + language.t("ui.question.multiHint") : ""}
+          </div>
+          <div data-slot="question-options">
+            <For each={options()}>
+              {(opt, i) => {
+                const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
+                return (
+                  <button
+                    data-slot="question-option"
+                    data-picked={picked()}
+                    disabled={store.sending}
+                    onClick={() => selectOption(i())}
+                  >
+                    <span data-slot="option-label">{opt.label}</span>
+                    <Show when={opt.description}>
+                      <span data-slot="option-description">{opt.description}</span>
+                    </Show>
+                    <Show when={picked()}>
+                      <Icon name="check-small" size="normal" />
+                    </Show>
+                  </button>
+                )
+              }}
+            </For>
+            <button
+              data-slot="question-option"
+              data-picked={customPicked()}
+              disabled={store.sending}
+              onClick={() => selectOption(options().length)}
+            >
+              <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
+              <Show when={!store.editing && input()}>
+                <span data-slot="option-description">{input()}</span>
+              </Show>
+              <Show when={customPicked()}>
+                <Icon name="check-small" size="normal" />
+              </Show>
+            </button>
+            <Show when={store.editing}>
+              <form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
+                <input
+                  ref={(el) => setTimeout(() => el.focus(), 0)}
+                  type="text"
+                  data-slot="custom-input"
+                  placeholder={language.t("ui.question.custom.placeholder")}
+                  value={input()}
+                  disabled={store.sending}
+                  onInput={(e) => {
+                    const inputs = [...store.custom]
+                    inputs[store.tab] = e.currentTarget.value
+                    setStore("custom", inputs)
+                  }}
+                />
+                <Button type="submit" variant="primary" size="small" disabled={store.sending}>
+                  {multi() ? language.t("ui.common.add") : language.t("ui.common.submit")}
+                </Button>
+                <Button
+                  type="button"
+                  variant="ghost"
+                  size="small"
+                  disabled={store.sending}
+                  onClick={() => setStore("editing", false)}
+                >
+                  {language.t("ui.common.cancel")}
+                </Button>
+              </form>
+            </Show>
+          </div>
+        </div>
+      </Show>
+
+      <Show when={confirm()}>
+        <div data-slot="question-review">
+          <div data-slot="review-title">{language.t("ui.messagePart.review.title")}</div>
+          <For each={questions()}>
+            {(q, index) => {
+              const value = () => store.answers[index()]?.join(", ") ?? ""
+              const answered = () => Boolean(value())
+              return (
+                <div data-slot="review-item">
+                  <span data-slot="review-label">{q.question}</span>
+                  <span data-slot="review-value" data-answered={answered()}>
+                    {answered() ? value() : language.t("ui.question.review.notAnswered")}
+                  </span>
+                </div>
+              )
+            }}
+          </For>
+        </div>
+      </Show>
+
+      <div data-slot="question-actions">
+        <Button variant="ghost" size="small" onClick={reject} disabled={store.sending}>
+          {language.t("ui.common.dismiss")}
+        </Button>
+        <Show when={!single()}>
+          <Show when={confirm()}>
+            <Button variant="primary" size="small" onClick={submit} disabled={store.sending}>
+              {language.t("ui.common.submit")}
+            </Button>
+          </Show>
+          <Show when={!confirm() && multi()}>
+            <Button
+              variant="secondary"
+              size="small"
+              onClick={() => selectTab(store.tab + 1)}
+              disabled={store.sending || (store.answers[store.tab]?.length ?? 0) === 0}
+            >
+              {language.t("ui.common.next")}
+            </Button>
+          </Show>
+        </Show>
+      </div>
+    </div>
+  )
+}

+ 77 - 0
packages/app/src/components/server/server-row.tsx

@@ -0,0 +1,77 @@
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { JSXElement, ParentProps, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
+import { serverDisplayName } from "@/context/server"
+import type { ServerHealth } from "@/utils/server-health"
+
+interface ServerRowProps extends ParentProps {
+  url: string
+  status?: ServerHealth
+  class?: string
+  nameClass?: string
+  versionClass?: string
+  dimmed?: boolean
+  badge?: JSXElement
+}
+
+export function ServerRow(props: ServerRowProps) {
+  const [truncated, setTruncated] = createSignal(false)
+  let nameRef: HTMLSpanElement | undefined
+  let versionRef: HTMLSpanElement | undefined
+
+  const check = () => {
+    const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
+    const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
+    setTruncated(nameTruncated || versionTruncated)
+  }
+
+  createEffect(() => {
+    props.url
+    props.status?.version
+    if (typeof requestAnimationFrame === "function") {
+      requestAnimationFrame(check)
+      return
+    }
+    check()
+  })
+
+  onMount(() => {
+    check()
+    if (typeof window === "undefined") return
+    window.addEventListener("resize", check)
+    onCleanup(() => window.removeEventListener("resize", check))
+  })
+
+  const tooltipValue = () => (
+    <span class="flex items-center gap-2">
+      <span>{serverDisplayName(props.url)}</span>
+      <Show when={props.status?.version}>
+        <span class="text-text-invert-base">{props.status?.version}</span>
+      </Show>
+    </span>
+  )
+
+  return (
+    <Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
+      <div class={props.class} classList={{ "opacity-50": props.dimmed }}>
+        <div
+          classList={{
+            "size-1.5 rounded-full shrink-0": true,
+            "bg-icon-success-base": props.status?.healthy === true,
+            "bg-icon-critical-base": props.status?.healthy === false,
+            "bg-border-weak-base": props.status === undefined,
+          }}
+        />
+        <span ref={nameRef} class={props.nameClass ?? "truncate"}>
+          {serverDisplayName(props.url)}
+        </span>
+        <Show when={props.status?.version}>
+          <span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
+            {props.status?.version}
+          </span>
+        </Show>
+        {props.badge}
+        {props.children}
+      </div>
+    </Tooltip>
+  )
+}

+ 10 - 25
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 { Button } from "@opencode-ai/ui/button"
 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 { useSync } from "@/context/sync"
 import { useLanguage } from "@/context/language"
+import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
 
 interface SessionContextUsageProps {
   variant?: "button" | "indicator"
@@ -23,6 +22,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
   const variant = createMemo(() => props.variant ?? "button")
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const tabs = createMemo(() => layout.tabs(sessionKey))
+  const view = createMemo(() => layout.view(sessionKey))
   const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
 
   const usd = createMemo(
@@ -33,30 +33,15 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
       }),
   )
 
+  const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
+  const context = createMemo(() => metrics().context)
   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 = () => {
     if (!params.id) return
+    if (!view().reviewPanel.opened()) view().reviewPanel.open()
     layout.fileTree.open()
     layout.fileTree.setTab("all")
     tabs().open("context")
@@ -64,8 +49,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
   }
 
   const circle = () => (
-    <div class="p-1">
-      <ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
+    <div class="flex items-center justify-center">
+      <ProgressCircle size={16} strokeWidth={2} percentage={context()?.usage ?? 0} />
     </div>
   )
 
@@ -75,11 +60,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
         {(ctx) => (
           <>
             <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>
             </div>
             <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>
             </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 { Code } from "@opencode-ai/ui/code"
 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 { getSessionContextMetrics } from "./session-context-metrics"
 
 interface SessionContextTabProps {
   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 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(() => {
@@ -114,14 +82,13 @@ export function SessionContextTab(props: SessionContextTabProps) {
   const providerLabel = createMemo(() => {
     const c = ctx()
     if (!c) return "—"
-    return c.provider?.name ?? c.message.providerID
+    return c.providerLabel
   })
 
   const modelLabel = createMemo(() => {
     const c = ctx()
     if (!c) return "—"
-    if (c.model?.name) return c.model.name
-    return c.message.modelID
+    return c.modelLabel
   })
 
   const breakdown = createMemo(

+ 261 - 8
packages/app/src/components/session/session-header.tsx

@@ -6,18 +6,23 @@ import { useLayout } from "@/context/layout"
 import { useCommand } from "@/context/command"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
+import { useServer } from "@/context/server"
 import { useSync } from "@/context/sync"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { getFilename } from "@opencode-ai/util/path"
 import { decode64 } from "@/utils/base64"
+import { Persist, persisted } from "@/utils/persist"
 
 import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Button } from "@opencode-ai/ui/button"
+import { AppIcon } from "@opencode-ai/ui/app-icon"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { Popover } from "@opencode-ai/ui/popover"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { Keybind } from "@opencode-ai/ui/keybind"
+import { showToast } from "@opencode-ai/ui/toast"
 import { StatusPopover } from "../status-popover"
 
 export function SessionHeader() {
@@ -25,6 +30,7 @@ export function SessionHeader() {
   const layout = useLayout()
   const params = useParams()
   const command = useCommand()
+  const server = useServer()
   const sync = useSync()
   const platform = usePlatform()
   const language = useLanguage()
@@ -48,6 +54,153 @@ export function SessionHeader() {
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const view = createMemo(() => layout.view(sessionKey))
 
+  const OPEN_APPS = [
+    "vscode",
+    "cursor",
+    "zed",
+    "textmate",
+    "antigravity",
+    "finder",
+    "terminal",
+    "iterm2",
+    "ghostty",
+    "xcode",
+    "android-studio",
+    "powershell",
+    "sublime-text",
+  ] as const
+  type OpenApp = (typeof OPEN_APPS)[number]
+
+  const MAC_APPS = [
+    { id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
+    { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
+    { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
+    { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
+    { id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
+    { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
+    { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
+    { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
+    { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
+    { id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
+    { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
+  ] as const
+
+  const WINDOWS_APPS = [
+    { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
+    { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
+    { id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
+    { id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
+    { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
+  ] as const
+
+  const LINUX_APPS = [
+    { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
+    { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
+    { id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
+    { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
+  ] as const
+
+  const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => {
+    if (platform.platform === "desktop" && platform.os) return platform.os
+    if (typeof navigator !== "object") return "unknown"
+    const value = navigator.platform || navigator.userAgent
+    if (/Mac/i.test(value)) return "macos"
+    if (/Win/i.test(value)) return "windows"
+    if (/Linux/i.test(value)) return "linux"
+    return "unknown"
+  })
+
+  const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true })
+
+  createEffect(() => {
+    if (platform.platform !== "desktop") return
+    if (!platform.checkAppExists) return
+
+    const list = os()
+    const apps = list === "macos" ? MAC_APPS : list === "windows" ? WINDOWS_APPS : list === "linux" ? LINUX_APPS : []
+    if (apps.length === 0) return
+
+    void Promise.all(
+      apps.map((app) =>
+        Promise.resolve(platform.checkAppExists?.(app.openWith)).then((value) => {
+          const ok = Boolean(value)
+          console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`)
+          return [app.id, ok] as const
+        }),
+      ),
+    ).then((entries) => {
+      setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
+    })
+  })
+
+  const options = createMemo(() => {
+    if (os() === "macos") {
+      return [{ id: "finder", label: "Finder", icon: "finder" }, ...MAC_APPS.filter((app) => exists[app.id])] as const
+    }
+
+    if (os() === "windows") {
+      return [
+        { id: "finder", label: "File Explorer", icon: "file-explorer" },
+        ...WINDOWS_APPS.filter((app) => exists[app.id]),
+      ] as const
+    }
+
+    return [
+      { id: "finder", label: "File Manager", icon: "finder" },
+      ...LINUX_APPS.filter((app) => exists[app.id]),
+    ] as const
+  })
+
+  const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
+
+  const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
+  const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
+
+  createEffect(() => {
+    if (platform.platform !== "desktop") return
+    const value = prefs.app
+    if (options().some((o) => o.id === value)) return
+    setPrefs("app", options()[0]?.id ?? "finder")
+  })
+
+  const openDir = (app: OpenApp) => {
+    const directory = projectDirectory()
+    if (!directory) return
+    if (!canOpen()) return
+
+    const item = options().find((o) => o.id === app)
+    const openWith = item && "openWith" in item ? item.openWith : undefined
+    Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => {
+      showToast({
+        variant: "error",
+        title: language.t("common.requestFailed"),
+        description: err instanceof Error ? err.message : String(err),
+      })
+    })
+  }
+
+  const copyPath = () => {
+    const directory = projectDirectory()
+    if (!directory) return
+    navigator.clipboard
+      .writeText(directory)
+      .then(() => {
+        showToast({
+          variant: "success",
+          icon: "circle-check",
+          title: language.t("session.share.copy.copied"),
+          description: directory,
+        })
+      })
+      .catch((err: unknown) => {
+        showToast({
+          variant: "error",
+          title: language.t("common.requestFailed"),
+          description: err instanceof Error ? err.message : String(err),
+        })
+      })
+  }
+
   const [state, setState] = createStore({
     share: false,
     unshare: false,
@@ -150,6 +303,80 @@ export function SessionHeader() {
         {(mount) => (
           <Portal mount={mount()}>
             <div class="flex items-center gap-3">
+              <Show when={projectDirectory()}>
+                <div class="hidden xl:flex items-center">
+                  <Show
+                    when={canOpen()}
+                    fallback={
+                      <Button
+                        variant="ghost"
+                        class="rounded-sm h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none"
+                        onClick={copyPath}
+                        aria-label={language.t("session.header.open.copyPath")}
+                      >
+                        <Icon name="copy" size="small" class="text-icon-base" />
+                        <span class="text-12-regular text-text-strong">
+                          {language.t("session.header.open.copyPath")}
+                        </span>
+                      </Button>
+                    }
+                  >
+                    <div class="flex items-center">
+                      <Button
+                        variant="ghost"
+                        class="rounded-sm h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none rounded-r-none"
+                        onClick={() => openDir(current().id)}
+                        aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
+                      >
+                        <AppIcon id={current().icon} class="size-5" />
+                        <span class="text-12-regular text-text-strong">
+                          {language.t("session.header.open.action", { app: current().label })}
+                        </span>
+                      </Button>
+                      <DropdownMenu>
+                        <DropdownMenu.Trigger
+                          as={IconButton}
+                          icon="chevron-down"
+                          variant="ghost"
+                          class="rounded-sm h-[24px] w-auto px-1.5 border-none shadow-none rounded-l-none data-[expanded]:bg-surface-raised-base-active"
+                          aria-label={language.t("session.header.open.menu")}
+                        />
+                        <DropdownMenu.Portal>
+                          <DropdownMenu.Content placement="bottom-end" gutter={6}>
+                            <DropdownMenu.Group>
+                              <DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
+                              <DropdownMenu.RadioGroup
+                                value={prefs.app}
+                                onChange={(value) => {
+                                  if (!OPEN_APPS.includes(value as OpenApp)) return
+                                  setPrefs("app", value as OpenApp)
+                                }}
+                              >
+                                {options().map((o) => (
+                                  <DropdownMenu.RadioItem value={o.id} onSelect={() => openDir(o.id)}>
+                                    <AppIcon id={o.icon} class="size-5" />
+                                    <DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
+                                    <DropdownMenu.ItemIndicator>
+                                      <Icon name="check-small" size="small" class="text-icon-weak" />
+                                    </DropdownMenu.ItemIndicator>
+                                  </DropdownMenu.RadioItem>
+                                ))}
+                              </DropdownMenu.RadioGroup>
+                            </DropdownMenu.Group>
+                            <DropdownMenu.Separator />
+                            <DropdownMenu.Item onSelect={copyPath}>
+                              <Icon name="copy" size="small" class="text-icon-weak" />
+                              <DropdownMenu.ItemLabel>
+                                {language.t("session.header.open.copyPath")}
+                              </DropdownMenu.ItemLabel>
+                            </DropdownMenu.Item>
+                          </DropdownMenu.Content>
+                        </DropdownMenu.Portal>
+                      </DropdownMenu>
+                    </div>
+                  </Show>
+                </div>
+              </Show>
               <StatusPopover />
               <Show when={showShare()}>
                 <div class="flex items-center">
@@ -283,27 +510,53 @@ export function SessionHeader() {
                 <TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
                   <Button
                     variant="ghost"
-                    class="group/file-tree-toggle size-6 p-0"
-                    onClick={() => layout.fileTree.toggle()}
+                    class="group/review-toggle size-6 p-0"
+                    onClick={() => view().reviewPanel.toggle()}
                     aria-label={language.t("command.review.toggle")}
-                    aria-expanded={layout.fileTree.opened()}
+                    aria-expanded={view().reviewPanel.opened()}
                     aria-controls="review-panel"
                   >
                     <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
                       <Icon
                         size="small"
-                        name={layout.fileTree.opened() ? "layout-right-full" : "layout-right"}
-                        class="group-hover/file-tree-toggle:hidden"
+                        name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"}
+                        class="group-hover/review-toggle:hidden"
                       />
                       <Icon
                         size="small"
                         name="layout-right-partial"
-                        class="hidden group-hover/file-tree-toggle:inline-block"
+                        class="hidden group-hover/review-toggle:inline-block"
                       />
                       <Icon
                         size="small"
-                        name={layout.fileTree.opened() ? "layout-right" : "layout-right-full"}
-                        class="hidden group-active/file-tree-toggle:inline-block"
+                        name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"}
+                        class="hidden group-active/review-toggle:inline-block"
+                      />
+                    </div>
+                  </Button>
+                </TooltipKeybind>
+              </div>
+              <div class="hidden md:block shrink-0">
+                <TooltipKeybind
+                  title={language.t("command.fileTree.toggle")}
+                  keybind={command.keybind("fileTree.toggle")}
+                >
+                  <Button
+                    variant="ghost"
+                    class="group/file-tree-toggle size-6 p-0"
+                    onClick={() => layout.fileTree.toggle()}
+                    aria-label={language.t("command.fileTree.toggle")}
+                    aria-expanded={layout.fileTree.opened()}
+                    aria-controls="file-tree-panel"
+                  >
+                    <div class="relative flex items-center justify-center size-4">
+                      <Icon
+                        size="small"
+                        name="bullet-list"
+                        classList={{
+                          "text-icon-strong": layout.fileTree.opened(),
+                          "text-icon-weak": !layout.fileTree.opened(),
+                        }}
                       />
                     </div>
                   </Button>

+ 9 - 3
packages/app/src/components/session/session-sortable-tab.tsx

@@ -3,11 +3,12 @@ import type { JSX } from "solid-js"
 import { createSortable } from "@thisbeyond/solid-dnd"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { getFilename } from "@opencode-ai/util/path"
 import { useFile } from "@/context/file"
 import { useLanguage } from "@/context/language"
+import { useCommand } from "@/context/command"
 
 export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
   return (
@@ -27,6 +28,7 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme
 export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
   const file = useFile()
   const language = useLanguage()
+  const command = useCommand()
   const sortable = createSortable(props.tab)
   const path = createMemo(() => file.pathFromTab(props.tab))
   return (
@@ -36,7 +38,11 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
         <Tabs.Trigger
           value={props.tab}
           closeButton={
-            <Tooltip value={language.t("common.closeTab")} placement="bottom">
+            <TooltipKeybind
+              title={language.t("common.closeTab")}
+              keybind={command.keybind("tab.close")}
+              placement="bottom"
+            >
               <IconButton
                 icon="close-small"
                 variant="ghost"
@@ -44,7 +50,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
                 onClick={() => props.onTabClose(props.tab)}
                 aria-label={language.t("common.closeTab")}
               />
-            </Tooltip>
+            </TooltipKeybind>
           }
           hideCloseButton
           onMiddleClick={() => props.onTabClose(props.tab)}

+ 37 - 21
packages/app/src/components/settings-general.tsx

@@ -165,6 +165,7 @@ export const SettingsGeneral: Component = () => {
               description={language.t("settings.general.row.appearance.description")}
             >
               <Select
+                data-action="settings-color-scheme"
                 options={colorSchemeOptions()}
                 current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
                 value={(o) => o.value}
@@ -191,6 +192,7 @@ export const SettingsGeneral: Component = () => {
               }
             >
               <Select
+                data-action="settings-theme"
                 options={themeOptions()}
                 current={themeOptions().find((o) => o.id === theme.themeId())}
                 value={(o) => o.id}
@@ -215,6 +217,7 @@ export const SettingsGeneral: Component = () => {
               description={language.t("settings.general.row.font.description")}
             >
               <Select
+                data-action="settings-font"
                 options={fontOptionsList}
                 current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
                 value={(o) => o.value}
@@ -244,30 +247,36 @@ export const SettingsGeneral: Component = () => {
               title={language.t("settings.general.notifications.agent.title")}
               description={language.t("settings.general.notifications.agent.description")}
             >
-              <Switch
-                checked={settings.notifications.agent()}
-                onChange={(checked) => settings.notifications.setAgent(checked)}
-              />
+              <div data-action="settings-notifications-agent">
+                <Switch
+                  checked={settings.notifications.agent()}
+                  onChange={(checked) => settings.notifications.setAgent(checked)}
+                />
+              </div>
             </SettingsRow>
 
             <SettingsRow
               title={language.t("settings.general.notifications.permissions.title")}
               description={language.t("settings.general.notifications.permissions.description")}
             >
-              <Switch
-                checked={settings.notifications.permissions()}
-                onChange={(checked) => settings.notifications.setPermissions(checked)}
-              />
+              <div data-action="settings-notifications-permissions">
+                <Switch
+                  checked={settings.notifications.permissions()}
+                  onChange={(checked) => settings.notifications.setPermissions(checked)}
+                />
+              </div>
             </SettingsRow>
 
             <SettingsRow
               title={language.t("settings.general.notifications.errors.title")}
               description={language.t("settings.general.notifications.errors.description")}
             >
-              <Switch
-                checked={settings.notifications.errors()}
-                onChange={(checked) => settings.notifications.setErrors(checked)}
-              />
+              <div data-action="settings-notifications-errors">
+                <Switch
+                  checked={settings.notifications.errors()}
+                  onChange={(checked) => settings.notifications.setErrors(checked)}
+                />
+              </div>
             </SettingsRow>
           </div>
         </div>
@@ -282,6 +291,7 @@ export const SettingsGeneral: Component = () => {
               description={language.t("settings.general.sounds.agent.description")}
             >
               <Select
+                data-action="settings-sounds-agent"
                 options={soundOptions}
                 current={soundOptions.find((o) => o.id === settings.sounds.agent())}
                 value={(o) => o.id}
@@ -306,6 +316,7 @@ export const SettingsGeneral: Component = () => {
               description={language.t("settings.general.sounds.permissions.description")}
             >
               <Select
+                data-action="settings-sounds-permissions"
                 options={soundOptions}
                 current={soundOptions.find((o) => o.id === settings.sounds.permissions())}
                 value={(o) => o.id}
@@ -330,6 +341,7 @@ export const SettingsGeneral: Component = () => {
               description={language.t("settings.general.sounds.errors.description")}
             >
               <Select
+                data-action="settings-sounds-errors"
                 options={soundOptions}
                 current={soundOptions.find((o) => o.id === settings.sounds.errors())}
                 value={(o) => o.id}
@@ -360,21 +372,25 @@ export const SettingsGeneral: Component = () => {
               title={language.t("settings.updates.row.startup.title")}
               description={language.t("settings.updates.row.startup.description")}
             >
-              <Switch
-                checked={settings.updates.startup()}
-                disabled={!platform.checkUpdate}
-                onChange={(checked) => settings.updates.setStartup(checked)}
-              />
+              <div data-action="settings-updates-startup">
+                <Switch
+                  checked={settings.updates.startup()}
+                  disabled={!platform.checkUpdate}
+                  onChange={(checked) => settings.updates.setStartup(checked)}
+                />
+              </div>
             </SettingsRow>
 
             <SettingsRow
               title={language.t("settings.general.row.releaseNotes.title")}
               description={language.t("settings.general.row.releaseNotes.description")}
             >
-              <Switch
-                checked={settings.general.releaseNotes()}
-                onChange={(checked) => settings.general.setReleaseNotes(checked)}
-              />
+              <div data-action="settings-release-notes">
+                <Switch
+                  checked={settings.general.releaseNotes()}
+                  onChange={(checked) => settings.general.setReleaseNotes(checked)}
+                />
+              </div>
             </SettingsRow>
 
             <SettingsRow

+ 2 - 1
packages/app/src/components/settings-keybinds.tsx

@@ -44,7 +44,7 @@ function groupFor(id: string): KeybindGroup {
   if (id === PALETTE_ID) return "General"
   if (id.startsWith("terminal.")) return "Terminal"
   if (id.startsWith("model.") || id.startsWith("agent.") || id.startsWith("mcp.")) return "Model and agent"
-  if (id.startsWith("file.")) return "Navigation"
+  if (id.startsWith("file.") || id.startsWith("fileTree.")) return "Navigation"
   if (id.startsWith("prompt.")) return "Prompt"
   if (
     id.startsWith("session.") ||
@@ -396,6 +396,7 @@ export const SettingsKeybinds: Component = () => {
                         <span class="text-14-regular text-text-strong">{title(id)}</span>
                         <button
                           type="button"
+                          data-keybind-id={id}
                           classList={{
                             "h-8 px-3 rounded-md text-12-regular": true,
                             "bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":

+ 6 - 3
packages/app/src/components/settings-providers.tsx

@@ -123,7 +123,7 @@ export const SettingsProviders: Component = () => {
       </div>
 
       <div class="flex flex-col gap-8 max-w-[720px]">
-        <div class="flex flex-col gap-1">
+        <div class="flex flex-col gap-1" data-component="connected-providers-section">
           <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
           <div class="bg-surface-raised-base px-4 rounded-lg">
             <Show
@@ -225,9 +225,12 @@ export const SettingsProviders: Component = () => {
               )}
             </For>
 
-            <div class="flex items-center justify-between gap-4 h-16 border-b border-border-weak-base last:border-none">
+            <div
+              class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
+              data-component="custom-provider-section"
+            >
               <div class="flex flex-col min-w-0">
-                <div class="flex items-center gap-x-3">
+                <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
                   <ProviderIcon id={icon("synthetic")} class="size-5 shrink-0 icon-strong-base" />
                   <span class="text-14-medium text-text-strong">Custom provider</span>
                   <Tag>{language.t("settings.providers.tag.custom")}</Tag>

+ 39 - 88
packages/app/src/components/status-popover.tsx

@@ -1,4 +1,4 @@
-import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
+import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
 import { createStore, reconcile } from "solid-js/store"
 import { useNavigate } from "@solidjs/router"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -7,30 +7,15 @@ import { Tabs } from "@opencode-ai/ui/tabs"
 import { Button } from "@opencode-ai/ui/button"
 import { Switch } from "@opencode-ai/ui/switch"
 import { Icon } from "@opencode-ai/ui/icon"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { useSync } from "@/context/sync"
 import { useSDK } from "@/context/sdk"
-import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
+import { normalizeServerUrl, useServer } from "@/context/server"
 import { usePlatform } from "@/context/platform"
 import { useLanguage } from "@/context/language"
-import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
 import { DialogSelectServer } from "./dialog-select-server"
 import { showToast } from "@opencode-ai/ui/toast"
-
-type ServerStatus = { healthy: boolean; version?: string }
-
-async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
-  const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
-  const sdk = createOpencodeClient({
-    baseUrl: url,
-    fetch: platform.fetch,
-    signal,
-  })
-  return sdk.global
-    .health()
-    .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
-    .catch(() => ({ healthy: false }))
-}
+import { ServerRow } from "@/components/server/server-row"
+import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
 
 export function StatusPopover() {
   const sync = useSync()
@@ -42,10 +27,11 @@ export function StatusPopover() {
   const navigate = useNavigate()
 
   const [store, setStore] = createStore({
-    status: {} as Record<string, ServerStatus | undefined>,
+    status: {} as Record<string, ServerHealth | undefined>,
     loading: null as string | null,
     defaultServerUrl: undefined as string | undefined,
   })
+  const fetcher = platform.fetch ?? globalThis.fetch
 
   const servers = createMemo(() => {
     const current = server.url
@@ -60,7 +46,7 @@ export function StatusPopover() {
     if (!list.length) return list
     const active = server.url
     const order = new Map(list.map((url, index) => [url, index] as const))
-    const rank = (value?: ServerStatus) => {
+    const rank = (value?: ServerHealth) => {
       if (value?.healthy === true) return 0
       if (value?.healthy === false) return 2
       return 1
@@ -75,10 +61,10 @@ export function StatusPopover() {
   })
 
   async function refreshHealth() {
-    const results: Record<string, ServerStatus> = {}
+    const results: Record<string, ServerHealth> = {}
     await Promise.all(
       servers().map(async (url) => {
-        results[url] = await checkHealth(url, platform)
+        results[url] = await checkServerHealth(url, fetcher)
       }),
     )
     setStore("status", reconcile(results))
@@ -213,78 +199,43 @@ export function StatusPopover() {
                     const isDefault = () => url === store.defaultServerUrl
                     const status = () => store.status[url]
                     const isBlocked = () => status()?.healthy === false
-                    const [truncated, setTruncated] = createSignal(false)
-                    let nameRef: HTMLSpanElement | undefined
-                    let versionRef: HTMLSpanElement | undefined
-
-                    onMount(() => {
-                      const check = () => {
-                        const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
-                        const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
-                        setTruncated(nameTruncated || versionTruncated)
-                      }
-                      check()
-                      window.addEventListener("resize", check)
-                      onCleanup(() => window.removeEventListener("resize", check))
-                    })
-
-                    const tooltipValue = () => {
-                      const name = serverDisplayName(url)
-                      const version = status()?.version
-                      return (
-                        <span class="flex items-center gap-2">
-                          <span>{name}</span>
-                          <Show when={version}>
-                            <span class="text-text-invert-base">{version}</span>
-                          </Show>
-                        </span>
-                      )
-                    }
 
                     return (
-                      <Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
-                        <button
-                          type="button"
-                          class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
-                          classList={{
-                            "opacity-50": isBlocked(),
-                            "hover:bg-surface-raised-base-hover": !isBlocked(),
-                            "cursor-not-allowed": isBlocked(),
-                          }}
-                          aria-disabled={isBlocked()}
-                          onClick={() => {
-                            if (isBlocked()) return
-                            server.setActive(url)
-                            navigate("/")
-                          }}
+                      <button
+                        type="button"
+                        class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
+                        classList={{
+                          "hover:bg-surface-raised-base-hover": !isBlocked(),
+                          "cursor-not-allowed": isBlocked(),
+                        }}
+                        aria-disabled={isBlocked()}
+                        onClick={() => {
+                          if (isBlocked()) return
+                          server.setActive(url)
+                          navigate("/")
+                        }}
+                      >
+                        <ServerRow
+                          url={url}
+                          status={status()}
+                          dimmed={isBlocked()}
+                          class="flex items-center gap-2 w-full min-w-0"
+                          nameClass="text-14-regular text-text-base truncate"
+                          versionClass="text-12-regular text-text-weak truncate"
+                          badge={
+                            <Show when={isDefault()}>
+                              <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
+                                {language.t("common.default")}
+                              </span>
+                            </Show>
+                          }
                         >
-                          <div
-                            classList={{
-                              "size-1.5 rounded-full shrink-0": true,
-                              "bg-icon-success-base": status()?.healthy === true,
-                              "bg-icon-critical-base": status()?.healthy === false,
-                              "bg-border-weak-base": status() === undefined,
-                            }}
-                          />
-                          <span ref={nameRef} class="text-14-regular text-text-base truncate">
-                            {serverDisplayName(url)}
-                          </span>
-                          <Show when={status()?.version}>
-                            <span ref={versionRef} class="text-12-regular text-text-weak truncate">
-                              {status()?.version}
-                            </span>
-                          </Show>
-                          <Show when={isDefault()}>
-                            <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
-                              {language.t("common.default")}
-                            </span>
-                          </Show>
                           <div class="flex-1" />
                           <Show when={isActive()}>
                             <Icon name="check" size="small" class="text-icon-weak shrink-0" />
                           </Show>
-                        </button>
-                      </Tooltip>
+                        </ServerRow>
+                      </button>
                     )
                   }}
                 </For>

+ 83 - 18
packages/app/src/components/terminal.tsx

@@ -1,5 +1,6 @@
 import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
 import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
+import { usePlatform } from "@/context/platform"
 import { useSDK } from "@/context/sdk"
 import { monoFontFamily, useSettings } from "@/context/settings"
 import { SerializeAddon } from "@/addons/serialize"
@@ -7,6 +8,7 @@ import { LocalPTY } from "@/context/terminal"
 import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
 import { useLanguage } from "@/context/language"
 import { showToast } from "@opencode-ai/ui/toast"
+import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
 
 export interface TerminalProps extends ComponentProps<"div"> {
   pty: LocalPTY
@@ -52,6 +54,7 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
 }
 
 export const Terminal = (props: TerminalProps) => {
+  const platform = usePlatform()
   const sdk = useSDK()
   const settings = useSettings()
   const theme = useTheme()
@@ -68,6 +71,7 @@ export const Terminal = (props: TerminalProps) => {
   let handleTextareaBlur: () => void
   let disposed = false
   const cleanups: VoidFunction[] = []
+  let tail = local.pty.tail ?? ""
 
   const cleanup = () => {
     if (!cleanups.length) return
@@ -108,17 +112,13 @@ export const Terminal = (props: TerminalProps) => {
     const colors = getTerminalColors()
     setTerminalColors(colors)
     if (!term) return
-    const setOption = (term as unknown as { setOption?: (key: string, value: TerminalColors) => void }).setOption
-    if (!setOption) return
-    setOption("theme", colors)
+    setOptionIfSupported(term, "theme", colors)
   })
 
   createEffect(() => {
     const font = monoFontFamily(settings.appearance.font())
     if (!term) return
-    const setOption = (term as unknown as { setOption?: (key: string, value: string) => void }).setOption
-    if (!setOption) return
-    setOption("fontFamily", font)
+    setOptionIfSupported(term, "fontFamily", font)
   })
 
   const focusTerminal = () => {
@@ -135,6 +135,22 @@ export const Terminal = (props: TerminalProps) => {
     focusTerminal()
   }
 
+  const handleLinkClick = (event: MouseEvent) => {
+    if (!event.shiftKey && !event.ctrlKey && !event.metaKey) return
+    if (event.altKey) return
+    if (event.button !== 0) return
+
+    const t = term
+    if (!t) return
+
+    const text = getHoveredLinkText(t)
+    if (!text) return
+
+    event.preventDefault()
+    event.stopImmediatePropagation()
+    platform.openLink(text)
+  }
+
   onMount(() => {
     const run = async () => {
       const loaded = await loadGhostty()
@@ -146,6 +162,7 @@ export const Terminal = (props: TerminalProps) => {
       const once = { value: false }
 
       const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
+      url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
       if (window.__OPENCODE__?.serverPassword) {
         url.username = "opencode"
         url.password = window.__OPENCODE__?.serverPassword
@@ -166,6 +183,7 @@ export const Terminal = (props: TerminalProps) => {
         fontSize: 14,
         fontFamily: monoFontFamily(settings.appearance.font()),
         allowTransparency: true,
+        convertEol: true,
         theme: terminalColors(),
         scrollback: 10_000,
         ghostty: g,
@@ -229,16 +247,20 @@ export const Terminal = (props: TerminalProps) => {
 
       const fit = new mod.FitAddon()
       const serializer = new SerializeAddon()
-      cleanups.push(() => (fit as unknown as { dispose?: VoidFunction }).dispose?.())
+      cleanups.push(() => disposeIfDisposable(fit))
       t.loadAddon(serializer)
       t.loadAddon(fit)
       fitAddon = fit
       serializeAddon = serializer
 
       t.open(container)
+
       container.addEventListener("pointerdown", handlePointerDown)
       cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown))
 
+      container.addEventListener("click", handleLinkClick, { capture: true })
+      cleanups.push(() => container.removeEventListener("click", handleLinkClick, { capture: true }))
+
       handleTextareaFocus = () => {
         t.options.cursorBlink = true
       }
@@ -253,15 +275,11 @@ export const Terminal = (props: TerminalProps) => {
 
       focusTerminal()
 
+      fit.fit()
+
       if (local.pty.buffer) {
-        if (local.pty.rows && local.pty.cols) {
-          t.resize(local.pty.cols, local.pty.rows)
-        }
         t.write(local.pty.buffer, () => {
-          if (local.pty.scrollY) {
-            t.scrollToLine(local.pty.scrollY)
-          }
-          fitAddon.fit()
+          if (local.pty.scrollY) t.scrollToLine(local.pty.scrollY)
         })
       }
 
@@ -269,6 +287,27 @@ export const Terminal = (props: TerminalProps) => {
       handleResize = () => fit.fit()
       window.addEventListener("resize", handleResize)
       cleanups.push(() => window.removeEventListener("resize", handleResize))
+      const limit = 16_384
+      const min = 32
+      const windowMs = 750
+      const seed = tail.length > limit ? tail.slice(-limit) : tail
+      let sync = seed.length >= min
+      let syncUntil = 0
+      const stopSync = () => {
+        sync = false
+        syncUntil = 0
+      }
+
+      const overlap = (data: string) => {
+        if (!seed) return 0
+        const max = Math.min(seed.length, data.length)
+        if (max < min) return 0
+        for (let i = max; i >= min; i--) {
+          if (seed.slice(-i) === data.slice(0, i)) return i
+        }
+        return 0
+      }
+
       const onResize = t.onResize(async (size) => {
         if (socket.readyState === WebSocket.OPEN) {
           await sdk.client.pty
@@ -282,25 +321,27 @@ export const Terminal = (props: TerminalProps) => {
             .catch(() => {})
         }
       })
-      cleanups.push(() => (onResize as unknown as { dispose?: VoidFunction }).dispose?.())
+      cleanups.push(() => disposeIfDisposable(onResize))
       const onData = t.onData((data) => {
+        if (data) stopSync()
         if (socket.readyState === WebSocket.OPEN) {
           socket.send(data)
         }
       })
-      cleanups.push(() => (onData as unknown as { dispose?: VoidFunction }).dispose?.())
+      cleanups.push(() => disposeIfDisposable(onData))
       const onKey = t.onKey((key) => {
         if (key.key == "Enter") {
           props.onSubmit?.()
         }
       })
-      cleanups.push(() => (onKey as unknown as { dispose?: VoidFunction }).dispose?.())
+      cleanups.push(() => disposeIfDisposable(onKey))
       // t.onScroll((ydisp) => {
       // console.log("Scroll position:", ydisp)
       // })
 
       const handleOpen = () => {
         local.onConnect?.()
+        if (sync) syncUntil = Date.now() + windowMs
         sdk.client.pty
           .update({
             ptyID: local.pty.id,
@@ -315,7 +356,30 @@ export const Terminal = (props: TerminalProps) => {
       cleanups.push(() => socket.removeEventListener("open", handleOpen))
 
       const handleMessage = (event: MessageEvent) => {
-        t.write(event.data)
+        if (disposed) return
+        const data = typeof event.data === "string" ? event.data : ""
+        if (!data) return
+
+        const next = (() => {
+          if (!sync) return data
+          if (syncUntil && Date.now() > syncUntil) {
+            stopSync()
+            return data
+          }
+          const n = overlap(data)
+          if (!n) {
+            stopSync()
+            return data
+          }
+          const trimmed = data.slice(n)
+          if (trimmed) stopSync()
+          return trimmed
+        })()
+
+        if (!next) return
+
+        t.write(next)
+        tail = next.length >= limit ? next.slice(-limit) : (tail + next).slice(-limit)
       }
       socket.addEventListener("message", handleMessage)
       cleanups.push(() => socket.removeEventListener("message", handleMessage))
@@ -369,6 +433,7 @@ export const Terminal = (props: TerminalProps) => {
       props.onCleanup({
         ...local.pty,
         buffer,
+        tail,
         rows: t.rows,
         cols: t.cols,
         scrollY: t.getViewportY(),

+ 63 - 0
packages/app/src/components/titlebar-history.test.ts

@@ -0,0 +1,63 @@
+import { describe, expect, test } from "bun:test"
+import { applyPath, backPath, forwardPath, type TitlebarHistory } from "./titlebar-history"
+
+function history(): TitlebarHistory {
+  return { stack: [], index: 0, action: undefined }
+}
+
+describe("titlebar history", () => {
+  test("append and trim keeps max bounded", () => {
+    let state = history()
+    state = applyPath(state, "/", 3)
+    state = applyPath(state, "/a", 3)
+    state = applyPath(state, "/b", 3)
+    state = applyPath(state, "/c", 3)
+
+    expect(state.stack).toEqual(["/a", "/b", "/c"])
+    expect(state.stack.length).toBe(3)
+    expect(state.index).toBe(2)
+  })
+
+  test("back and forward indexes stay correct after trimming", () => {
+    let state = history()
+    state = applyPath(state, "/", 3)
+    state = applyPath(state, "/a", 3)
+    state = applyPath(state, "/b", 3)
+    state = applyPath(state, "/c", 3)
+
+    expect(state.stack).toEqual(["/a", "/b", "/c"])
+    expect(state.index).toBe(2)
+
+    const back = backPath(state)
+    expect(back?.to).toBe("/b")
+    expect(back?.state.index).toBe(1)
+
+    const afterBack = applyPath(back!.state, back!.to, 3)
+    expect(afterBack.stack).toEqual(["/a", "/b", "/c"])
+    expect(afterBack.index).toBe(1)
+
+    const forward = forwardPath(afterBack)
+    expect(forward?.to).toBe("/c")
+    expect(forward?.state.index).toBe(2)
+
+    const afterForward = applyPath(forward!.state, forward!.to, 3)
+    expect(afterForward.stack).toEqual(["/a", "/b", "/c"])
+    expect(afterForward.index).toBe(2)
+  })
+
+  test("action-driven navigation does not push duplicate history entries", () => {
+    const state: TitlebarHistory = {
+      stack: ["/", "/a", "/b"],
+      index: 2,
+      action: undefined,
+    }
+
+    const back = backPath(state)
+    expect(back?.to).toBe("/a")
+
+    const next = applyPath(back!.state, back!.to, 10)
+    expect(next.stack).toEqual(["/", "/a", "/b"])
+    expect(next.index).toBe(1)
+    expect(next.action).toBeUndefined()
+  })
+})

+ 57 - 0
packages/app/src/components/titlebar-history.ts

@@ -0,0 +1,57 @@
+export const MAX_TITLEBAR_HISTORY = 100
+
+export type TitlebarAction = "back" | "forward" | undefined
+
+export type TitlebarHistory = {
+  stack: string[]
+  index: number
+  action: TitlebarAction
+}
+
+export function applyPath(state: TitlebarHistory, current: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
+  if (!state.stack.length) {
+    const stack = current === "/" ? ["/"] : ["/", current]
+    return { stack, index: stack.length - 1, action: undefined }
+  }
+
+  const active = state.stack[state.index]
+  if (current === active) {
+    if (!state.action) return state
+    return { ...state, action: undefined }
+  }
+
+  if (state.action) return { ...state, action: undefined }
+
+  return pushPath(state, current, max)
+}
+
+export function pushPath(state: TitlebarHistory, path: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
+  const stack = state.stack.slice(0, state.index + 1).concat(path)
+  const next = trimHistory(stack, stack.length - 1, max)
+  return { ...state, ...next, action: undefined }
+}
+
+export function trimHistory(stack: string[], index: number, max = MAX_TITLEBAR_HISTORY) {
+  if (stack.length <= max) return { stack, index }
+  const cut = stack.length - max
+  return {
+    stack: stack.slice(cut),
+    index: Math.max(0, index - cut),
+  }
+}
+
+export function backPath(state: TitlebarHistory) {
+  if (state.index <= 0) return
+  const index = state.index - 1
+  const to = state.stack[index]
+  if (!to) return
+  return { state: { ...state, index, action: "back" as const }, to }
+}
+
+export function forwardPath(state: TitlebarHistory) {
+  if (state.index >= state.stack.length - 1) return
+  const index = state.index + 1
+  const to = state.stack[index]
+  if (!to) return
+  return { state: { ...state, index, action: "forward" as const }, to }
+}

+ 56 - 43
packages/app/src/components/titlebar.tsx

@@ -11,6 +11,7 @@ import { useLayout } from "@/context/layout"
 import { usePlatform } from "@/context/platform"
 import { useCommand } from "@/context/command"
 import { useLanguage } from "@/context/language"
+import { applyPath, backPath, forwardPath } from "./titlebar-history"
 
 export function Titlebar() {
   const layout = useLayout()
@@ -24,6 +25,8 @@ export function Titlebar() {
   const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
   const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
   const web = createMemo(() => platform.platform === "web")
+  const zoom = () => platform.webviewZoom?.() ?? 1
+  const minHeight = () => (mac() ? `${40 / zoom()}px` : undefined)
 
   const [history, setHistory] = createStore({
     stack: [] as string[],
@@ -37,25 +40,9 @@ export function Titlebar() {
     const current = path()
 
     untrack(() => {
-      if (!history.stack.length) {
-        const stack = current === "/" ? ["/"] : ["/", current]
-        setHistory({ stack, index: stack.length - 1 })
-        return
-      }
-
-      const active = history.stack[history.index]
-      if (current === active) {
-        if (history.action) setHistory("action", undefined)
-        return
-      }
-
-      if (history.action) {
-        setHistory("action", undefined)
-        return
-      }
-
-      const next = history.stack.slice(0, history.index + 1).concat(current)
-      setHistory({ stack: next, index: next.length - 1 })
+      const next = applyPath(history, current)
+      if (next === history) return
+      setHistory(next)
     })
   })
 
@@ -63,29 +50,47 @@ export function Titlebar() {
   const canForward = createMemo(() => history.index < history.stack.length - 1)
 
   const back = () => {
-    if (!canBack()) return
-    const index = history.index - 1
-    const to = history.stack[index]
-    if (!to) return
-    setHistory({ index, action: "back" })
-    navigate(to)
+    const next = backPath(history)
+    if (!next) return
+    setHistory(next.state)
+    navigate(next.to)
   }
 
   const forward = () => {
-    if (!canForward()) return
-    const index = history.index + 1
-    const to = history.stack[index]
-    if (!to) return
-    setHistory({ index, action: "forward" })
-    navigate(to)
+    const next = forwardPath(history)
+    if (!next) return
+    setHistory(next.state)
+    navigate(next.to)
   }
 
+  command.register(() => [
+    {
+      id: "common.goBack",
+      title: language.t("common.goBack"),
+      category: language.t("command.category.view"),
+      onSelect: back,
+    },
+    {
+      id: "common.goForward",
+      title: language.t("common.goForward"),
+      category: language.t("command.category.view"),
+      onSelect: forward,
+    },
+  ])
+
   const getWin = () => {
     if (platform.platform !== "desktop") return
 
     const tauri = (
       window as unknown as {
-        __TAURI__?: { window?: { getCurrentWindow?: () => { startDragging?: () => Promise<void> } } }
+        __TAURI__?: {
+          window?: {
+            getCurrentWindow?: () => {
+              startDragging?: () => Promise<void>
+              toggleMaximize?: () => Promise<void>
+            }
+          }
+        }
       }
     ).__TAURI__
     if (!tauri?.window?.getCurrentWindow) return
@@ -131,21 +136,33 @@ export function Titlebar() {
     void win.startDragging().catch(() => undefined)
   }
 
+  const maximize = (e: MouseEvent) => {
+    if (platform.platform !== "desktop") return
+    if (interactive(e.target)) return
+    if (e.target instanceof Element && e.target.closest("[data-tauri-decorum-tb]")) return
+
+    const win = getWin()
+    if (!win?.toggleMaximize) return
+
+    e.preventDefault()
+    void win.toggleMaximize().catch(() => undefined)
+  }
+
   return (
     <header
       class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
-      data-tauri-drag-region
+      style={{ "min-height": minHeight() }}
+      onMouseDown={drag}
+      onDblClick={maximize}
     >
       <div
         classList={{
           "flex items-center min-w-0": true,
           "pl-2": !mac(),
         }}
-        onMouseDown={drag}
-        data-tauri-drag-region
       >
         <Show when={mac()}>
-          <div class="w-[72px] h-full shrink-0" data-tauri-drag-region />
+          <div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
           <div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
             <IconButton
               icon="menu"
@@ -219,13 +236,10 @@ export function Titlebar() {
             </Tooltip>
           </div>
         </div>
-        <div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" data-tauri-drag-region />
+        <div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
       </div>
 
-      <div
-        class="min-w-0 flex items-center justify-center pointer-events-none lg:absolute lg:inset-0 lg:flex lg:items-center lg:justify-center"
-        data-tauri-drag-region
-      >
+      <div class="min-w-0 flex items-center justify-center pointer-events-none lg:absolute lg:inset-0 lg:flex lg:items-center lg:justify-center">
         <div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
       </div>
 
@@ -235,9 +249,8 @@ export function Titlebar() {
           "pr-6": !windows(),
         }}
         onMouseDown={drag}
-        data-tauri-drag-region
       >
-        <div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" data-tauri-drag-region />
+        <div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" />
         <Show when={windows()}>
           <div class="w-6 shrink-0" />
           <div data-tauri-decorum-tb class="flex flex-row" />

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

@@ -0,0 +1,43 @@
+import { describe, expect, test } from "bun:test"
+import { formatKeybind, matchKeybind, parseKeybind } from "./command"
+
+describe("command keybind helpers", () => {
+  test("parseKeybind handles aliases and multiple combos", () => {
+    const keybinds = parseKeybind("control+option+k, mod+shift+comma")
+
+    expect(keybinds).toHaveLength(2)
+    expect(keybinds[0]).toEqual({
+      key: "k",
+      ctrl: true,
+      meta: false,
+      shift: false,
+      alt: true,
+    })
+    expect(keybinds[1]?.shift).toBe(true)
+    expect(keybinds[1]?.key).toBe("comma")
+    expect(Boolean(keybinds[1]?.ctrl || keybinds[1]?.meta)).toBe(true)
+  })
+
+  test("parseKeybind treats none and empty as disabled", () => {
+    expect(parseKeybind("none")).toEqual([])
+    expect(parseKeybind("")).toEqual([])
+  })
+
+  test("matchKeybind normalizes punctuation keys", () => {
+    const keybinds = parseKeybind("ctrl+comma, shift+plus, meta+space")
+
+    expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true }))).toBe(true)
+    expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: "+", shiftKey: true }))).toBe(true)
+    expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: " ", metaKey: true }))).toBe(true)
+    expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
+  })
+
+  test("formatKeybind returns human readable output", () => {
+    const display = formatKeybind("ctrl+alt+arrowup")
+
+    expect(display).toContain("↑")
+    expect(display.includes("Ctrl") || display.includes("⌃")).toBe(true)
+    expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
+    expect(formatKeybind("none")).toBe("")
+  })
+})

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

@@ -0,0 +1,25 @@
+import { describe, expect, test } from "bun:test"
+import { upsertCommandRegistration } from "./command"
+
+describe("upsertCommandRegistration", () => {
+  test("replaces keyed registrations", () => {
+    const one = () => [{ id: "one", title: "One" }]
+    const two = () => [{ id: "two", title: "Two" }]
+
+    const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two })
+
+    expect(next).toHaveLength(1)
+    expect(next[0]?.options).toBe(two)
+  })
+
+  test("keeps unkeyed registrations additive", () => {
+    const one = () => [{ id: "one", title: "One" }]
+    const two = () => [{ id: "two", title: "Two" }]
+
+    const next = upsertCommandRegistration([{ options: one }], { options: two })
+
+    expect(next).toHaveLength(2)
+    expect(next[0]?.options).toBe(two)
+    expect(next[1]?.options).toBe(one)
+  })
+})

+ 38 - 10
packages/app/src/context/command.tsx

@@ -64,6 +64,16 @@ export type CommandCatalogItem = {
   slash?: string
 }
 
+export type CommandRegistration = {
+  key?: string
+  options: Accessor<CommandOption[]>
+}
+
+export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
+  if (entry.key === undefined) return [entry, ...registrations]
+  return [entry, ...registrations.filter((x) => x.key !== entry.key)]
+}
+
 export function parseKeybind(config: string): Keybind[] {
   if (!config || config === "none") return []
 
@@ -166,9 +176,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
     const settings = useSettings()
     const language = useLanguage()
     const [store, setStore] = createStore({
-      registrations: [] as Accessor<CommandOption[]>[],
+      registrations: [] as CommandRegistration[],
       suspendCount: 0,
     })
+    const warnedDuplicates = new Set<string>()
 
     const [catalog, setCatalog, _, catalogReady] = persisted(
       Persist.global("command.catalog.v1"),
@@ -187,8 +198,14 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
       const all: CommandOption[] = []
 
       for (const reg of store.registrations) {
-        for (const opt of reg()) {
-          if (seen.has(opt.id)) continue
+        for (const opt of reg.options()) {
+          if (seen.has(opt.id)) {
+            if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
+              warnedDuplicates.add(opt.id)
+              console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`)
+            }
+            continue
+          }
           seen.add(opt.id)
           all.push(opt)
         }
@@ -296,14 +313,25 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
       document.removeEventListener("keydown", handleKeyDown)
     })
 
+    function register(cb: () => CommandOption[]): void
+    function register(key: string, cb: () => CommandOption[]): void
+    function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) {
+      const id = typeof key === "string" ? key : undefined
+      const next = typeof key === "function" ? key : cb
+      if (!next) return
+      const options = createMemo(next)
+      const entry: CommandRegistration = {
+        key: id,
+        options,
+      }
+      setStore("registrations", (arr) => upsertCommandRegistration(arr, entry))
+      onCleanup(() => {
+        setStore("registrations", (arr) => arr.filter((x) => x !== entry))
+      })
+    }
+
     return {
-      register(cb: () => CommandOption[]) {
-        const results = createMemo(cb)
-        setStore("registrations", (arr) => [results, ...arr])
-        onCleanup(() => {
-          setStore("registrations", (arr) => arr.filter((x) => x !== results))
-        })
-      },
+      register,
       trigger(id: string, source?: "palette" | "keybind" | "slash") {
         run(id, source)
       },

+ 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()
+    })
+  })
+})

+ 94 - 64
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 { useParams } from "@solidjs/router"
 import { Persist, persisted } from "@/utils/persist"
+import { createScopedCache } from "@/utils/scoped-cache"
 import type { SelectedLineRange } from "@/context/file"
 
 export type LineComment = {
@@ -18,28 +19,28 @@ type CommentFocus = { file: string; id: string }
 const WORKSPACE_KEY = "__workspace__"
 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({
     focus: null as CommentFocus | null,
     active: null as CommentFocus | null,
+    all: aggregate(store.comments),
   })
 
   const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
@@ -59,6 +60,7 @@ function createCommentSession(dir: string, id: string | undefined) {
 
     batch(() => {
       setStore("comments", input.file, (items) => [...(items ?? []), next])
+      setState("all", (items) => insert(items, next))
       setFocus({ file: input.file, id: next.id })
     })
 
@@ -66,28 +68,72 @@ function createCommentSession(dir: string, id: string | undefined) {
   }
 
   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 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)
-  })
+  const clear = () => {
+    batch(() => {
+      setStore("comments", reconcile({}))
+      setState("all", [])
+      setFocus(null)
+      setActive(null)
+    })
+  }
 
   return {
-    ready,
     list,
-    all,
+    all: () => state.all,
     add,
     remove,
-    focus: createMemo(() => state.focus),
+    clear,
+    focus: () => state.focus,
     setFocus,
     clearFocus: () => setFocus(null),
-    active: createMemo(() => state.active),
+    active: () => state.active,
     setActive,
     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,
   }
 }
 
@@ -96,44 +142,27 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
   gate: false,
   init: () => {
     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 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))
@@ -144,6 +173,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
       all: () => session().all(),
       add: (input: Omit<LineComment, "id" | "time">) => session().add(input),
       remove: (file: string, id: string) => session().remove(file, id),
+      clear: () => session().clear(),
       focus: () => session().focus(),
       setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
       clearFocus: () => session().clearFocus(),

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно