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

Merge public/dev and resolve conflicts in server and webgui

paviko 1 месяц назад
Родитель
Сommit
7773d62503
100 измененных файлов с 3406 добавлено и 1010 удалено
  1. 69 0
      .github/workflows/docs-update.yml
  2. 13 5
      .github/workflows/publish.yml
  3. 1 1
      .github/workflows/review.yml
  4. 29 0
      .github/workflows/stale-issues.yml
  5. 3 3
      .github/workflows/sync-zed-extension.yml
  6. 3 0
      .opencode/agent/docs.md
  7. 1 0
      .opencode/agent/triage.md
  8. 6 0
      .opencode/skill/test-skill/SKILL.md
  9. 1 28
      AGENTS.md
  10. 4 4
      CONTRIBUTING.md
  11. 2 2
      README.md
  12. 8 0
      STATS.md
  13. 200 210
      bun.lock
  14. 3 3
      flake.lock
  15. 9 0
      infra/app.ts
  16. 3 0
      infra/console.ts
  17. 0 10
      infra/desktop.ts
  18. 89 35
      install
  19. 1 1
      nix/hashes.json
  20. 6 3
      package.json
  21. 1 0
      packages/app/.gitignore
  22. 0 0
      packages/app/AGENTS.md
  23. 34 0
      packages/app/README.md
  24. 0 0
      packages/app/bunfig.toml
  25. 0 0
      packages/app/happydom.ts
  26. 53 0
      packages/app/index.html
  27. 62 0
      packages/app/package.json
  28. 17 0
      packages/app/public/_headers
  29. 0 0
      packages/app/public/apple-touch-icon.png
  30. 0 0
      packages/app/public/favicon-96x96.png
  31. 0 0
      packages/app/public/favicon.ico
  32. 0 0
      packages/app/public/favicon.svg
  33. 0 0
      packages/app/public/site.webmanifest
  34. 0 0
      packages/app/public/social-share-zen.png
  35. 0 0
      packages/app/public/social-share.png
  36. 0 0
      packages/app/public/web-app-manifest-192x192.png
  37. 0 0
      packages/app/public/web-app-manifest-512x512.png
  38. 0 0
      packages/app/src/addons/serialize.test.ts
  39. 0 0
      packages/app/src/addons/serialize.ts
  40. 95 0
      packages/app/src/app.tsx
  41. 19 17
      packages/app/src/components/dialog-connect-provider.tsx
  42. 180 0
      packages/app/src/components/dialog-edit-project.tsx
  43. 14 7
      packages/app/src/components/dialog-manage-models.tsx
  44. 5 6
      packages/app/src/components/dialog-select-file.tsx
  45. 91 0
      packages/app/src/components/dialog-select-mcp.tsx
  46. 11 20
      packages/app/src/components/dialog-select-model-unpaid.tsx
  47. 1 2
      packages/app/src/components/dialog-select-model.tsx
  48. 2 12
      packages/app/src/components/dialog-select-provider.tsx
  49. 5 5
      packages/app/src/components/file-tree.tsx
  50. 70 23
      packages/app/src/components/header.tsx
  51. 0 0
      packages/app/src/components/link.tsx
  52. 284 108
      packages/app/src/components/prompt-input.tsx
  53. 57 0
      packages/app/src/components/session-context-usage.tsx
  54. 40 0
      packages/app/src/components/session-lsp-indicator.tsx
  55. 36 0
      packages/app/src/components/session-mcp-indicator.tsx
  56. 32 0
      packages/app/src/components/status-bar.tsx
  57. 242 0
      packages/app/src/components/terminal.tsx
  58. 25 7
      packages/app/src/context/command.tsx
  59. 0 0
      packages/app/src/context/global-sdk.tsx
  60. 140 20
      packages/app/src/context/global-sync.tsx
  61. 20 2
      packages/app/src/context/layout.tsx
  62. 51 56
      packages/app/src/context/local.tsx
  63. 0 0
      packages/app/src/context/notification.tsx
  64. 3 0
      packages/app/src/context/platform.tsx
  65. 0 0
      packages/app/src/context/prompt.tsx
  66. 0 0
      packages/app/src/context/sdk.tsx
  67. 44 20
      packages/app/src/context/sync.tsx
  68. 38 22
      packages/app/src/context/terminal.tsx
  69. 0 0
      packages/app/src/custom-elements.d.ts
  70. 2 0
      packages/app/src/entry.tsx
  71. 0 0
      packages/app/src/env.d.ts
  72. 0 0
      packages/app/src/hooks/use-providers.ts
  73. 0 0
      packages/app/src/index.css
  74. 0 0
      packages/app/src/index.ts
  75. 9 2
      packages/app/src/pages/directory-layout.tsx
  76. 47 20
      packages/app/src/pages/error.tsx
  77. 0 0
      packages/app/src/pages/home.tsx
  78. 465 240
      packages/app/src/pages/layout.tsx
  79. 208 82
      packages/app/src/pages/session.tsx
  80. 0 0
      packages/app/src/sst-env.d.ts
  81. 0 0
      packages/app/src/utils/dom.ts
  82. 0 0
      packages/app/src/utils/id.ts
  83. 0 0
      packages/app/src/utils/index.ts
  84. 0 0
      packages/app/src/utils/persist.ts
  85. 0 0
      packages/app/src/utils/prompt.ts
  86. 0 0
      packages/app/src/utils/solid-dnd.tsx
  87. 0 0
      packages/app/src/utils/speech.ts
  88. 0 0
      packages/app/sst-env.d.ts
  89. 11 5
      packages/app/tsconfig.json
  90. 15 0
      packages/app/vite.config.ts
  91. 0 0
      packages/app/vite.js
  92. 1 1
      packages/console/app/package.json
  93. 4 4
      packages/console/app/src/config.ts
  94. 31 23
      packages/console/app/src/routes/auth/callback.ts
  95. 365 0
      packages/console/app/src/routes/bench/[id].tsx
  96. 86 0
      packages/console/app/src/routes/bench/index.tsx
  97. 29 0
      packages/console/app/src/routes/bench/submission.ts
  98. 1 0
      packages/console/app/src/routes/download/[platform].ts
  99. 5 0
      packages/console/app/src/routes/download/index.tsx
  100. 4 1
      packages/console/app/src/routes/download/types.ts

+ 69 - 0
.github/workflows/docs-update.yml

@@ -0,0 +1,69 @@
+name: Docs Update
+
+on:
+  schedule:
+    - cron: "0 */12 * * *"
+  workflow_dispatch:
+
+jobs:
+  update-docs:
+    if: github.repository == 'sst/opencode'
+    runs-on: blacksmith-4vcpu-ubuntu-2404
+    permissions:
+      id-token: write
+      contents: write
+      pull-requests: write
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0 # Fetch full history to access commits
+
+      - name: Setup Bun
+        uses: ./.github/actions/setup-bun
+
+      - name: Get recent commits
+        id: commits
+        run: |
+          COMMITS=$(git log --since="4 hours ago" --pretty=format:"- %h %s" 2>/dev/null || echo "")
+          if [ -z "$COMMITS" ]; then
+            echo "No commits in the last 4 hours"
+            echo "has_commits=false" >> $GITHUB_OUTPUT
+          else
+            echo "has_commits=true" >> $GITHUB_OUTPUT
+            {
+              echo "list<<EOF"
+              echo "$COMMITS"
+              echo "EOF"
+            } >> $GITHUB_OUTPUT
+          fi
+
+      - name: Run opencode
+        if: steps.commits.outputs.has_commits == 'true'
+        uses: sst/opencode/github@latest
+        env:
+          OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
+        with:
+          model: opencode/gpt-5.2
+          agent: docs
+          prompt: |
+            Review the following commits from the last 4 hours and identify any new features that may need documentation.
+
+            <recent_commits>
+            ${{ steps.commits.outputs.list }}
+            </recent_commits>
+
+            Steps:
+            1. For each commit that looks like a new feature or significant change:
+               - Read the changed files to understand what was added
+               - Check if the feature is already documented in packages/web/src/content/docs/*
+            2. If you find undocumented features:
+               - Update the relevant documentation files in packages/web/src/content/docs/*
+               - Follow the existing documentation style and structure
+               - Make sure to document the feature clearly with examples where appropriate
+            3. If all new features are already documented, report that no updates are needed
+            4. If you are creating a new documentation file be sure to update packages/web/astro.config.mjs too.
+
+            Focus on user-facing features and API changes. Skip internal refactors, bug fixes, and test updates unless they affect user-facing behavior.
+            Don't feel the need to document every little thing. It is perfectly okay to make 0 changes at all.
+            Try to keep documentation only for large features or changes that already have a good spot to be documented.

+ 13 - 5
.github/workflows/publish.yml

@@ -79,6 +79,12 @@ jobs:
           AUR_KEY: ${{ secrets.AUR_KEY }}
           GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
           NPM_CONFIG_PROVENANCE: false
+
+      - uses: actions/upload-artifact@v4
+        with:
+          name: opencode-cli
+          path: packages/opencode/dist
+
     outputs:
       release: ${{ steps.publish.outputs.release }}
       tag: ${{ steps.publish.outputs.tag }}
@@ -99,6 +105,8 @@ jobs:
             target: x86_64-pc-windows-msvc
           - host: blacksmith-4vcpu-ubuntu-2404
             target: x86_64-unknown-linux-gnu
+          - host: blacksmith-4vcpu-ubuntu-2404-arm
+            target: aarch64-unknown-linux-gnu
     runs-on: ${{ matrix.settings.host }}
     steps:
       - uses: actions/checkout@v3
@@ -143,13 +151,12 @@ jobs:
 
       - uses: Swatinem/rust-cache@v2
         with:
-          workspaces: packages/tauri/src-tauri
+          workspaces: packages/desktop/src-tauri
           shared-key: ${{ matrix.settings.target }}
 
       - name: Prepare
-        if: inputs.bump || inputs.version
         run: |
-          cd packages/tauri
+          cd packages/desktop
           bun ./scripts/prepare.ts
         env:
           OPENCODE_VERSION: ${{ needs.publish.outputs.version }}
@@ -159,6 +166,7 @@ jobs:
           OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
           RUST_TARGET: ${{ matrix.settings.target }}
           GH_TOKEN: ${{ github.token }}
+          GITHUB_RUN_ID: ${{ github.run_id }}
 
       # Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
       - name: Install tauri-cli from portable appimage branch
@@ -183,10 +191,10 @@ jobs:
           APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
           APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
         with:
-          projectPath: packages/tauri
+          projectPath: packages/desktop
           uploadWorkflowArtifacts: true
           tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
-          args: --target ${{ matrix.settings.target }} --config src-tauri/tauri.prod.conf.json
+          args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
           updaterJsonPreferNsis: true
           releaseId: ${{ needs.publish.outputs.release }}
           tagName: ${{ needs.publish.outputs.tag }}

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

@@ -64,7 +64,7 @@ jobs:
           Please check all the code changes in this pull request against the style guide, also look for any bugs if they exist. Diffs are important but make sure you read the entire file to get proper context. Make it clear the suggestions are merely suggestions and the human can decide what to do
 
           When critiquing code against the style guide, be sure that the code is ACTUALLY in violation, don't complain about else statements if they already use early returns there. You may complain about excessive nesting though, regardless of else statement usage.
-          When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simpliest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts)
+          When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simplest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts)
 
           Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block.
           If you are writing suggested fixes, BE SURE THAT the change you are recommending is actually valid typescript, often I have seen missing closing "}" or other syntax errors.

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

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

+ 3 - 3
.github/workflows/sync-zed-extension.yml

@@ -2,8 +2,8 @@ name: "sync-zed-extension"
 
 on:
   workflow_dispatch:
-  # release:
-  #   types: [published]
+  release:
+    types: [published]
 
 jobs:
   zed:
@@ -31,4 +31,4 @@ jobs:
         run: |
           ./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }}
         env:
-          GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
+          ZED_EXTENSIONS_PAT: ${{ secrets.ZED_EXTENSIONS_PAT }}

+ 3 - 0
.opencode/agent/docs.md

@@ -1,11 +1,14 @@
 ---
 description: ALWAYS use this when writing docs
+color: "#38A3EE"
 ---
 
 You are an expert technical documentation writer
 
 You are not verbose
 
+Use a relaxed and friendly tone
+
 The title of the page should be a word or a 2-3 word phrase
 
 The description should be one short line, should not start with "The", should

+ 1 - 0
.opencode/agent/triage.md

@@ -2,6 +2,7 @@
 mode: primary
 hidden: true
 model: opencode/claude-haiku-4-5
+color: "#44BA81"
 tools:
   "*": false
   "github-triage": true

+ 6 - 0
.opencode/skill/test-skill/SKILL.md

@@ -0,0 +1,6 @@
+---
+name: test-skill
+description: use this when asked to test skill
+---
+
+woah this is a test skill

+ 1 - 28
AGENTS.md

@@ -4,31 +4,4 @@
 
 ## Tool Calling
 
-- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. Here is an example illustrating how to execute 3 parallel file reads in this chat environment:
-
-json
-{
-"recipient_name": "multi_tool_use.parallel",
-"parameters": {
-"tool_uses": [
-{
-"recipient_name": "functions.read",
-"parameters": {
-"filePath": "path/to/file.tsx"
-}
-},
-{
-"recipient_name": "functions.read",
-"parameters": {
-"filePath": "path/to/file.ts"
-}
-},
-{
-"recipient_name": "functions.read",
-"parameters": {
-"filePath": "path/to/file.md"
-}
-}
-]
-}
-}
+- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.

+ 4 - 4
CONTRIBUTING.md

@@ -53,12 +53,12 @@ your debugger via that URL. Other methods can result in breakpoints being mapped
 
 Caveats:
 
-- `*.tsx` files won't have their breakpoints correctly mapped. This seems due to Bun currently not supporting source maps on code transformed
-  via `BunPlugin`s (currently necessary due to our dependency on `@opentui/solid`). Currently, the best you can do in terms of debugging `*.tsx`
-  files is writing a `debugger;` statement. Debugging facilities like stepping won't work, but at least you will be informed if a specific code
-  is triggered.
 - If you want to run the OpenCode TUI and have breakpoints triggered in the server code, you might need to run `bun dev spawn` instead of
   the usual `bun dev`. This is because `bun dev` runs the server in a worker thread and breakpoints might not work there.
+- If `spawn` does not work for you, you can debug the server separately:
+  - Debug server: `bun run --inspect=ws://localhost:6499/ ./src/index.ts serve --port 4096`,
+    then attach TUI with `opencode attach http://localhost:4096`
+  - Debug TUI: `bun run --inspect=ws://localhost:6499/ --conditions=browser ./src/index.ts`
 
 Other tips and tricks:
 

+ 2 - 2
README.md

@@ -89,7 +89,7 @@ you can switch between these using the `Tab` key.
   - Asks permission before running bash commands
   - Ideal for exploring unfamiliar codebases or planning changes
 
-Also, included is a **general** subagent for complex searches and multi-step 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).
@@ -108,7 +108,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
 
 ### FAQ
 
-#### How is this different than Claude Code?
+#### How is this different from Claude Code?
 
 It's very similar to Claude Code in terms of capability. Here are the key differences:
 

+ 8 - 0
STATS.md

@@ -177,3 +177,11 @@
 | 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) |
 | 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) |
 | 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) |
+| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) |
+| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) |
+| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) |
+| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) |
+| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) |
+| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) |
+| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454)  | 2,636,078 (+26,071) |
+| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |

Разница между файлами не показана из-за своего большого размера
+ 200 - 210
bun.lock


+ 3 - 3
flake.lock

@@ -2,11 +2,11 @@
   "nodes": {
     "nixpkgs": {
       "locked": {
-        "lastModified": 1766125104,
-        "narHash": "sha256-l/YGrEpLromL4viUo5GmFH3K5M1j0Mb9O+LiaeCPWEM=",
+        "lastModified": 1766870016,
+        "narHash": "sha256-fHmxAesa6XNqnIkcS6+nIHuEmgd/iZSP/VXxweiEuQw=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "7d853e518814cca2a657b72eeba67ae20ebf7059",
+        "rev": "5c2bc52fb9f8c264ed6c93bd20afa2ff5e763dce",
         "type": "github"
       },
       "original": {

+ 9 - 0
infra/app.ts

@@ -44,3 +44,12 @@ new sst.cloudflare.x.Astro("Web", {
     VITE_API_URL: api.url.apply((url) => url!),
   },
 })
+
+new sst.cloudflare.StaticSite("WebApp", {
+  domain: "app." + domain,
+  path: "packages/app",
+  build: {
+    command: "bun turbo build",
+    output: "./dist",
+  },
+})

+ 3 - 0
infra/console.ts

@@ -103,6 +103,7 @@ const ZEN_MODELS = [
   new sst.Secret("ZEN_MODELS3"),
   new sst.Secret("ZEN_MODELS4"),
   new sst.Secret("ZEN_MODELS5"),
+  new sst.Secret("ZEN_MODELS6"),
 ]
 const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
 const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
@@ -118,6 +119,7 @@ const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
 ////////////////
 
 const bucket = new sst.cloudflare.Bucket("ZenData")
+const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
 
 const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
 const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
@@ -136,6 +138,7 @@ new sst.cloudflare.x.SolidStart("Console", {
   path: "packages/console/app",
   link: [
     bucket,
+    bucketNew,
     database,
     AUTH_API_URL,
     STRIPE_WEBHOOK_SECRET,

+ 0 - 10
infra/desktop.ts

@@ -1,10 +0,0 @@
-import { domain } from "./stage"
-
-new sst.cloudflare.StaticSite("Desktop", {
-  domain: "desktop." + domain,
-  path: "packages/desktop",
-  build: {
-    command: "bun turbo build",
-    output: "./dist",
-  },
-})

+ 89 - 35
install

@@ -7,7 +7,51 @@ RED='\033[0;31m'
 ORANGE='\033[38;5;214m'
 NC='\033[0m' # No Color
 
+usage() {
+    cat <<EOF
+OpenCode Installer
+
+Usage: install.sh [options]
+
+Options:
+    -h, --help              Display this help message
+    -v, --version <version> Install a specific version (e.g., 1.0.180)
+        --no-modify-path    Don't modify shell config files (.zshrc, .bashrc, etc.)
+
+Examples:
+    curl -fsSL https://opencode.ai/install | bash
+    curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180
+EOF
+}
+
 requested_version=${VERSION:-}
+no_modify_path=false
+
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        -h|--help)
+            usage
+            exit 0
+            ;;
+        -v|--version)
+            if [[ -n "${2:-}" ]]; then
+                requested_version="$2"
+                shift 2
+            else
+                echo -e "${RED}Error: --version requires a version argument${NC}"
+                exit 1
+            fi
+            ;;
+        --no-modify-path)
+            no_modify_path=true
+            shift
+            ;;
+        *)
+            echo -e "${ORANGE}Warning: Unknown option '$1'${NC}" >&2
+            shift
+            ;;
+    esac
+done
 
 raw_os=$(uname -s)
 os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
@@ -111,8 +155,18 @@ if [ -z "$requested_version" ]; then
         exit 1
     fi
 else
+    # Strip leading 'v' if present
+    requested_version="${requested_version#v}"
     url="https://github.com/sst/opencode/releases/download/v${requested_version}/$filename"
     specific_version=$requested_version
+    
+    # Verify the release exists before downloading
+    http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/sst/opencode/releases/tag/v${requested_version}")
+    if [ "$http_status" = "404" ]; then
+        echo -e "${RED}Error: Release v${requested_version} not found${NC}"
+        echo -e "${MUTED}Available releases: https://github.com/sst/opencode/releases${NC}"
+        exit 1
+    fi
 fi
 
 print_message() {
@@ -304,42 +358,42 @@ case $current_shell in
     ;;
 esac
 
-config_file=""
-for file in $config_files; do
-    if [[ -f $file ]]; then
-        config_file=$file
-        break
+if [[ "$no_modify_path" != "true" ]]; then
+    config_file=""
+    for file in $config_files; do
+        if [[ -f $file ]]; then
+            config_file=$file
+            break
+        fi
+    done
+
+    if [[ -z $config_file ]]; then
+        print_message warning "No config file found for $current_shell. You may need to manually add to PATH:"
+        print_message info "  export PATH=$INSTALL_DIR:\$PATH"
+    elif [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
+        case $current_shell in
+            fish)
+                add_to_path "$config_file" "fish_add_path $INSTALL_DIR"
+            ;;
+            zsh)
+                add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
+            ;;
+            bash)
+                add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
+            ;;
+            ash)
+                add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
+            ;;
+            sh)
+                add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
+            ;;
+            *)
+                export PATH=$INSTALL_DIR:$PATH
+                print_message warning "Manually add the directory to $config_file (or similar):"
+                print_message info "  export PATH=$INSTALL_DIR:\$PATH"
+            ;;
+        esac
     fi
-done
-
-if [[ -z $config_file ]]; then
-    print_message error "No config file found for $current_shell. Checked files: ${config_files[@]}"
-    exit 1
-fi
-
-if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
-    case $current_shell in
-        fish)
-            add_to_path "$config_file" "fish_add_path $INSTALL_DIR"
-        ;;
-        zsh)
-            add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
-        ;;
-        bash)
-            add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
-        ;;
-        ash)
-            add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
-        ;;
-        sh)
-            add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
-        ;;
-        *)
-            export PATH=$INSTALL_DIR:$PATH
-            print_message warning "Manually add the directory to $config_file (or similar):"
-            print_message info "  export PATH=$INSTALL_DIR:\$PATH"
-        ;;
-    esac
 fi
 
 if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then

+ 1 - 1
nix/hashes.json

@@ -1,3 +1,3 @@
 {
-  "nodeModules": "sha256-XhU8gEwLPUtzFhMfg+QxExn5/WiDo5VVOiZ0AmklRwc="
+  "nodeModules": "sha256-SB9slGD8Vd1hgvm1AsuPzUi3yBPUCDGeha0CABjZdCY="
 }

+ 6 - 3
package.json

@@ -32,7 +32,7 @@
       "@tsconfig/bun": "1.0.9",
       "@cloudflare/workers-types": "4.20251008.0",
       "@openauthjs/openauth": "0.0.0-20250322224806",
-      "@pierre/diffs": "1.0.0-beta.3",
+      "@pierre/diffs": "1.0.2",
       "@solid-primitives/storage": "4.3.3",
       "@tailwindcss/vite": "4.1.11",
       "diff": "8.0.2",
@@ -41,10 +41,13 @@
       "hono-openapi": "1.1.2",
       "fuzzysort": "3.1.0",
       "luxon": "3.6.1",
+      "marked": "17.0.1",
+      "marked-shiki": "1.2.1",
       "typescript": "5.8.2",
       "@typescript/native-preview": "7.0.0-dev.20251207.1",
       "zod": "4.1.8",
       "remeda": "2.26.0",
+      "shiki": "3.20.0",
       "solid-list": "0.3.0",
       "tailwindcss": "4.1.11",
       "virtua": "0.42.3",
@@ -57,6 +60,7 @@
     }
   },
   "devDependencies": {
+    "@actions/artifact": "5.0.1",
     "@tsconfig/bun": "catalog:",
     "husky": "9.1.7",
     "prettier": "3.6.2",
@@ -66,9 +70,9 @@
   "dependencies": {
     "@aws-sdk/client-s3": "3.933.0",
     "@octokit/rest": "22.0.1",
+    "@opencode-ai/plugin": "workspace:*",
     "@opencode-ai/script": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",
-    "@opencode-ai/plugin": "workspace:*",
     "typescript": "catalog:"
   },
   "repository": {
@@ -83,7 +87,6 @@
   "trustedDependencies": [
     "esbuild",
     "protobufjs",
-    "sharp",
     "tree-sitter",
     "tree-sitter-bash",
     "web-tree-sitter"

+ 1 - 0
packages/app/.gitignore

@@ -0,0 +1 @@
+src/assets/theme.css

+ 0 - 0
packages/desktop/AGENTS.md → packages/app/AGENTS.md


+ 34 - 0
packages/app/README.md

@@ -0,0 +1,34 @@
+## Usage
+
+Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`.
+
+This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template.
+
+```bash
+$ npm install # or pnpm install or yarn install
+```
+
+### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
+
+## Available Scripts
+
+In the project directory, you can run:
+
+### `npm run dev` or `npm start`
+
+Runs the app in the development mode.<br>
+Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
+
+The page will reload if you make edits.<br>
+
+### `npm run build`
+
+Builds the app for production to the `dist` folder.<br>
+It correctly bundles Solid in production mode and optimizes the build for the best performance.
+
+The build is minified and the filenames include the hashes.<br>
+Your app is ready to be deployed!
+
+## Deployment
+
+You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)

+ 0 - 0
packages/desktop/bunfig.toml → packages/app/bunfig.toml


+ 0 - 0
packages/desktop/happydom.ts → packages/app/happydom.ts


+ 53 - 0
packages/app/index.html

@@ -0,0 +1,53 @@
+<!doctype html>
+<html lang="en" style="background-color: var(--background-base)">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <title>OpenCode</title>
+    <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
+    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+    <link rel="shortcut icon" href="/favicon.ico" />
+    <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
+    <link rel="manifest" href="/site.webmanifest" />
+    <meta name="theme-color" content="#F8F7F7" />
+    <meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
+    <meta property="og:image" content="/social-share.png" />
+    <meta property="twitter:image" content="/social-share.png" />
+    <!-- Theme preload script - applies cached theme to avoid FOUC -->
+    <script id="oc-theme-preload-script">
+      ;(function () {
+        var themeId = localStorage.getItem("opencode-theme-id")
+        if (!themeId) return
+
+        var scheme = localStorage.getItem("opencode-color-scheme") || "system"
+        var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
+        var mode = isDark ? "dark" : "light"
+
+        document.documentElement.dataset.theme = themeId
+        document.documentElement.dataset.colorScheme = mode
+
+        if (themeId === "oc-1") return
+
+        var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
+        if (css) {
+          var style = document.createElement("style")
+          style.id = "oc-theme-preload"
+          style.textContent =
+            ":root{color-scheme:" +
+            mode +
+            ";--text-mix-blend-mode:" +
+            (isDark ? "plus-lighter" : "multiply") +
+            ";" +
+            css +
+            "}"
+          document.head.appendChild(style)
+        }
+      })()
+    </script>
+  </head>
+  <body class="antialiased overscroll-none text-12-regular overflow-hidden">
+    <noscript>You need to enable JavaScript to run this app.</noscript>
+    <div id="root" class="flex flex-col h-screen"></div>
+    <script src="/src/entry.tsx" type="module"></script>
+  </body>
+</html>

+ 62 - 0
packages/app/package.json

@@ -0,0 +1,62 @@
+{
+  "name": "@opencode-ai/app",
+  "version": "1.0.207",
+  "description": "",
+  "type": "module",
+  "exports": {
+    ".": "./src/index.ts",
+    "./vite": "./vite.js"
+  },
+  "scripts": {
+    "typecheck": "tsgo -b",
+    "start": "vite",
+    "dev": "vite",
+    "build": "vite build",
+    "serve": "vite preview"
+  },
+  "license": "MIT",
+  "devDependencies": {
+    "@happy-dom/global-registrator": "20.0.11",
+    "@tailwindcss/vite": "catalog:",
+    "@tsconfig/bun": "1.0.9",
+    "@types/bun": "catalog:",
+    "@types/luxon": "catalog:",
+    "@types/node": "catalog:",
+    "@typescript/native-preview": "catalog:",
+    "typescript": "catalog:",
+    "vite": "catalog:",
+    "vite-plugin-icons-spritesheet": "3.0.1",
+    "vite-plugin-solid": "catalog:"
+  },
+  "dependencies": {
+    "@kobalte/core": "catalog:",
+    "@opencode-ai/sdk": "workspace:*",
+    "@opencode-ai/ui": "workspace:*",
+    "@opencode-ai/util": "workspace:*",
+    "@shikijs/transformers": "3.9.2",
+    "@solid-primitives/active-element": "2.1.3",
+    "@solid-primitives/audio": "1.4.2",
+    "@solid-primitives/event-bus": "1.1.2",
+    "@solid-primitives/media": "2.3.3",
+    "@solid-primitives/resize-observer": "2.1.3",
+    "@solid-primitives/scroll": "2.1.3",
+    "@solid-primitives/storage": "catalog:",
+    "@solid-primitives/websocket": "1.3.1",
+    "@solidjs/meta": "catalog:",
+    "@solidjs/router": "catalog:",
+    "@thisbeyond/solid-dnd": "0.7.5",
+    "diff": "catalog:",
+    "fuzzysort": "catalog:",
+    "ghostty-web": "0.3.0",
+    "luxon": "catalog:",
+    "marked": "catalog:",
+    "marked-shiki": "catalog:",
+    "remeda": "catalog:",
+    "shiki": "catalog:",
+    "solid-js": "catalog:",
+    "solid-list": "catalog:",
+    "tailwindcss": "catalog:",
+    "virtua": "catalog:",
+    "zod": "catalog:"
+  }
+}

+ 17 - 0
packages/app/public/_headers

@@ -0,0 +1,17 @@
+/assets/*.js
+  Content-Type: application/javascript
+
+/assets/*.mjs
+  Content-Type: application/javascript
+
+/assets/*.css
+  Content-Type: text/css
+
+/*.js
+  Content-Type: application/javascript
+
+/*.mjs
+  Content-Type: application/javascript
+
+/*.css
+  Content-Type: text/css

+ 0 - 0
packages/desktop/public/apple-touch-icon.png → packages/app/public/apple-touch-icon.png


+ 0 - 0
packages/desktop/public/favicon-96x96.png → packages/app/public/favicon-96x96.png


+ 0 - 0
packages/desktop/public/favicon.ico → packages/app/public/favicon.ico


+ 0 - 0
packages/desktop/public/favicon.svg → packages/app/public/favicon.svg


+ 0 - 0
packages/desktop/public/site.webmanifest → packages/app/public/site.webmanifest


+ 0 - 0
packages/desktop/public/social-share-zen.png → packages/app/public/social-share-zen.png


+ 0 - 0
packages/desktop/public/social-share.png → packages/app/public/social-share.png


+ 0 - 0
packages/desktop/public/web-app-manifest-192x192.png → packages/app/public/web-app-manifest-192x192.png


+ 0 - 0
packages/desktop/public/web-app-manifest-512x512.png → packages/app/public/web-app-manifest-512x512.png


+ 0 - 0
packages/desktop/src/addons/serialize.test.ts → packages/app/src/addons/serialize.test.ts


+ 0 - 0
packages/desktop/src/addons/serialize.ts → packages/app/src/addons/serialize.ts


+ 95 - 0
packages/app/src/app.tsx

@@ -0,0 +1,95 @@
+import "@/index.css"
+import { ErrorBoundary, Show } from "solid-js"
+import { Router, Route, Navigate } from "@solidjs/router"
+import { MetaProvider } from "@solidjs/meta"
+import { Font } from "@opencode-ai/ui/font"
+import { MarkedProvider } from "@opencode-ai/ui/context/marked"
+import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
+import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
+import { Diff } from "@opencode-ai/ui/diff"
+import { Code } from "@opencode-ai/ui/code"
+import { ThemeProvider } from "@opencode-ai/ui/theme"
+import { GlobalSyncProvider } from "@/context/global-sync"
+import { LayoutProvider } from "@/context/layout"
+import { GlobalSDKProvider } from "@/context/global-sdk"
+import { TerminalProvider } from "@/context/terminal"
+import { PromptProvider } from "@/context/prompt"
+import { NotificationProvider } from "@/context/notification"
+import { DialogProvider } from "@opencode-ai/ui/context/dialog"
+import { CommandProvider } from "@/context/command"
+import Layout from "@/pages/layout"
+import Home from "@/pages/home"
+import DirectoryLayout from "@/pages/directory-layout"
+import Session from "@/pages/session"
+import { ErrorPage } from "./pages/error"
+import { iife } from "@opencode-ai/util/iife"
+
+declare global {
+  interface Window {
+    __OPENCODE__?: { updaterEnabled?: boolean; port?: number }
+  }
+}
+
+const url = iife(() => {
+  const param = new URLSearchParams(document.location.search).get("url")
+  if (param) return param
+
+  if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
+  if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
+  if (import.meta.env.DEV)
+    return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
+
+  return window.location.origin
+})
+
+export function App() {
+  return (
+    <MetaProvider>
+      <Font />
+      <ThemeProvider>
+        <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
+          <DialogProvider>
+            <MarkedProvider>
+              <DiffComponentProvider component={Diff}>
+                <CodeComponentProvider component={Code}>
+                  <GlobalSDKProvider url={url}>
+                    <GlobalSyncProvider>
+                      <LayoutProvider>
+                        <NotificationProvider>
+                          <Router
+                            root={(props) => (
+                              <CommandProvider>
+                                <Layout>{props.children}</Layout>
+                              </CommandProvider>
+                            )}
+                          >
+                            <Route path="/" component={Home} />
+                            <Route path="/:dir" component={DirectoryLayout}>
+                              <Route path="/" component={() => <Navigate href="session" />} />
+                              <Route
+                                path="/session/:id?"
+                                component={(p) => (
+                                  <Show when={p.params.id ?? "new"} keyed>
+                                    <TerminalProvider>
+                                      <PromptProvider>
+                                        <Session />
+                                      </PromptProvider>
+                                    </TerminalProvider>
+                                  </Show>
+                                )}
+                              />
+                            </Route>
+                          </Router>
+                        </NotificationProvider>
+                      </LayoutProvider>
+                    </GlobalSyncProvider>
+                  </GlobalSDKProvider>
+                </CodeComponentProvider>
+              </DiffComponentProvider>
+            </MarkedProvider>
+          </DialogProvider>
+        </ErrorBoundary>
+      </ThemeProvider>
+    </MetaProvider>
+  )
+}

+ 19 - 17
packages/desktop/src/components/dialog-connect-provider.tsx → packages/app/src/components/dialog-connect-provider.tsx

@@ -1,24 +1,24 @@
-import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
-import { createStore, produce } from "solid-js/store"
+import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
+import { Button } from "@opencode-ai/ui/button"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { useGlobalSync } from "@/context/global-sync"
-import { useGlobalSDK } from "@/context/global-sdk"
-import { usePlatform } from "@/context/platform"
-import { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
 import { Dialog } from "@opencode-ai/ui/dialog"
-import { List, ListRef } from "@opencode-ai/ui/list"
-import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
-import { TextField } from "@opencode-ai/ui/text-field"
+import type { IconName } from "@opencode-ai/ui/icons/provider"
+import { List, type ListRef } from "@opencode-ai/ui/list"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Spinner } from "@opencode-ai/ui/spinner"
-import { Icon } from "@opencode-ai/ui/icon"
+import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast } from "@opencode-ai/ui/toast"
-import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import { IconName } from "@opencode-ai/ui/icons/provider"
 import { iife } from "@opencode-ai/util/iife"
+import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
+import { createStore, produce } from "solid-js/store"
 import { Link } from "@/components/link"
-import { DialogSelectProvider } from "./dialog-select-provider"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { useGlobalSync } from "@/context/global-sync"
+import { usePlatform } from "@/context/platform"
 import { DialogSelectModel } from "./dialog-select-model"
+import { DialogSelectProvider } from "./dialog-select-provider"
 
 export function DialogConnectProvider(props: { provider: string }) {
   const dialog = useDialog()
@@ -154,7 +154,9 @@ export function DialogConnectProvider(props: { provider: string }) {
               <div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
               <div class="">
                 <List
-                  ref={(ref) => (listRef = ref)}
+                  ref={(ref) => {
+                    listRef = ref
+                  }}
                   items={methods}
                   key={(m) => m?.label}
                   onSelect={async (method, index) => {
@@ -163,7 +165,7 @@ export function DialogConnectProvider(props: { provider: string }) {
                   }}
                 >
                   {(i) => (
-                    <div class="w-full flex items-center gap-x-4">
+                    <div class="w-full flex items-center gap-x-2">
                       <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
                         <div class="w-2.5 h-0.5 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
                       </div>
@@ -175,7 +177,7 @@ export function DialogConnectProvider(props: { provider: string }) {
             </Match>
             <Match when={store.state === "pending"}>
               <div class="text-14-regular text-text-base">
-                <div class="flex items-center gap-x-4">
+                <div class="flex items-center gap-x-2">
                   <Spinner />
                   <span>Authorization in progress...</span>
                 </div>
@@ -183,7 +185,7 @@ export function DialogConnectProvider(props: { provider: string }) {
             </Match>
             <Match when={store.state === "error"}>
               <div class="text-14-regular text-text-base">
-                <div class="flex items-center gap-x-4">
+                <div class="flex items-center gap-x-2">
                   <Icon name="circle-ban-sign" class="text-icon-critical-base" />
                   <span>Authorization failed: {store.error}</span>
                 </div>

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

@@ -0,0 +1,180 @@
+import { Button } from "@opencode-ai/ui/button"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { TextField } from "@opencode-ai/ui/text-field"
+import { Icon } from "@opencode-ai/ui/icon"
+import { createMemo, createSignal, For, Show } from "solid-js"
+import { createStore } from "solid-js/store"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { type LocalProject, getAvatarColors } from "@/context/layout"
+import { Avatar } from "@opencode-ai/ui/avatar"
+
+const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
+
+function getFilename(input: string) {
+  const parts = input.split("/")
+  return parts[parts.length - 1] || input
+}
+
+export function DialogEditProject(props: { project: LocalProject }) {
+  const dialog = useDialog()
+  const globalSDK = useGlobalSDK()
+
+  const folderName = createMemo(() => getFilename(props.project.worktree))
+  const defaultName = createMemo(() => props.project.name || folderName())
+
+  const [store, setStore] = createStore({
+    name: defaultName(),
+    color: props.project.icon?.color || "pink",
+    iconUrl: props.project.icon?.url || "",
+    saving: false,
+  })
+
+  const [dragOver, setDragOver] = createSignal(false)
+
+  function handleFileSelect(file: File) {
+    if (!file.type.startsWith("image/")) return
+    const reader = new FileReader()
+    reader.onload = (e) => setStore("iconUrl", e.target?.result as string)
+    reader.readAsDataURL(file)
+  }
+
+  function handleDrop(e: DragEvent) {
+    e.preventDefault()
+    setDragOver(false)
+    const file = e.dataTransfer?.files[0]
+    if (file) handleFileSelect(file)
+  }
+
+  function handleDragOver(e: DragEvent) {
+    e.preventDefault()
+    setDragOver(true)
+  }
+
+  function handleDragLeave() {
+    setDragOver(false)
+  }
+
+  function handleInputChange(e: Event) {
+    const input = e.target as HTMLInputElement
+    const file = input.files?.[0]
+    if (file) handleFileSelect(file)
+  }
+
+  function clearIcon() {
+    setStore("iconUrl", "")
+  }
+
+  async function handleSubmit(e: SubmitEvent) {
+    e.preventDefault()
+    if (!props.project.id) return
+
+    setStore("saving", true)
+    const name = store.name.trim() === folderName() ? "" : store.name.trim()
+    await globalSDK.client.project.update({
+      projectID: props.project.id,
+      name,
+      icon: { color: store.color, url: store.iconUrl },
+    })
+    setStore("saving", false)
+    dialog.close()
+  }
+
+  return (
+    <Dialog title="Edit project">
+      <form onSubmit={handleSubmit} class="flex flex-col gap-6 px-2.5 pb-3">
+        <div class="flex flex-col gap-4">
+          <TextField
+            autofocus
+            type="text"
+            label="Name"
+            placeholder={folderName()}
+            value={store.name}
+            onChange={(v) => setStore("name", v)}
+          />
+
+          <div class="flex flex-col gap-2">
+            <label class="text-12-medium text-text-weak">Icon</label>
+            <div class="flex gap-3 items-start">
+              <div class="relative">
+                <div
+                  class="size-16 rounded-lg overflow-hidden border border-dashed transition-colors cursor-pointer"
+                  classList={{
+                    "border-text-interactive-base bg-surface-info-base/20": dragOver(),
+                    "border-border-base hover:border-border-strong": !dragOver(),
+                  }}
+                  onDrop={handleDrop}
+                  onDragOver={handleDragOver}
+                  onDragLeave={handleDragLeave}
+                  onClick={() => document.getElementById("icon-upload")?.click()}
+                >
+                  <Show
+                    when={store.iconUrl}
+                    fallback={
+                      <div class="size-full flex items-center justify-center">
+                        <Avatar
+                          fallback={store.name || defaultName()}
+                          {...getAvatarColors(store.color)}
+                          class="size-full"
+                        />
+                      </div>
+                    }
+                  >
+                    <img src={store.iconUrl} alt="Project icon" class="size-full object-cover" />
+                  </Show>
+                </div>
+                <Show when={store.iconUrl}>
+                  <button
+                    type="button"
+                    class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-base border border-border-base flex items-center justify-center hover:bg-surface-raised-base-hover"
+                    onClick={clearIcon}
+                  >
+                    <Icon name="close" class="size-3 text-icon-base" />
+                  </button>
+                </Show>
+              </div>
+              <input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
+              <div class="flex flex-col gap-1.5 text-12-regular text-text-weak">
+                <span>Click or drag an image</span>
+                <span>Recommended: 128x128px</span>
+              </div>
+            </div>
+          </div>
+
+          <Show when={!store.iconUrl}>
+            <div class="flex flex-col gap-2">
+              <label class="text-12-medium text-text-weak">Color</label>
+              <div class="flex gap-2">
+                <For each={AVATAR_COLOR_KEYS}>
+                  {(color) => (
+                    <button
+                      type="button"
+                      class="relative size-8 rounded-md transition-all"
+                      classList={{
+                        "ring-2 ring-offset-2 ring-offset-surface-base ring-text-interactive-base":
+                          store.color === color,
+                      }}
+                      style={{ background: getAvatarColors(color).background }}
+                      onClick={() => setStore("color", color)}
+                    >
+                      <Avatar fallback={store.name || defaultName()} {...getAvatarColors(color)} class="size-full" />
+                    </button>
+                  )}
+                </For>
+              </div>
+            </div>
+          </Show>
+        </div>
+
+        <div class="flex justify-end gap-2">
+          <Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
+            Cancel
+          </Button>
+          <Button type="submit" variant="primary" size="large" disabled={store.saving}>
+            {store.saving ? "Saving..." : "Save"}
+          </Button>
+        </div>
+      </form>
+    </Dialog>
+  )
+}

+ 14 - 7
packages/desktop/src/components/dialog-manage-models.tsx → packages/app/src/components/dialog-manage-models.tsx

@@ -1,16 +1,15 @@
-import { Component } from "solid-js"
-import { useLocal } from "@/context/local"
-import { popularProviders } from "@/hooks/use-providers"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { List } from "@opencode-ai/ui/list"
 import { Switch } from "@opencode-ai/ui/switch"
+import type { Component } from "solid-js"
+import { useLocal } from "@/context/local"
+import { popularProviders } from "@/hooks/use-providers"
 
 export const DialogManageModels: Component = () => {
   const local = useLocal()
   return (
     <Dialog title="Manage models" description="Customize which models appear in the model selector.">
       <List
-        class="px-2.5"
         search={{ placeholder: "Search models", autofocus: true }}
         emptyMessage="No model results"
         key={(x) => `${x?.provider?.id}:${x?.id}`}
@@ -27,16 +26,24 @@ export const DialogManageModels: Component = () => {
         }}
         onSelect={(x) => {
           if (!x) return
-          const visible = local.model.visible({ modelID: x.id, providerID: x.provider.id })
+          const visible = local.model.visible({
+            modelID: x.id,
+            providerID: x.provider.id,
+          })
           local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
         }}
       >
         {(i) => (
-          <div class="w-full flex items-center justify-between gap-x-2.5">
+          <div class="w-full flex items-center justify-between gap-x-3">
             <span>{i.name}</span>
             <div onClick={(e) => e.stopPropagation()}>
               <Switch
-                checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
+                checked={
+                  !!local.model.visible({
+                    modelID: i.id,
+                    providerID: i.provider.id,
+                  })
+                }
                 onChange={(checked) => {
                   local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
                 }}

+ 5 - 6
packages/desktop/src/components/dialog-select-file.tsx → packages/app/src/components/dialog-select-file.tsx

@@ -1,12 +1,12 @@
-import { useLocal } from "@/context/local"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
-import { List } from "@opencode-ai/ui/list"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { List } from "@opencode-ai/ui/list"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { useLayout } from "@/context/layout"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useParams } from "@solidjs/router"
 import { createMemo } from "solid-js"
+import { useLayout } from "@/context/layout"
+import { useLocal } from "@/context/local"
 
 export function DialogSelectFile() {
   const layout = useLayout()
@@ -18,7 +18,6 @@ export function DialogSelectFile() {
   return (
     <Dialog title="Select file">
       <List
-        class="px-2.5"
         search={{ placeholder: "Search files", autofocus: true }}
         emptyMessage="No files found"
         items={local.file.searchFiles}
@@ -32,7 +31,7 @@ export function DialogSelectFile() {
       >
         {(i) => (
           <div class="w-full flex items-center justify-between rounded-md">
-            <div class="flex items-center gap-x-2 grow min-w-0">
+            <div class="flex items-center gap-x-3 grow min-w-0">
               <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
               <div class="flex items-center text-14-regular">
                 <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">

+ 91 - 0
packages/app/src/components/dialog-select-mcp.tsx

@@ -0,0 +1,91 @@
+import { Component, createMemo, createSignal, Show } from "solid-js"
+import { useSync } from "@/context/sync"
+import { useSDK } from "@/context/sdk"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { List } from "@opencode-ai/ui/list"
+import { Switch } from "@opencode-ai/ui/switch"
+
+export const DialogSelectMcp: Component = () => {
+  const sync = useSync()
+  const sdk = useSDK()
+  const [loading, setLoading] = createSignal<string | null>(null)
+
+  const items = createMemo(() =>
+    Object.entries(sync.data.mcp ?? {})
+      .map(([name, status]) => ({ name, status: status.status }))
+      .sort((a, b) => a.name.localeCompare(b.name)),
+  )
+
+  const toggle = async (name: string) => {
+    if (loading()) return
+    setLoading(name)
+    const status = sync.data.mcp[name]
+    if (status?.status === "connected") {
+      await sdk.client.mcp.disconnect({ name })
+    } else {
+      await sdk.client.mcp.connect({ name })
+    }
+    const result = await sdk.client.mcp.status()
+    if (result.data) sync.set("mcp", result.data)
+    setLoading(null)
+  }
+
+  const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
+  const totalCount = createMemo(() => items().length)
+
+  return (
+    <Dialog title="MCPs" description={`${enabledCount()} of ${totalCount()} enabled`}>
+      <List
+        search={{ placeholder: "Search", autofocus: true }}
+        emptyMessage="No MCPs configured"
+        key={(x) => x?.name ?? ""}
+        items={items}
+        filterKeys={["name", "status"]}
+        sortBy={(a, b) => a.name.localeCompare(b.name)}
+        onSelect={(x) => {
+          if (x) toggle(x.name)
+        }}
+      >
+        {(i) => {
+          const mcpStatus = () => sync.data.mcp[i.name]
+          const status = () => mcpStatus()?.status
+          const error = () => {
+            const s = mcpStatus()
+            return s?.status === "failed" ? s.error : undefined
+          }
+          const enabled = () => status() === "connected"
+          return (
+            <div class="w-full flex items-center justify-between gap-x-3">
+              <div class="flex flex-col gap-0.5 min-w-0">
+                <div class="flex items-center gap-2">
+                  <span class="truncate">{i.name}</span>
+                  <Show when={status() === "connected"}>
+                    <span class="text-11-regular text-text-weaker">connected</span>
+                  </Show>
+                  <Show when={status() === "failed"}>
+                    <span class="text-11-regular text-text-weaker">failed</span>
+                  </Show>
+                  <Show when={status() === "needs_auth"}>
+                    <span class="text-11-regular text-text-weaker">needs auth</span>
+                  </Show>
+                  <Show when={status() === "disabled"}>
+                    <span class="text-11-regular text-text-weaker">disabled</span>
+                  </Show>
+                  <Show when={loading() === i.name}>
+                    <span class="text-11-regular text-text-weak">...</span>
+                  </Show>
+                </div>
+                <Show when={error()}>
+                  <span class="text-11-regular text-text-weaker truncate">{error()}</span>
+                </Show>
+              </div>
+              <div onClick={(e) => e.stopPropagation()}>
+                <Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
+              </div>
+            </div>
+          )
+        }}
+      </List>
+    </Dialog>
+  )
+}

+ 11 - 20
packages/desktop/src/components/dialog-select-model-unpaid.tsx → packages/app/src/components/dialog-select-model-unpaid.tsx

@@ -1,15 +1,15 @@
-import { Component, onCleanup, onMount, Show } from "solid-js"
-import { useLocal } from "@/context/local"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { popularProviders, useProviders } from "@/hooks/use-providers"
 import { Button } from "@opencode-ai/ui/button"
-import { Tag } from "@opencode-ai/ui/tag"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
-import { List, ListRef } from "@opencode-ai/ui/list"
+import type { IconName } from "@opencode-ai/ui/icons/provider"
+import { List, type ListRef } from "@opencode-ai/ui/list"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import { IconName } from "@opencode-ai/ui/icons/provider"
-import { DialogSelectProvider } from "./dialog-select-provider"
+import { Tag } from "@opencode-ai/ui/tag"
+import { type Component, onCleanup, onMount, Show } from "solid-js"
+import { useLocal } from "@/context/local"
+import { popularProviders, useProviders } from "@/hooks/use-providers"
 import { DialogConnectProvider } from "./dialog-connect-provider"
+import { DialogSelectProvider } from "./dialog-select-provider"
 
 export const DialogSelectModelUnpaid: Component = () => {
   const local = useLocal()
@@ -64,7 +64,7 @@ export const DialogSelectModelUnpaid: Component = () => {
             <div class="px-2 text-14-medium text-text-base">Add more models from popular providers</div>
             <div class="w-full">
               <List
-                class="w-full"
+                class="w-full px-0"
                 key={(x) => x?.id}
                 items={providers.popular}
                 activeIcon="plus-small"
@@ -79,17 +79,8 @@ export const DialogSelectModelUnpaid: Component = () => {
                 }}
               >
                 {(i) => (
-                  <div class="w-full flex items-center gap-x-4">
-                    <ProviderIcon
-                      data-slot="list-item-extra-icon"
-                      id={i.id as IconName}
-                      // TODO: clean this up after we update icon in models.dev
-                      classList={{
-                        "text-icon-weak-base": true,
-                        "size-4 mx-0.5": i.id === "opencode",
-                        "size-5": i.id !== "opencode",
-                      }}
-                    />
+                  <div class="w-full flex items-center gap-x-3">
+                    <ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
                     <span>{i.name}</span>
                     <Show when={i.id === "opencode"}>
                       <Tag>Recommended</Tag>

+ 1 - 2
packages/desktop/src/components/dialog-select-model.tsx → packages/app/src/components/dialog-select-model.tsx

@@ -35,7 +35,6 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
       }
     >
       <List
-        class="px-2.5"
         search={{ placeholder: "Search models", autofocus: true }}
         emptyMessage="No model results"
         key={(x) => `${x.provider.id}:${x.id}`}
@@ -61,7 +60,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
         }}
       >
         {(i) => (
-          <div class="w-full flex items-center gap-x-2.5">
+          <div class="w-full flex items-center gap-x-3">
             <span>{i.name}</span>
             <Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
               <Tag>Free</Tag>

+ 2 - 12
packages/desktop/src/components/dialog-select-provider.tsx → packages/app/src/components/dialog-select-provider.tsx

@@ -15,7 +15,6 @@ export const DialogSelectProvider: Component = () => {
   return (
     <Dialog title="Connect provider">
       <List
-        class="px-2.5"
         search={{ placeholder: "Search providers", autofocus: true }}
         activeIcon="plus-small"
         key={(x) => x?.id}
@@ -38,17 +37,8 @@ export const DialogSelectProvider: Component = () => {
         }}
       >
         {(i) => (
-          <div class="px-1.25 w-full flex items-center gap-x-4">
-            <ProviderIcon
-              data-slot="list-item-extra-icon"
-              id={i.id as IconName}
-              // TODO: clean this up after we update icon in models.dev
-              classList={{
-                "text-icon-weak-base": true,
-                "size-4 mx-0.5": i.id === "opencode",
-                "size-5": i.id !== "opencode",
-              }}
-            />
+          <div class="px-1.25 w-full flex items-center gap-x-3">
+            <ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
             <span>{i.name}</span>
             <Show when={i.id === "opencode"}>
               <Tag>Recommended</Tag>

+ 5 - 5
packages/desktop/src/components/file-tree.tsx → packages/app/src/components/file-tree.tsx

@@ -2,7 +2,7 @@ import { useLocal, type LocalFile } from "@/context/local"
 import { Collapsible } from "@opencode-ai/ui/collapsible"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
+import { For, Match, Switch, type ComponentProps, type ParentProps } from "solid-js"
 import { Dynamic } from "solid-js/web"
 
 export default function FileTree(props: {
@@ -57,14 +57,14 @@ export default function FileTree(props: {
           "text-text-muted/40": p.node.ignored,
           "text-text-muted/80": !p.node.ignored,
           // "!text-text": local.file.active()?.path === p.node.path,
-          "!text-primary": local.file.changed(p.node.path),
+          // "!text-primary": local.file.changed(p.node.path),
         }}
       >
         {p.node.name}
       </span>
-      <Show when={local.file.changed(p.node.path)}>
-        <span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" />
-      </Show>
+      {/* <Show when={local.file.changed(p.node.path)}> */}
+      {/*   <span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" /> */}
+      {/* </Show> */}
     </Dynamic>
   )
 

+ 70 - 23
packages/desktop/src/components/header.tsx → packages/app/src/components/header.tsx

@@ -20,6 +20,7 @@ import { iife } from "@opencode-ai/util/iife"
 export function Header(props: {
   navigateToProject: (directory: string) => void
   navigateToSession: (session: Session | undefined) => void
+  onMobileMenuToggle?: () => void
 }) {
   const globalSync = useGlobalSync()
   const globalSDK = useGlobalSDK()
@@ -29,11 +30,19 @@ export function Header(props: {
 
   return (
     <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
+      <button
+        type="button"
+        class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
+        onClick={props.onMobileMenuToggle}
+      >
+        <Icon name="menu" size="small" />
+      </button>
       <A
         href="/"
         classList={{
+          "hidden xl:flex": true,
           "w-12 shrink-0 px-4 py-3.5": true,
-          "flex items-center justify-start self-stretch": true,
+          "items-center justify-start self-stretch": true,
           "border-r border-border-weak-base": true,
         }}
         style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
@@ -46,30 +55,32 @@ export function Header(props: {
           {(directory) => {
             const currentDirectory = createMemo(() => base64Decode(directory()))
             const store = createMemo(() => globalSync.child(currentDirectory())[0])
-            const sessions = createMemo(() => store().session ?? [])
+            const sessions = createMemo(() => (store().session ?? []).filter((s) => !s.parentID))
             const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
             const shareEnabled = createMemo(() => store().config.share !== "disabled")
             return (
               <>
-                <div class="flex items-center gap-3">
-                  <div class="flex items-center gap-2">
-                    <Select
-                      options={layout.projects.list().map((project) => project.worktree)}
-                      current={currentDirectory()}
-                      label={(x) => getFilename(x)}
-                      onSelect={(x) => (x ? props.navigateToProject(x) : undefined)}
-                      class="text-14-regular text-text-base"
-                      variant="ghost"
-                    >
-                      {/* @ts-ignore */}
-                      {(i) => (
-                        <div class="flex items-center gap-2">
-                          <Icon name="folder" size="small" />
-                          <div class="text-text-strong">{getFilename(i)}</div>
-                        </div>
-                      )}
-                    </Select>
-                    <div class="text-text-weaker">/</div>
+                <div class="flex items-center gap-3 min-w-0">
+                  <div class="flex items-center gap-2 min-w-0">
+                    <div class="hidden xl:flex items-center gap-2">
+                      <Select
+                        options={layout.projects.list().map((project) => project.worktree)}
+                        current={currentDirectory()}
+                        label={(x) => getFilename(x)}
+                        onSelect={(x) => (x ? props.navigateToProject(x) : undefined)}
+                        class="text-14-regular text-text-base"
+                        variant="ghost"
+                      >
+                        {/* @ts-ignore */}
+                        {(i) => (
+                          <div class="flex items-center gap-2">
+                            <Icon name="folder" size="small" />
+                            <div class="text-text-strong">{getFilename(i)}</div>
+                          </div>
+                        )}
+                      </Select>
+                      <div class="text-text-weaker">/</div>
+                    </div>
                     <Select
                       options={sessions()}
                       current={currentSession()}
@@ -77,12 +88,13 @@ export function Header(props: {
                       label={(x) => x.title}
                       value={(x) => x.id}
                       onSelect={props.navigateToSession}
-                      class="text-14-regular text-text-base max-w-md"
+                      class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
                       variant="ghost"
                     />
                   </div>
                   <Show when={currentSession()}>
                     <Tooltip
+                      class="hidden xl:block"
                       value={
                         <div class="flex items-center gap-2">
                           <span>New session</span>
@@ -97,8 +109,39 @@ export function Header(props: {
                   </Show>
                 </div>
                 <div class="flex items-center gap-4">
+                  <Show when={currentSession()?.summary?.files}>
+                    <Tooltip
+                      class="hidden md:block shrink-0"
+                      value={
+                        <div class="flex items-center gap-2">
+                          <span>Toggle review</span>
+                          <span class="text-icon-base text-12-medium">{command.keybind("review.toggle")}</span>
+                        </div>
+                      }
+                    >
+                      <Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
+                        <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+                          <Icon
+                            name={layout.review.opened() ? "layout-right" : "layout-left"}
+                            size="small"
+                            class="group-hover/review-toggle:hidden"
+                          />
+                          <Icon
+                            name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
+                            size="small"
+                            class="hidden group-hover/review-toggle:inline-block"
+                          />
+                          <Icon
+                            name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
+                            size="small"
+                            class="hidden group-active/review-toggle:inline-block"
+                          />
+                        </div>
+                      </Button>
+                    </Tooltip>
+                  </Show>
                   <Tooltip
-                    class="shrink-0"
+                    class="hidden md:block shrink-0"
                     value={
                       <div class="flex items-center gap-2">
                         <span>Toggle terminal</span>
@@ -145,6 +188,10 @@ export function Header(props: {
                               shareURL = await globalSDK.client.session
                                 .share({ sessionID: session.id, directory: currentDirectory() })
                                 .then((r) => r.data?.share?.url)
+                                .catch((e) => {
+                                  console.error("Failed to share session", e)
+                                  return undefined
+                                })
                             }
                             return shareURL
                           },

+ 0 - 0
packages/desktop/src/components/link.tsx → packages/app/src/components/link.tsx


+ 284 - 108
packages/desktop/src/components/prompt-input.tsx → packages/app/src/components/prompt-input.tsx

@@ -22,6 +22,7 @@ import { useProviders } from "@/hooks/use-providers"
 import { useCommand } from "@/context/command"
 import { persisted } from "@/utils/persist"
 import { Identifier } from "@/utils/id"
+import { SessionContextUsage } from "@/components/session-context-usage"
 
 const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
 const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
@@ -81,6 +82,37 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const command = useCommand()
   let editorRef!: HTMLDivElement
   let fileInputRef!: HTMLInputElement
+  let scrollRef!: HTMLDivElement
+
+  const scrollCursorIntoView = () => {
+    const container = scrollRef
+    const selection = window.getSelection()
+    if (!container || !selection || selection.rangeCount === 0) return
+
+    const range = selection.getRangeAt(0)
+    if (!editorRef.contains(range.startContainer)) return
+
+    const rect = range.getBoundingClientRect()
+    if (!rect.height) return
+
+    const containerRect = container.getBoundingClientRect()
+    const top = rect.top - containerRect.top + container.scrollTop
+    const bottom = rect.bottom - containerRect.top + container.scrollTop
+    const padding = 12
+
+    if (top < container.scrollTop + padding) {
+      container.scrollTop = Math.max(0, top - padding)
+      return
+    }
+
+    if (bottom > container.scrollTop + container.clientHeight - padding) {
+      container.scrollTop = bottom - container.clientHeight + padding
+    }
+  }
+
+  const queueScroll = () => {
+    requestAnimationFrame(scrollCursorIntoView)
+  }
 
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const tabs = createMemo(() => layout.tabs(sessionKey()))
@@ -102,7 +134,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     imageAttachments: ImageAttachmentPart[]
     mode: "normal" | "shell"
     applyingHistory: boolean
-    userHasEdited: boolean
   }>({
     popover: null,
     historyIndex: -1,
@@ -112,7 +143,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     imageAttachments: [],
     mode: "normal",
     applyingHistory: false,
-    userHasEdited: false,
   })
 
   const MAX_HISTORY = 100
@@ -149,12 +179,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
     const length = position === "start" ? 0 : promptLength(p)
     setStore("applyingHistory", true)
-    setStore("userHasEdited", false)
     prompt.set(p, length)
     requestAnimationFrame(() => {
       editorRef.focus()
       setCursorPosition(editorRef, length)
       setStore("applyingHistory", false)
+      queueScroll()
     })
   }
 
@@ -218,6 +248,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }
 
   const handlePaste = async (event: ClipboardEvent) => {
+    if (!isFocused()) return
     const clipboardData = event.clipboardData
     if (!clipboardData) return
 
@@ -240,7 +271,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     addPart({ type: "text", content: plainText, start: 0, end: 0 })
   }
 
-  const handleDragOver = (event: DragEvent) => {
+  const handleGlobalDragOver = (event: DragEvent) => {
     event.preventDefault()
     const hasFiles = event.dataTransfer?.types.includes("Files")
     if (hasFiles) {
@@ -248,15 +279,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
   }
 
-  const handleDragLeave = (event: DragEvent) => {
-    const related = event.relatedTarget as Node | null
-    const form = event.currentTarget as HTMLElement
-    if (!related || !form.contains(related)) {
+  const handleGlobalDragLeave = (event: DragEvent) => {
+    // relatedTarget is null when leaving the document window
+    if (!event.relatedTarget) {
       setStore("dragging", false)
     }
   }
 
-  const handleDrop = async (event: DragEvent) => {
+  const handleGlobalDrop = async (event: DragEvent) => {
     event.preventDefault()
     setStore("dragging", false)
 
@@ -272,17 +302,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   onMount(() => {
     editorRef.addEventListener("paste", handlePaste)
+    document.addEventListener("dragover", handleGlobalDragOver)
+    document.addEventListener("dragleave", handleGlobalDragLeave)
+    document.addEventListener("drop", handleGlobalDrop)
   })
   onCleanup(() => {
     editorRef.removeEventListener("paste", handlePaste)
+    document.removeEventListener("dragover", handleGlobalDragOver)
+    document.removeEventListener("dragleave", handleGlobalDragLeave)
+    document.removeEventListener("drop", handleGlobalDrop)
   })
 
   createEffect(() => {
-    if (isFocused()) {
-      handleInput()
-    } else {
-      setStore("popover", null)
-    }
+    if (!isFocused()) setStore("popover", null)
   })
 
   const handleFileSelect = (path: string | undefined) => {
@@ -362,7 +394,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       () => prompt.current(),
       (currentParts) => {
         const domParts = parseFromDOM()
-        if (isPromptEqual(currentParts, domParts)) return
+        const normalized = Array.from(editorRef.childNodes).every((node) => {
+          if (node.nodeType === Node.TEXT_NODE) {
+            const text = node.textContent ?? ""
+            if (!text.includes("\u200B")) return true
+            if (text !== "\u200B") return false
+
+            const prev = node.previousSibling
+            const next = node.nextSibling
+            const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
+            const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
+            if (!prevIsBr && !nextIsBr) return false
+            if (nextIsBr && !prevIsBr && prev) return false
+            return true
+          }
+          if (node.nodeType !== Node.ELEMENT_NODE) return false
+          const el = node as HTMLElement
+          if (el.dataset.type === "file") return true
+          return el.tagName === "BR"
+        })
+        if (normalized && isPromptEqual(currentParts, domParts)) return
 
         const selection = window.getSelection()
         let cursorPosition: number | null = null
@@ -373,7 +424,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         editorRef.innerHTML = ""
         currentParts.forEach((part) => {
           if (part.type === "text") {
-            editorRef.appendChild(document.createTextNode(part.content))
+            editorRef.appendChild(createTextFragment(part.content))
           } else if (part.type === "file") {
             const pill = document.createElement("span")
             pill.textContent = part.content
@@ -394,34 +445,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   )
 
   const parseFromDOM = (): Prompt => {
-    const newParts: Prompt = []
+    const parts: Prompt = []
     let position = 0
+    let buffer = ""
 
-    const pushText = (content: string) => {
+    const flushText = () => {
+      const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "")
+      buffer = ""
       if (!content) return
-      newParts.push({ type: "text", content, start: position, end: position + content.length })
+      parts.push({ type: "text", content, start: position, end: position + content.length })
       position += content.length
     }
 
-    const rangeText = (range: Range) => {
-      const fragment = range.cloneContents()
-      const container = document.createElement("div")
-      container.append(fragment)
-      return container.innerText
-    }
-
-    const files = Array.from(editorRef.querySelectorAll<HTMLElement>("[data-type=file]"))
-    let last: HTMLElement | undefined
-
-    files.forEach((file) => {
-      const before = document.createRange()
-      before.selectNodeContents(editorRef)
-      if (last) before.setStartAfter(last)
-      before.setEndBefore(file)
-      pushText(rangeText(before))
-
+    const pushFile = (file: HTMLElement) => {
       const content = file.textContent ?? ""
-      newParts.push({
+      parts.push({
         type: "file",
         path: file.dataset.path!,
         content,
@@ -429,16 +467,44 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         end: position + content.length,
       })
       position += content.length
-      last = file
+    }
+
+    const visit = (node: Node) => {
+      if (node.nodeType === Node.TEXT_NODE) {
+        buffer += node.textContent ?? ""
+        return
+      }
+      if (node.nodeType !== Node.ELEMENT_NODE) return
+
+      const el = node as HTMLElement
+      if (el.dataset.type === "file") {
+        flushText()
+        pushFile(el)
+        return
+      }
+      if (el.tagName === "BR") {
+        buffer += "\n"
+        return
+      }
+
+      for (const child of Array.from(el.childNodes)) {
+        visit(child)
+      }
+    }
+
+    const children = Array.from(editorRef.childNodes)
+    children.forEach((child, index) => {
+      const isBlock = child.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((child as HTMLElement).tagName)
+      visit(child)
+      if (isBlock && index < children.length - 1) {
+        buffer += "\n"
+      }
     })
 
-    const after = document.createRange()
-    after.selectNodeContents(editorRef)
-    if (last) after.setStartAfter(last)
-    pushText(rangeText(after))
+    flushText()
 
-    if (newParts.length === 0) newParts.push(...DEFAULT_PROMPT)
-    return newParts
+    if (parts.length === 0) parts.push(...DEFAULT_PROMPT)
+    return parts
   }
 
   const handleInput = () => {
@@ -451,7 +517,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
     if (shouldReset) {
       setStore("popover", null)
-      setStore("userHasEdited", false)
       if (store.historyIndex >= 0 && !store.applyingHistory) {
         setStore("historyIndex", -1)
         setStore("savedPrompt", null)
@@ -459,6 +524,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       if (prompt.dirty()) {
         prompt.set(DEFAULT_PROMPT, 0)
       }
+      queueScroll()
       return
     }
 
@@ -486,11 +552,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       setStore("savedPrompt", null)
     }
 
-    if (!store.applyingHistory) {
-      setStore("userHasEdited", true)
-    }
-
     prompt.set(rawParts, cursorPosition)
+    queueScroll()
   }
 
   const addPart = (part: ContentPart) => {
@@ -515,27 +578,40 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const gap = document.createTextNode(" ")
       const range = selection.getRangeAt(0)
 
-      if (atMatch) {
-        let runningLength = 0
-
-        const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
-        let currentNode = walker.nextNode()
-        while (currentNode) {
-          const textContent = currentNode.textContent || ""
-          if (runningLength + textContent.length >= atMatch.index!) {
-            const localStart = atMatch.index! - runningLength
-            const localEnd = cursorPosition - runningLength
-            if (currentNode === range.startContainer || runningLength + textContent.length >= cursorPosition) {
-              range.setStart(currentNode, localStart)
-              range.setEnd(currentNode, Math.min(localEnd, textContent.length))
-              break
-            }
+      const setEdge = (edge: "start" | "end", offset: number) => {
+        let remaining = offset
+        const nodes = Array.from(editorRef.childNodes)
+
+        for (const node of nodes) {
+          const length = getNodeLength(node)
+          const isText = node.nodeType === Node.TEXT_NODE
+          const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
+          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 ((isFile || 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
           }
-          runningLength += textContent.length
-          currentNode = walker.nextNode()
+
+          remaining -= length
         }
       }
 
+      if (atMatch) {
+        const start = atMatch.index ?? cursorPosition - atMatch[0].length
+        setEdge("start", start)
+        setEdge("end", cursorPosition)
+      }
+
       range.deleteContents()
       range.insertNode(gap)
       range.insertNode(pill)
@@ -544,11 +620,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       selection.removeAllRanges()
       selection.addRange(range)
     } else if (part.type === "text") {
-      const textNode = document.createTextNode(part.content)
       const range = selection.getRangeAt(0)
+      const fragment = createTextFragment(part.content)
+      const last = fragment.lastChild
       range.deleteContents()
-      range.insertNode(textNode)
-      range.setStartAfter(textNode)
+      range.insertNode(fragment)
+      if (last) {
+        if (last.nodeType === Node.TEXT_NODE) {
+          const text = last.textContent ?? ""
+          if (text === "\u200B") {
+            range.setStart(last, 0)
+          }
+          if (text !== "\u200B") {
+            range.setStart(last, text.length)
+          }
+        }
+        if (last.nodeType !== Node.TEXT_NODE) {
+          range.setStartAfter(last)
+        }
+      }
       range.collapse(true)
       selection.removeAllRanges()
       selection.addRange(range)
@@ -559,9 +649,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }
 
   const abort = () =>
-    sdk.client.session.abort({
-      sessionID: params.id!,
-    })
+    sdk.client.session
+      .abort({
+        sessionID: params.id!,
+      })
+      .catch(() => {})
 
   const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
     const text = prompt
@@ -583,8 +675,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }
 
   const navigateHistory = (direction: "up" | "down") => {
-    if (store.userHasEdited) return false
-
     const entries = store.mode === "shell" ? shellHistory.entries : history.entries
     const current = store.historyIndex
 
@@ -627,6 +717,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }
 
   const handleKeyDown = (event: KeyboardEvent) => {
+    if (event.key === "Backspace") {
+      const selection = window.getSelection()
+      if (selection && selection.isCollapsed) {
+        const node = selection.anchorNode
+        const offset = selection.anchorOffset
+        if (node && node.nodeType === Node.TEXT_NODE) {
+          const text = node.textContent ?? ""
+          if (/^\u200B+$/.test(text) && offset > 0) {
+            const range = document.createRange()
+            range.setStart(node, 0)
+            range.collapse(true)
+            selection.removeAllRanges()
+            selection.addRange(range)
+          }
+        }
+      }
+    }
+
     if (event.key === "!" && store.mode === "normal") {
       const cursorPosition = getCursorPosition(editorRef)
       if (cursorPosition === 0) {
@@ -667,7 +775,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
       const cursorPosition = getCursorPosition(editorRef)
       const textLength = promptLength(prompt.current())
-      const textContent = editorRef.textContent ?? ""
+      const textContent = prompt
+        .current()
+        .map((part) => ("content" in part ? part.content : ""))
+        .join("")
       const isEmpty = textContent.trim() === "" || textLength <= 1
       const hasNewlines = textContent.includes("\n")
       const inHistory = store.historyIndex >= 0
@@ -691,6 +802,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       return
     }
 
+    if (event.key === "Enter" && event.shiftKey) {
+      addPart({ type: "text", content: "\n", start: 0, end: 0 })
+      event.preventDefault()
+      return
+    }
     if (event.key === "Enter" && !event.shiftKey) {
       handleSubmit(event)
     }
@@ -716,7 +832,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     addToHistory(currentPrompt, store.mode)
     setStore("historyIndex", -1)
     setStore("savedPrompt", null)
-    setStore("userHasEdited", false)
 
     let existing = info()
     if (!existing) {
@@ -776,12 +891,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     const agent = local.agent.current()!.name
 
     if (isShellMode) {
-      sdk.client.session.shell({
-        sessionID: existing.id,
-        agent,
-        model,
-        command: text,
-      })
+      sdk.client.session
+        .shell({
+          sessionID: existing.id,
+          agent,
+          model,
+          command: text,
+        })
+        .catch((e) => {
+          console.error("Failed to send shell command", e)
+        })
       return
     }
 
@@ -790,13 +909,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const commandName = cmdName.slice(1)
       const customCommand = sync.data.command.find((c) => c.name === commandName)
       if (customCommand) {
-        sdk.client.session.command({
-          sessionID: existing.id,
-          command: commandName,
-          arguments: args.join(" "),
-          agent,
-          model: `${model.providerID}/${model.modelID}`,
-        })
+        sdk.client.session
+          .command({
+            sessionID: existing.id,
+            command: commandName,
+            arguments: args.join(" "),
+            agent,
+            model: `${model.providerID}/${model.modelID}`,
+          })
+          .catch((e) => {
+            console.error("Failed to send command", e)
+          })
         return
       }
     }
@@ -822,13 +945,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       model,
     })
 
-    sdk.client.session.prompt({
-      sessionID: existing.id,
-      agent,
-      model,
-      messageID,
-      parts: requestParts,
-    })
+    sdk.client.session
+      .prompt({
+        sessionID: existing.id,
+        agent,
+        model,
+        messageID,
+        parts: requestParts,
+      })
+      .catch((e) => {
+        console.error("Failed to send prompt", e)
+      })
   }
 
   return (
@@ -903,9 +1030,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       </Show>
       <form
         onSubmit={handleSubmit}
-        onDragOver={handleDragOver}
-        onDragLeave={handleDragLeave}
-        onDrop={handleDrop}
         classList={{
           "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
           "rounded-md overflow-clip focus-within:shadow-xs-border": true,
@@ -955,7 +1079,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             </For>
           </div>
         </Show>
-        <div class="relative max-h-[240px] overflow-y-auto">
+        <div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}>
           <div
             data-component="prompt-input"
             ref={(el) => {
@@ -966,18 +1090,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             onInput={handleInput}
             onKeyDown={handleKeyDown}
             classList={{
-              "w-full px-5 py-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
-              "[&>[data-type=file]]:text-icon-info-active": true,
+              "w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
+              "[&_[data-type=file]]:text-icon-info-active": true,
               "font-mono!": store.mode === "shell",
             }}
           />
           <Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
-            <div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none">
+            <div class="absolute top-0 inset-x-0 px-5 py-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
               {store.mode === "shell"
                 ? "Enter shell command..."
                 : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
             </div>
           </Show>
+          <div class="absolute top-4.5 right-4">
+            <SessionContextUsage />
+          </div>
         </div>
         <div class="relative p-3 flex items-center justify-between">
           <div class="flex items-center justify-start gap-1">
@@ -1026,7 +1153,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                     }
                   >
                     {local.model.current()?.name ?? "Select model"}
-                    <span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
+                    <span class="hidden md:block ml-0.5 text-text-weak text-12-regular">
+                      {local.model.current()?.provider.name}
+                    </span>
                     <Icon name="chevron-down" size="small" />
                   </Button>
                 </Tooltip>
@@ -1091,23 +1220,56 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   )
 }
 
+function createTextFragment(content: string): DocumentFragment {
+  const fragment = document.createDocumentFragment()
+  const segments = content.split("\n")
+  segments.forEach((segment, index) => {
+    if (segment) {
+      fragment.appendChild(document.createTextNode(segment))
+    } else if (segments.length > 1) {
+      fragment.appendChild(document.createTextNode("\u200B"))
+    }
+    if (index < segments.length - 1) {
+      fragment.appendChild(document.createElement("br"))
+    }
+  })
+  return fragment
+}
+
+function getNodeLength(node: Node): number {
+  if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
+  return (node.textContent ?? "").replace(/\u200B/g, "").length
+}
+
+function getTextLength(node: Node): number {
+  if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
+  if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
+  let length = 0
+  for (const child of Array.from(node.childNodes)) {
+    length += getTextLength(child)
+  }
+  return length
+}
+
 function getCursorPosition(parent: HTMLElement): number {
   const selection = window.getSelection()
   if (!selection || selection.rangeCount === 0) return 0
   const range = selection.getRangeAt(0)
+  if (!parent.contains(range.startContainer)) return 0
   const preCaretRange = range.cloneRange()
   preCaretRange.selectNodeContents(parent)
   preCaretRange.setEnd(range.startContainer, range.startOffset)
-  return preCaretRange.toString().length
+  return getTextLength(preCaretRange.cloneContents())
 }
 
 function setCursorPosition(parent: HTMLElement, position: number) {
   let remaining = position
   let node = parent.firstChild
   while (node) {
-    const length = node.textContent ? node.textContent.length : 0
+    const length = getNodeLength(node)
     const isText = node.nodeType === Node.TEXT_NODE
     const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
+    const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
 
     if (isText && remaining <= length) {
       const range = document.createRange()
@@ -1119,10 +1281,24 @@ function setCursorPosition(parent: HTMLElement, position: number) {
       return
     }
 
-    if (isFile && remaining <= length) {
+    if ((isFile || isBreak) && remaining <= length) {
       const range = document.createRange()
       const selection = window.getSelection()
-      range.setStartAfter(node)
+      if (remaining === 0) {
+        range.setStartBefore(node)
+      }
+      if (remaining > 0 && isFile) {
+        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)

+ 57 - 0
packages/app/src/components/session-context-usage.tsx

@@ -0,0 +1,57 @@
+import { createMemo, Show } from "solid-js"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
+import { useSync } from "@/context/sync"
+import { useParams } from "@solidjs/router"
+import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
+
+export function SessionContextUsage() {
+  const sync = useSync()
+  const params = useParams()
+  const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
+
+  const cost = createMemo(() => {
+    const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
+    return new Intl.NumberFormat("en-US", {
+      style: "currency",
+      currency: "USD",
+    }).format(total)
+  })
+
+  const context = createMemo(() => {
+    const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 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(),
+      percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
+    }
+  })
+
+  return (
+    <Show when={context?.()}>
+      {(ctx) => (
+        <Tooltip
+          value={
+            <div class="grid grid-cols-2 gap-x-3 gap-y-1">
+              <span class="opacity-70 text-right">Tokens</span>
+              <span class="text-left">{ctx().tokens}</span>
+              <span class="opacity-70 text-right">Usage</span>
+              <span class="text-left">{ctx().percentage ?? 0}%</span>
+              <span class="opacity-70 text-right">Cost</span>
+              <span class="text-left">{cost()}</span>
+            </div>
+          }
+          placement="top"
+        >
+          <div class="flex items-center gap-1.5">
+            <ProgressCircle size={16} strokeWidth={2} percentage={ctx().percentage ?? 0} />
+            {/* <span class="text-12-medium text-text-weak">{`${ctx().percentage ?? 0}%`}</span> */}
+          </div>
+        </Tooltip>
+      )}
+    </Show>
+  )
+}

+ 40 - 0
packages/app/src/components/session-lsp-indicator.tsx

@@ -0,0 +1,40 @@
+import { createMemo, Show } from "solid-js"
+import { Icon } from "@opencode-ai/ui/icon"
+import { useSync } from "@/context/sync"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+
+export function SessionLspIndicator() {
+  const sync = useSync()
+
+  const lspStats = createMemo(() => {
+    const lsp = sync.data.lsp ?? []
+    const connected = lsp.filter((s) => s.status === "connected").length
+    const hasError = lsp.some((s) => s.status === "error")
+    const total = lsp.length
+    return { connected, hasError, total }
+  })
+
+  const tooltipContent = createMemo(() => {
+    const lsp = sync.data.lsp ?? []
+    if (lsp.length === 0) return "No LSP servers"
+    return lsp.map((s) => s.name).join(", ")
+  })
+
+  return (
+    <Show when={lspStats().total > 0}>
+      <Tooltip placement="top" value={tooltipContent()}>
+        <div class="flex items-center gap-1 px-2 cursor-default select-none">
+          <Icon
+            name="code"
+            size="small"
+            classList={{
+              "text-icon-critical-base": lspStats().hasError,
+              "text-icon-success-base": !lspStats().hasError && lspStats().connected > 0,
+            }}
+          />
+          <span class="text-12-regular text-text-weak">{lspStats().connected} LSP</span>
+        </div>
+      </Tooltip>
+    </Show>
+  )
+}

+ 36 - 0
packages/app/src/components/session-mcp-indicator.tsx

@@ -0,0 +1,36 @@
+import { createMemo, Show } from "solid-js"
+import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useSync } from "@/context/sync"
+import { DialogSelectMcp } from "@/components/dialog-select-mcp"
+
+export function SessionMcpIndicator() {
+  const sync = useSync()
+  const dialog = useDialog()
+
+  const mcpStats = createMemo(() => {
+    const mcp = sync.data.mcp ?? {}
+    const entries = Object.entries(mcp)
+    const enabled = entries.filter(([, status]) => status.status === "connected").length
+    const failed = entries.some(([, status]) => status.status === "failed")
+    const total = entries.length
+    return { enabled, failed, total }
+  })
+
+  return (
+    <Show when={mcpStats().total > 0}>
+      <Button variant="ghost" onClick={() => dialog.show(() => <DialogSelectMcp />)}>
+        <Icon
+          name="mcp"
+          size="small"
+          classList={{
+            "text-icon-critical-base": mcpStats().failed,
+            "text-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0,
+          }}
+        />
+        <span class="text-12-regular text-text-weak">{mcpStats().enabled} MCP</span>
+      </Button>
+    </Show>
+  )
+}

+ 32 - 0
packages/app/src/components/status-bar.tsx

@@ -0,0 +1,32 @@
+import { createMemo, Show, type ParentProps } from "solid-js"
+import { usePlatform } from "@/context/platform"
+import { useSync } from "@/context/sync"
+import { useGlobalSync } from "@/context/global-sync"
+
+export function StatusBar(props: ParentProps) {
+  const platform = usePlatform()
+  const sync = useSync()
+  const globalSync = useGlobalSync()
+
+  const directoryDisplay = createMemo(() => {
+    const directory = sync.data.path.directory || ""
+    const home = globalSync.data.path.home || ""
+    const short = home && directory.startsWith(home) ? directory.replace(home, "~") : directory
+    const branch = sync.data.vcs?.branch
+    return branch ? `${short}:${branch}` : short
+  })
+
+  return (
+    <div class="h-8 w-full shrink-0 flex items-center justify-between px-2 border-t border-border-weak-base bg-background-base">
+      <div class="flex items-center gap-3">
+        <Show when={platform.version}>
+          <span class="text-12-regular text-text-weak">v{platform.version}</span>
+        </Show>
+        <Show when={directoryDisplay()}>
+          <span class="text-12-regular text-text-weak">{directoryDisplay()}</span>
+        </Show>
+      </div>
+      <div class="flex items-center">{props.children}</div>
+    </div>
+  )
+}

+ 242 - 0
packages/app/src/components/terminal.tsx

@@ -0,0 +1,242 @@
+import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
+import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
+import { useSDK } from "@/context/sdk"
+import { SerializeAddon } from "@/addons/serialize"
+import { LocalPTY } from "@/context/terminal"
+import { resolveThemeVariant, useTheme } from "@opencode-ai/ui/theme"
+
+export interface TerminalProps extends ComponentProps<"div"> {
+  pty: LocalPTY
+  onSubmit?: () => void
+  onCleanup?: (pty: LocalPTY) => void
+  onConnectError?: (error: unknown) => void
+}
+
+type TerminalColors = {
+  background: string
+  foreground: string
+  cursor: string
+}
+
+const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
+  light: {
+    background: "#fcfcfc",
+    foreground: "#211e1e",
+    cursor: "#211e1e",
+  },
+  dark: {
+    background: "#191515",
+    foreground: "#d4d4d4",
+    cursor: "#d4d4d4",
+  },
+}
+
+export const Terminal = (props: TerminalProps) => {
+  const sdk = useSDK()
+  const theme = useTheme()
+  let container!: HTMLDivElement
+  const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
+  let ws: WebSocket
+  let term: Term
+  let ghostty: Ghostty
+  let serializeAddon: SerializeAddon
+  let fitAddon: FitAddon
+  let handleResize: () => void
+
+  const getTerminalColors = (): TerminalColors => {
+    const mode = theme.mode()
+    const fallback = DEFAULT_TERMINAL_COLORS[mode]
+    const currentTheme = theme.themes()[theme.themeId()]
+    if (!currentTheme) return fallback
+    const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
+    if (!variant?.seeds) return fallback
+    const resolved = resolveThemeVariant(variant, mode === "dark")
+    const text = resolved["text-base"] ?? fallback.foreground
+    const background = resolved["background-stronger"] ?? fallback.background
+    return {
+      background,
+      foreground: text,
+      cursor: text,
+    }
+  }
+
+  const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
+
+  createEffect(() => {
+    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)
+  })
+
+  const focusTerminal = () => term?.focus()
+  const copySelection = () => {
+    if (!term || !term.hasSelection()) return false
+    const selection = term.getSelection()
+    if (!selection) return false
+    const clipboard = navigator.clipboard
+    if (clipboard?.writeText) {
+      clipboard.writeText(selection).catch(() => {})
+      return true
+    }
+    if (!document.body) return false
+    const textarea = document.createElement("textarea")
+    textarea.value = selection
+    textarea.setAttribute("readonly", "")
+    textarea.style.position = "fixed"
+    textarea.style.opacity = "0"
+    document.body.appendChild(textarea)
+    textarea.select()
+    const copied = document.execCommand("copy")
+    document.body.removeChild(textarea)
+    return copied
+  }
+  const handlePointerDown = () => {
+    const activeElement = document.activeElement
+    if (activeElement instanceof HTMLElement && activeElement !== container) {
+      activeElement.blur()
+    }
+    focusTerminal()
+  }
+
+  onMount(async () => {
+    ghostty = await Ghostty.load()
+
+    ws = new WebSocket(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
+    term = new Term({
+      cursorBlink: true,
+      fontSize: 14,
+      fontFamily: "IBM Plex Mono, monospace",
+      allowTransparency: true,
+      theme: terminalColors(),
+      scrollback: 10_000,
+      ghostty,
+    })
+    term.attachCustomKeyEventHandler((event) => {
+      const key = event.key.toLowerCase()
+      if (key === "c") {
+        const macCopy = event.metaKey && !event.ctrlKey && !event.altKey
+        const linuxCopy = event.ctrlKey && event.shiftKey && !event.metaKey
+        if ((macCopy || linuxCopy) && copySelection()) {
+          event.preventDefault()
+          return true
+        }
+      }
+      // allow for ctrl-` to toggle terminal in parent
+      if (event.ctrlKey && key === "`") {
+        event.preventDefault()
+        return true
+      }
+      return false
+    })
+
+    fitAddon = new FitAddon()
+    serializeAddon = new SerializeAddon()
+    term.loadAddon(serializeAddon)
+    term.loadAddon(fitAddon)
+
+    term.open(container)
+    container.addEventListener("pointerdown", handlePointerDown)
+    focusTerminal()
+
+    if (local.pty.buffer) {
+      if (local.pty.rows && local.pty.cols) {
+        term.resize(local.pty.cols, local.pty.rows)
+      }
+      term.reset()
+      term.write(local.pty.buffer)
+      if (local.pty.scrollY) {
+        term.scrollToLine(local.pty.scrollY)
+      }
+      fitAddon.fit()
+    }
+
+    fitAddon.observeResize()
+    handleResize = () => fitAddon.fit()
+    window.addEventListener("resize", handleResize)
+    term.onResize(async (size) => {
+      if (ws && ws.readyState === WebSocket.OPEN) {
+        await sdk.client.pty
+          .update({
+            ptyID: local.pty.id,
+            size: {
+              cols: size.cols,
+              rows: size.rows,
+            },
+          })
+          .catch(() => {})
+      }
+    })
+    term.onData((data) => {
+      if (ws && ws.readyState === WebSocket.OPEN) {
+        ws.send(data)
+      }
+    })
+    term.onKey((key) => {
+      if (key.key == "Enter") {
+        props.onSubmit?.()
+      }
+    })
+    // term.onScroll((ydisp) => {
+    // console.log("Scroll position:", ydisp)
+    // })
+    ws.addEventListener("open", () => {
+      console.log("WebSocket connected")
+      sdk.client.pty
+        .update({
+          ptyID: local.pty.id,
+          size: {
+            cols: term.cols,
+            rows: term.rows,
+          },
+        })
+        .catch(() => {})
+    })
+    ws.addEventListener("message", (event) => {
+      term.write(event.data)
+    })
+    ws.addEventListener("error", (error) => {
+      console.error("WebSocket error:", error)
+      props.onConnectError?.(error)
+    })
+    ws.addEventListener("close", () => {
+      console.log("WebSocket disconnected")
+    })
+  })
+
+  onCleanup(() => {
+    if (handleResize) {
+      window.removeEventListener("resize", handleResize)
+    }
+    container.removeEventListener("pointerdown", handlePointerDown)
+    if (serializeAddon && props.onCleanup) {
+      const buffer = serializeAddon.serialize()
+      props.onCleanup({
+        ...local.pty,
+        buffer,
+        rows: term.rows,
+        cols: term.cols,
+        scrollY: term.getViewportY(),
+      })
+    }
+    ws?.close()
+    term?.dispose()
+  })
+
+  return (
+    <div
+      ref={container}
+      data-component="terminal"
+      data-prevent-autofocus
+      style={{ "background-color": terminalColors().background }}
+      classList={{
+        ...(local.classList ?? {}),
+        "size-full px-6 py-3 font-mono": true,
+        [local.class ?? ""]: !!local.class,
+      }}
+      {...others}
+    />
+  )
+}

+ 25 - 7
packages/desktop/src/context/command.tsx → packages/app/src/context/command.tsx

@@ -26,6 +26,7 @@ export interface CommandOption {
   suggested?: boolean
   disabled?: boolean
   onSelect?: (source?: "palette" | "keybind" | "slash") => void
+  onHighlight?: () => (() => void) | void
 }
 
 export function parseKeybind(config: string): Keybind[] {
@@ -115,23 +116,40 @@ export function formatKeybind(config: string): string {
 
 function DialogCommand(props: { options: CommandOption[] }) {
   const dialog = useDialog()
+  let cleanup: (() => void) | void
+  let committed = false
+
+  const handleMove = (option: CommandOption | undefined) => {
+    cleanup?.()
+    cleanup = option?.onHighlight?.()
+  }
+
+  const handleSelect = (option: CommandOption | undefined) => {
+    if (option) {
+      committed = true
+      cleanup = undefined
+      dialog.close()
+      option.onSelect?.("palette")
+    }
+  }
+
+  onCleanup(() => {
+    if (!committed) {
+      cleanup?.()
+    }
+  })
 
   return (
     <Dialog title="Commands">
       <List
-        class="px-2.5"
         search={{ placeholder: "Search commands", autofocus: true }}
         emptyMessage="No commands found"
         items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
         key={(x) => x?.id}
         filterKeys={["title", "description", "category"]}
         groupBy={(x) => x.category ?? ""}
-        onSelect={(option) => {
-          if (option) {
-            dialog.close()
-            option.onSelect?.("palette")
-          }
-        }}
+        onMove={handleMove}
+        onSelect={handleSelect}
       >
         {(option) => (
           <div class="w-full flex items-center justify-between gap-4">

+ 0 - 0
packages/desktop/src/context/global-sdk.tsx → packages/app/src/context/global-sdk.tsx


+ 140 - 20
packages/desktop/src/context/global-sync.tsx → packages/app/src/context/global-sync.tsx

@@ -5,8 +5,6 @@ import {
   type Part,
   type Config,
   type Path,
-  type File,
-  type FileNode,
   type Project,
   type FileDiff,
   type Todo,
@@ -14,6 +12,10 @@ import {
   type ProviderListResponse,
   type ProviderAuthResponse,
   type Command,
+  type McpStatus,
+  type LspStatus,
+  type VcsInfo,
+  type Permission,
   createOpencodeClient,
 } from "@opencode-ai/sdk/v2/client"
 import { createStore, produce, reconcile } from "solid-js/store"
@@ -21,7 +23,7 @@ import { Binary } from "@opencode-ai/util/binary"
 import { retry } from "@opencode-ai/util/retry"
 import { useGlobalSDK } from "./global-sdk"
 import { ErrorPage, type InitError } from "../pages/error"
-import { createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
+import { batch, createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
 import { showToast } from "@opencode-ai/ui/toast"
 import { getFilename } from "@opencode-ai/util/path"
 
@@ -43,6 +45,14 @@ type State = {
   todo: {
     [sessionID: string]: Todo[]
   }
+  permission: {
+    [sessionID: string]: Permission[]
+  }
+  mcp: {
+    [name: string]: McpStatus
+  }
+  lsp: LspStatus[]
+  vcs: VcsInfo | undefined
   limit: number
   message: {
     [sessionID: string]: Message[]
@@ -50,8 +60,6 @@ type State = {
   part: {
     [messageID: string]: Part[]
   }
-  node: FileNode[]
-  changes: File[]
 }
 
 function createGlobalSync() {
@@ -63,21 +71,19 @@ function createGlobalSync() {
     project: Project[]
     provider: ProviderListResponse
     provider_auth: ProviderAuthResponse
-    children: Record<string, State>
   }>({
     ready: false,
     path: { state: "", config: "", worktree: "", directory: "", home: "" },
     project: [],
     provider: { all: [], connected: [], default: {} },
     provider_auth: {},
-    children: {},
   })
 
   const children: Record<string, ReturnType<typeof createStore<State>>> = {}
   function child(directory: string) {
     if (!directory) console.error("No directory provided")
     if (!children[directory]) {
-      setGlobalStore("children", directory, {
+      children[directory] = createStore<State>({
         project: "",
         provider: { all: [], connected: [], default: {} },
         config: {},
@@ -89,13 +95,14 @@ function createGlobalSync() {
         session_status: {},
         session_diff: {},
         todo: {},
+        permission: {},
+        mcp: {},
+        lsp: [],
+        vcs: undefined,
         limit: 5,
         message: {},
         part: {},
-        node: [],
-        changes: [],
       })
-      children[directory] = createStore(globalStore.children[directory])
       bootstrapInstance(directory)
     }
     return children[directory]
@@ -117,7 +124,7 @@ function createGlobalSync() {
           const updated = new Date(s.time.updated).getTime()
           return updated > fourHoursAgo
         })
-        setStore("session", sessions)
+        setStore("session", reconcile(sessions, { key: "id" }))
       })
       .catch((err) => {
         console.error("Failed to load sessions", err)
@@ -128,7 +135,7 @@ function createGlobalSync() {
 
   async function bootstrapInstance(directory: string) {
     if (!directory) return
-    const [, setStore] = child(directory)
+    const [store, setStore] = child(directory)
     const sdk = createOpencodeClient({
       baseUrl: globalSDK.url,
       directory,
@@ -136,15 +143,57 @@ function createGlobalSync() {
     })
     const load = {
       project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
-      provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)),
+      provider: () =>
+        sdk.provider.list().then((x) => {
+          const data = x.data!
+          setStore("provider", {
+            ...data,
+            all: data.all.map((provider) => ({
+              ...provider,
+              models: Object.fromEntries(
+                Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
+              ),
+            })),
+          })
+        }),
       path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
       agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
       command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])),
       session: () => loadSessions(directory),
       status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
       config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
-      changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
-      node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
+      mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})),
+      lsp: () => sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])),
+      vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)),
+      permission: () =>
+        sdk.permission.list().then((x) => {
+          const grouped: Record<string, Permission[]> = {}
+          for (const perm of x.data ?? []) {
+            const existing = grouped[perm.sessionID]
+            if (existing) {
+              existing.push(perm)
+              continue
+            }
+            grouped[perm.sessionID] = [perm]
+          }
+
+          batch(() => {
+            for (const sessionID of Object.keys(store.permission)) {
+              if (grouped[sessionID]) continue
+              setStore("permission", sessionID, [])
+            }
+            for (const [sessionID, permissions] of Object.entries(grouped)) {
+              setStore(
+                "permission",
+                sessionID,
+                reconcile(
+                  permissions.slice().sort((a, b) => a.id.localeCompare(b.id)),
+                  { key: "id" },
+                ),
+              )
+            }
+          })
+        }),
     }
     await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
       .then(() => setStore("ready", true))
@@ -211,13 +260,13 @@ function createGlobalSync() {
         break
       }
       case "session.diff":
-        setStore("session_diff", event.properties.sessionID, event.properties.diff)
+        setStore("session_diff", event.properties.sessionID, reconcile(event.properties.diff, { key: "file" }))
         break
       case "todo.updated":
-        setStore("todo", event.properties.sessionID, event.properties.todos)
+        setStore("todo", event.properties.sessionID, reconcile(event.properties.todos, { key: "id" }))
         break
       case "session.status": {
-        setStore("session_status", event.properties.sessionID, event.properties.status)
+        setStore("session_status", event.properties.sessionID, reconcile(event.properties.status))
         break
       }
       case "message.updated": {
@@ -291,10 +340,72 @@ function createGlobalSync() {
         }
         break
       }
+      case "vcs.branch.updated": {
+        setStore("vcs", { branch: event.properties.branch })
+        break
+      }
+      case "permission.updated": {
+        const sessionID = event.properties.sessionID
+        const permissions = store.permission[sessionID]
+        if (!permissions) {
+          setStore("permission", sessionID, [event.properties])
+          break
+        }
+
+        const result = Binary.search(permissions, event.properties.id, (p) => p.id)
+        if (result.found) {
+          setStore("permission", sessionID, result.index, reconcile(event.properties))
+          break
+        }
+
+        setStore(
+          "permission",
+          sessionID,
+          produce((draft) => {
+            draft.splice(result.index, 0, event.properties)
+          }),
+        )
+        break
+      }
+      case "permission.replied": {
+        const permissions = store.permission[event.properties.sessionID]
+        if (!permissions) break
+        const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
+        if (!result.found) break
+        setStore(
+          "permission",
+          event.properties.sessionID,
+          produce((draft) => {
+            draft.splice(result.index, 1)
+          }),
+        )
+        break
+      }
+      case "lsp.updated": {
+        const sdk = createOpencodeClient({
+          baseUrl: globalSDK.url,
+          directory,
+          throwOnError: true,
+        })
+        sdk.lsp.status().then((x) => setStore("lsp", x.data ?? []))
+        break
+      }
     }
   })
 
   async function bootstrap() {
+    const health = await globalSDK.client.global
+      .health()
+      .then((x) => x.data)
+      .catch(() => undefined)
+    if (!health?.healthy) {
+      setGlobalStore(
+        "error",
+        new Error(`Could not connect to server. Is there a server running at \`${globalSDK.url}\`?`),
+      )
+      return
+    }
+
     return Promise.all([
       retry(() =>
         globalSDK.client.path.get().then((x) => {
@@ -311,7 +422,16 @@ function createGlobalSync() {
       ),
       retry(() =>
         globalSDK.client.provider.list().then((x) => {
-          setGlobalStore("provider", x.data ?? {})
+          const data = x.data!
+          setGlobalStore("provider", {
+            ...data,
+            all: data.all.map((provider) => ({
+              ...provider,
+              models: Object.fromEntries(
+                Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
+              ),
+            })),
+          })
         }),
       ),
       retry(() =>

+ 20 - 2
packages/desktop/src/context/layout.tsx → packages/app/src/context/layout.tsx

@@ -46,6 +46,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           opened: false,
           height: 280,
         },
+        review: {
+          opened: true,
+        },
         session: {
           width: 600,
         },
@@ -67,6 +70,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         {
           ...project,
           ...(metadata ?? {}),
+          icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
         },
       ]
     }
@@ -108,10 +112,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
         },
         expand(directory: string) {
-          setStore("projects", (x) => x.map((x) => (x.worktree === directory ? { ...x, expanded: true } : x)))
+          const index = store.projects.findIndex((x) => x.worktree === directory)
+          if (index !== -1) setStore("projects", index, "expanded", true)
         },
         collapse(directory: string) {
-          setStore("projects", (x) => x.map((x) => (x.worktree === directory ? { ...x, expanded: false } : x)))
+          const index = store.projects.findIndex((x) => x.worktree === directory)
+          if (index !== -1) setStore("projects", index, "expanded", false)
         },
         move(directory: string, toIndex: number) {
           setStore("projects", (projects) => {
@@ -156,6 +162,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           setStore("terminal", "height", height)
         },
       },
+      review: {
+        opened: createMemo(() => store.review?.opened ?? true),
+        open() {
+          setStore("review", "opened", true)
+        },
+        close() {
+          setStore("review", "opened", false)
+        },
+        toggle() {
+          setStore("review", "opened", (x) => !x)
+        },
+      },
       session: {
         width: createMemo(() => store.session?.width ?? 600),
         resize(width: number) {

+ 51 - 56
packages/desktop/src/context/local.tsx → packages/app/src/context/local.tsx

@@ -1,5 +1,5 @@
 import { createStore, produce, reconcile } from "solid-js/store"
-import { batch, createEffect, createMemo } from "solid-js"
+import { batch, createMemo } from "solid-js"
 import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
 import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
 import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -9,6 +9,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
 import { useProviders } from "@/hooks/use-providers"
 import { DateTime } from "luxon"
 import { persisted } from "@/utils/persist"
+import { showToast } from "@opencode-ai/ui/toast"
 
 export type LocalFile = FileNode &
   Partial<{
@@ -61,24 +62,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       }
     }
 
-    // Automatically update model when agent changes
-    createEffect(() => {
-      const value = agent.current()
-      if (value.model) {
-        if (isModelValid(value.model))
-          model.set({
-            providerID: value.model.providerID,
-            modelID: value.model.modelID,
-          })
-        // else
-        //   toast.show({
-        //     type: "warning",
-        //     message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
-        //     duration: 3000,
-        //   })
-      }
-    })
-
     const agent = (() => {
       const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
       const [store, setStore] = createStore<{
@@ -276,11 +259,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       const [store, setStore] = createStore<{
         node: Record<string, LocalFile>
       }>({
-        node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
+        node: {}, //  Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
       })
 
-      const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
-      const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
+      // const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
+      // const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
 
       // createEffect((prev: FileStatus[]) => {
       //   const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
@@ -308,16 +291,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       //   return sync.data.changes
       // }, sync.data.changes)
 
-      const changed = (path: string) => {
-        const node = store.node[path]
-        if (node?.status) return true
-        const set = changeset()
-        if (set.has(path)) return true
-        for (const p of set) {
-          if (p.startsWith(path ? path + "/" : "")) return true
-        }
-        return false
-      }
+      // const changed = (path: string) => {
+      //   const node = store.node[path]
+      //   if (node?.status) return true
+      //   const set = changeset()
+      //   if (set.has(path)) return true
+      //   for (const p of set) {
+      //     if (p.startsWith(path ? path + "/" : "")) return true
+      //   }
+      //   return false
+      // }
 
       // const resetNode = (path: string) => {
       //   setStore("node", path, {
@@ -336,17 +319,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
 
       const load = async (path: string) => {
         const relativePath = relative(path)
-        await sdk.client.file.read({ path: relativePath }).then((x) => {
-          if (!store.node[relativePath]) return
-          setStore(
-            "node",
-            relativePath,
-            produce((draft) => {
-              draft.loaded = true
-              draft.content = x.data
-            }),
-          )
-        })
+        await sdk.client.file
+          .read({ path: relativePath })
+          .then((x) => {
+            if (!store.node[relativePath]) return
+            setStore(
+              "node",
+              relativePath,
+              produce((draft) => {
+                draft.loaded = true
+                draft.content = x.data
+              }),
+            )
+          })
+          .catch((e) => {
+            showToast({
+              variant: "error",
+              title: "Failed to load file",
+              description: e.message,
+            })
+          })
       }
 
       const fetch = async (path: string) => {
@@ -385,17 +377,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       }
 
       const list = async (path: string) => {
-        return sdk.client.file.list({ path: path + "/" }).then((x) => {
-          setStore(
-            "node",
-            produce((draft) => {
-              x.data!.forEach((node) => {
-                if (node.path in draft) return
-                draft[node.path] = node
-              })
-            }),
-          )
-        })
+        return sdk.client.file
+          .list({ path: path + "/" })
+          .then((x) => {
+            setStore(
+              "node",
+              produce((draft) => {
+                x.data!.forEach((node) => {
+                  if (node.path in draft) return
+                  draft[node.path] = node
+                })
+              }),
+            )
+          })
+          .catch(() => {})
       }
 
       const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!)
@@ -466,8 +461,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
         setChangeIndex(path: string, index: number | undefined) {
           setStore("node", path, "selectedChange", index)
         },
-        changes,
-        changed,
+        // changes,
+        // changed,
         children(path: string) {
           return Object.values(store.node).filter(
             (x) =>

+ 0 - 0
packages/desktop/src/context/notification.tsx → packages/app/src/context/notification.tsx


+ 3 - 0
packages/desktop/src/context/platform.tsx → packages/app/src/context/platform.tsx

@@ -5,6 +5,9 @@ export type Platform = {
   /** Platform discriminator */
   platform: "web" | "tauri"
 
+  /** App version */
+  version?: string
+
   /** Open a URL in the default browser */
   openLink(url: string): void
 

+ 0 - 0
packages/desktop/src/context/prompt.tsx → packages/app/src/context/prompt.tsx


+ 0 - 0
packages/desktop/src/context/sdk.tsx → packages/app/src/context/sdk.tsx


+ 44 - 20
packages/desktop/src/context/sync.tsx → packages/app/src/context/sync.tsx

@@ -1,5 +1,5 @@
-import { produce } from "solid-js/store"
-import { createMemo } from "solid-js"
+import { batch, createMemo } from "solid-js"
+import { produce, reconcile } from "solid-js/store"
 import { Binary } from "@opencode-ai/util/binary"
 import { retry } from "@opencode-ai/util/retry"
 import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -56,7 +56,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
                 const result = Binary.search(messages, input.messageID, (m) => m.id)
                 messages.splice(result.index, 0, message)
               }
-              draft.part[input.messageID] = input.parts.slice()
+              draft.part[input.messageID] = input.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
             }),
           )
         },
@@ -67,22 +67,46 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             retry(() => sdk.client.session.todo({ sessionID })),
             retry(() => sdk.client.session.diff({ sessionID })),
           ])
-          setStore(
-            produce((draft) => {
-              const match = Binary.search(draft.session, sessionID, (s) => s.id)
-              if (match.found) draft.session[match.index] = session.data!
-              if (!match.found) draft.session.splice(match.index, 0, session.data!)
-              draft.todo[sessionID] = todo.data ?? []
-              draft.message[sessionID] = messages
-                .data!.map((x) => x.info)
-                .slice()
-                .sort((a, b) => a.id.localeCompare(b.id))
-              for (const message of messages.data!) {
-                draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
-              }
-              draft.session_diff[sessionID] = diff.data ?? []
-            }),
-          )
+
+          batch(() => {
+            setStore(
+              "session",
+              produce((draft) => {
+                const match = Binary.search(draft, sessionID, (s) => s.id)
+                if (match.found) {
+                  draft[match.index] = session.data!
+                  return
+                }
+                draft.splice(match.index, 0, session.data!)
+              }),
+            )
+
+            setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
+            setStore(
+              "message",
+              sessionID,
+              reconcile(
+                (messages.data ?? [])
+                  .map((x) => x.info)
+                  .slice()
+                  .sort((a, b) => a.id.localeCompare(b.id)),
+                { key: "id" },
+              ),
+            )
+
+            for (const message of messages.data ?? []) {
+              setStore(
+                "part",
+                message.info.id,
+                reconcile(
+                  message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)),
+                  { key: "id" },
+                ),
+              )
+            }
+
+            setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
+          })
         },
         fetch: async (count = 10) => {
           setStore("limit", (x) => x + count)
@@ -91,7 +115,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
               .slice()
               .sort((a, b) => a.id.localeCompare(b.id))
               .slice(0, store.limit)
-            setStore("session", sessions)
+            setStore("session", reconcile(sessions, { key: "id" }))
           })
         },
         more: createMemo(() => store.session.length >= store.limit),

+ 38 - 22
packages/desktop/src/context/terminal.tsx → packages/app/src/context/terminal.tsx

@@ -36,35 +36,49 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
       all: createMemo(() => Object.values(store.all)),
       active: createMemo(() => store.active),
       new() {
-        sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => {
-          const id = pty.data?.id
-          if (!id) return
-          setStore("all", [
-            ...store.all,
-            {
-              id,
-              title: pty.data?.title ?? "Terminal",
-            },
-          ])
-          setStore("active", id)
-        })
+        sdk.client.pty
+          .create({ title: `Terminal ${store.all.length + 1}` })
+          .then((pty) => {
+            const id = pty.data?.id
+            if (!id) return
+            setStore("all", [
+              ...store.all,
+              {
+                id,
+                title: pty.data?.title ?? "Terminal",
+              },
+            ])
+            setStore("active", id)
+          })
+          .catch((e) => {
+            console.error("Failed to create terminal", e)
+          })
       },
       update(pty: Partial<LocalPTY> & { id: string }) {
         setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
-        sdk.client.pty.update({
-          ptyID: pty.id,
-          title: pty.title,
-          size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
-        })
+        sdk.client.pty
+          .update({
+            ptyID: pty.id,
+            title: pty.title,
+            size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
+          })
+          .catch((e) => {
+            console.error("Failed to update terminal", e)
+          })
       },
       async clone(id: string) {
         const index = store.all.findIndex((x) => x.id === id)
         const pty = store.all[index]
         if (!pty) return
-        const clone = await sdk.client.pty.create({
-          title: pty.title,
-        })
-        if (!clone.data) return
+        const clone = await sdk.client.pty
+          .create({
+            title: pty.title,
+          })
+          .catch((e) => {
+            console.error("Failed to clone terminal", e)
+            return undefined
+          })
+        if (!clone?.data) return
         setStore("all", index, {
           ...pty,
           ...clone.data,
@@ -88,7 +102,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
             setStore("active", previous?.id)
           }
         })
-        await sdk.client.pty.remove({ ptyID: id })
+        await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
+          console.error("Failed to close terminal", e)
+        })
       },
       move(id: string, to: number) {
         const index = store.all.findIndex((f) => f.id === id)

+ 0 - 0
packages/desktop/src/custom-elements.d.ts → packages/app/src/custom-elements.d.ts


+ 2 - 0
packages/desktop/src/entry.tsx → packages/app/src/entry.tsx

@@ -2,6 +2,7 @@
 import { render } from "solid-js/web"
 import { App } from "@/app"
 import { Platform, PlatformProvider } from "@/context/platform"
+import pkg from "../package.json"
 
 const root = document.getElementById("root")
 if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -12,6 +13,7 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
 
 const platform: Platform = {
   platform: "web",
+  version: pkg.version,
   openLink(url: string) {
     window.open(url, "_blank")
   },

+ 0 - 0
packages/desktop/src/env.d.ts → packages/app/src/env.d.ts


+ 0 - 0
packages/desktop/src/hooks/use-providers.ts → packages/app/src/hooks/use-providers.ts


+ 0 - 0
packages/desktop/src/index.css → packages/app/src/index.css


+ 0 - 0
packages/desktop/src/index.ts → packages/app/src/index.ts


+ 9 - 2
packages/desktop/src/pages/directory-layout.tsx → packages/app/src/pages/directory-layout.tsx

@@ -1,6 +1,6 @@
 import { createMemo, Show, type ParentProps } from "solid-js"
 import { useParams } from "@solidjs/router"
-import { SDKProvider } from "@/context/sdk"
+import { SDKProvider, useSDK } from "@/context/sdk"
 import { SyncProvider, useSync } from "@/context/sync"
 import { LocalProvider } from "@/context/local"
 import { base64Decode } from "@opencode-ai/util/encode"
@@ -18,8 +18,15 @@ export default function Layout(props: ParentProps) {
         <SyncProvider>
           {iife(() => {
             const sync = useSync()
+            const sdk = useSDK()
             return (
-              <DataProvider data={sync.data} directory={directory()}>
+              <DataProvider
+                data={sync.data}
+                directory={directory()}
+                onPermissionRespond={(input) => {
+                  sdk.client.permission.respond(input)
+                }}
+              >
                 <LocalProvider>{props.children}</LocalProvider>
               </DataProvider>
             )

+ 47 - 20
packages/desktop/src/pages/error.tsx → packages/app/src/pages/error.tsx

@@ -1,7 +1,7 @@
 import { TextField } from "@opencode-ai/ui/text-field"
 import { Logo } from "@opencode-ai/ui/logo"
 import { Button } from "@opencode-ai/ui/button"
-import { Component } from "solid-js"
+import { Component, Show } from "solid-js"
 import { usePlatform } from "@/context/platform"
 import { Icon } from "@opencode-ai/ui/icon"
 
@@ -62,27 +62,49 @@ function formatInitError(error: InitError): string {
   }
 }
 
-function formatErrorChain(error: unknown, depth = 0): string {
+function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): string {
   if (!error) return "Unknown error"
 
-  const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
-
   if (isInitError(error)) {
-    return indent + formatInitError(error)
+    const message = formatInitError(error)
+    if (depth > 0 && parentMessage === message) return ""
+    const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
+    return indent + message
   }
 
   if (error instanceof Error) {
-    const parts = [indent + `${error.name}: ${error.message}`]
-    if (error.stack) {
-      parts.push(error.stack)
+    const isDuplicate = depth > 0 && parentMessage === error.message
+    const parts: string[] = []
+    const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
+
+    if (!isDuplicate) {
+      // Stack already includes error name and message, so prefer it
+      parts.push(indent + (error.stack ?? `${error.name}: ${error.message}`))
+    } else if (error.stack) {
+      // Duplicate message - only show the stack trace lines (skip message)
+      const trace = error.stack.split("\n").slice(1).join("\n").trim()
+      if (trace) {
+        parts.push(trace)
+      }
     }
+
     if (error.cause) {
-      parts.push(formatErrorChain(error.cause, depth + 1))
+      const causeResult = formatErrorChain(error.cause, depth + 1, error.message)
+      if (causeResult) {
+        parts.push(causeResult)
+      }
     }
+
     return parts.join("\n\n")
   }
 
-  if (typeof error === "string") return indent + error
+  if (typeof error === "string") {
+    if (depth > 0 && parentMessage === error) return ""
+    const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
+    return indent + error
+  }
+
+  const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
   return indent + JSON.stringify(error, null, 2)
 }
 
@@ -116,16 +138,21 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
         <Button size="large" onClick={platform.restart}>
           Restart
         </Button>
-        <div class="flex items-center justify-center gap-1">
-          Please report this error to the OpenCode team
-          <button
-            type="button"
-            class="flex items-center text-text-interactive-base gap-1"
-            onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
-          >
-            <div>on Discord</div>
-            <Icon name="discord" class="text-text-interactive-base" />
-          </button>
+        <div class="flex flex-col items-center gap-2">
+          <div class="flex items-center justify-center gap-1">
+            Please report this error to the OpenCode team
+            <button
+              type="button"
+              class="flex items-center text-text-interactive-base gap-1"
+              onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
+            >
+              <div>on Discord</div>
+              <Icon name="discord" class="text-text-interactive-base" />
+            </button>
+          </div>
+          <Show when={platform.version}>
+            <p class="text-xs text-text-weak">Version: {platform.version}</p>
+          </Show>
         </div>
       </div>
     </div>

+ 0 - 0
packages/desktop/src/pages/home.tsx → packages/app/src/pages/home.tsx


+ 465 - 240
packages/desktop/src/pages/layout.tsx → packages/app/src/pages/layout.tsx

@@ -1,4 +1,17 @@
-import { createEffect, createMemo, For, Match, onMount, ParentProps, Show, Switch, type JSX } from "solid-js"
+import {
+  createEffect,
+  createMemo,
+  createSignal,
+  For,
+  Match,
+  onCleanup,
+  onMount,
+  ParentProps,
+  Show,
+  Switch,
+  untrack,
+  type JSX,
+} from "solid-js"
 import { DateTime } from "luxon"
 import { A, useNavigate, useParams } from "@solidjs/router"
 import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
@@ -28,23 +41,45 @@ import {
 } from "@thisbeyond/solid-dnd"
 import type { DragEvent } from "@thisbeyond/solid-dnd"
 import { useProviders } from "@/hooks/use-providers"
-import { showToast, Toast } from "@opencode-ai/ui/toast"
+import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { useNotification } from "@/context/notification"
 import { Binary } from "@opencode-ai/util/binary"
 import { Header } from "@/components/header"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
 import { DialogSelectProvider } from "@/components/dialog-select-provider"
-import { useCommand } from "@/context/command"
+import { DialogEditProject } from "@/components/dialog-edit-project"
+import { useCommand, type CommandOption } from "@/context/command"
 import { ConstrainDragXAxis } from "@/utils/solid-dnd"
 
 export default function Layout(props: ParentProps) {
   const [store, setStore] = createStore({
     lastSession: {} as { [directory: string]: string },
     activeDraggable: undefined as string | undefined,
+    mobileSidebarOpen: false,
+    mobileProjectsExpanded: {} as Record<string, boolean>,
   })
 
+  const mobileSidebar = {
+    open: () => store.mobileSidebarOpen,
+    show: () => setStore("mobileSidebarOpen", true),
+    hide: () => setStore("mobileSidebarOpen", false),
+    toggle: () => setStore("mobileSidebarOpen", (x) => !x),
+  }
+
+  const mobileProjects = {
+    expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true,
+    expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true),
+    collapse: (directory: string) => setStore("mobileProjectsExpanded", directory, false),
+  }
+
   let scrollContainerRef: HTMLDivElement | undefined
+  const xlQuery = window.matchMedia("(min-width: 1280px)")
+  const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches)
+  const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches)
+  xlQuery.addEventListener("change", handleViewportChange)
+  onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange))
 
   const params = useParams()
   const globalSDK = useGlobalSDK()
@@ -56,6 +91,41 @@ export default function Layout(props: ParentProps) {
   const providers = useProviders()
   const dialog = useDialog()
   const command = useCommand()
+  const theme = useTheme()
+  const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
+  const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
+  const colorSchemeLabel: Record<ColorScheme, string> = {
+    system: "System",
+    light: "Light",
+    dark: "Dark",
+  }
+
+  function cycleTheme(direction = 1) {
+    const ids = availableThemeEntries().map(([id]) => id)
+    if (ids.length === 0) return
+    const currentIndex = ids.indexOf(theme.themeId())
+    const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
+    const nextThemeId = ids[nextIndex]
+    theme.setTheme(nextThemeId)
+    const nextTheme = theme.themes()[nextThemeId]
+    showToast({
+      title: "Theme switched",
+      description: nextTheme?.name ?? nextThemeId,
+    })
+  }
+
+  function cycleColorScheme(direction = 1) {
+    const current = theme.colorScheme()
+    const currentIndex = colorSchemeOrder.indexOf(current)
+    const nextIndex =
+      currentIndex === -1 ? 0 : (currentIndex + direction + colorSchemeOrder.length) % colorSchemeOrder.length
+    const next = colorSchemeOrder[nextIndex]
+    theme.setColorScheme(next)
+    showToast({
+      title: "Color scheme",
+      description: colorSchemeLabel[next],
+    })
+  }
 
   onMount(async () => {
     if (platform.checkUpdate && platform.update && platform.restart) {
@@ -84,42 +154,96 @@ export default function Layout(props: ParentProps) {
     }
   })
 
-  function flattenSessions(sessions: Session[]): Session[] {
-    const childrenMap = new Map<string, Session[]>()
-    for (const session of sessions) {
-      if (session.parentID) {
-        const children = childrenMap.get(session.parentID) ?? []
-        children.push(session)
-        childrenMap.set(session.parentID, children)
+  onMount(() => {
+    const seenSessions = new Set<string>()
+    const toastBySession = new Map<string, number>()
+    const unsub = globalSDK.event.listen((e) => {
+      if (e.details?.type !== "permission.updated") return
+      const directory = e.name
+      const permission = e.details.properties
+      const sessionKey = `${directory}:${permission.sessionID}`
+      if (seenSessions.has(sessionKey)) return
+      seenSessions.add(sessionKey)
+      const currentDir = params.dir ? base64Decode(params.dir) : undefined
+      const currentSession = params.id
+      if (directory === currentDir && permission.sessionID === currentSession) return
+      const [store] = globalSync.child(directory)
+      const session = store.session.find((s) => s.id === permission.sessionID)
+      if (directory === currentDir && session?.parentID === currentSession) return
+      const sessionTitle = session?.title ?? "New session"
+      const projectName = getFilename(directory)
+      const toastId = showToast({
+        persistent: true,
+        icon: "checklist",
+        title: "Permission required",
+        description: `${sessionTitle} in ${projectName} needs permission`,
+        actions: [
+          {
+            label: "Go to session",
+            onClick: () => {
+              navigate(`/${base64Encode(directory)}/session/${permission.sessionID}`)
+            },
+          },
+          {
+            label: "Dismiss",
+            onClick: "dismiss",
+          },
+        ],
+      })
+      toastBySession.set(sessionKey, toastId)
+    })
+    onCleanup(unsub)
+
+    createEffect(() => {
+      const currentDir = params.dir ? base64Decode(params.dir) : undefined
+      const currentSession = params.id
+      if (!currentDir || !currentSession) return
+      const sessionKey = `${currentDir}:${currentSession}`
+      const toastId = toastBySession.get(sessionKey)
+      if (toastId !== undefined) {
+        toaster.dismiss(toastId)
+        toastBySession.delete(sessionKey)
+        seenSessions.delete(sessionKey)
       }
-    }
-    const result: Session[] = []
-    function visit(session: Session) {
-      result.push(session)
-      for (const child of childrenMap.get(session.id) ?? []) {
-        visit(child)
+      const [store] = globalSync.child(currentDir)
+      const childSessions = store.session.filter((s) => s.parentID === currentSession)
+      for (const child of childSessions) {
+        const childKey = `${currentDir}:${child.id}`
+        const childToastId = toastBySession.get(childKey)
+        if (childToastId !== undefined) {
+          toaster.dismiss(childToastId)
+          toastBySession.delete(childKey)
+          seenSessions.delete(childKey)
+        }
       }
-    }
-    for (const session of sessions) {
-      if (!session.parentID) visit(session)
-    }
-    return result
+    })
+  })
+
+  function sortSessions(a: Session, b: Session) {
+    const now = Date.now()
+    const oneMinuteAgo = now - 60 * 1000
+    const aUpdated = a.time.updated ?? a.time.created
+    const bUpdated = b.time.updated ?? b.time.created
+    const aRecent = aUpdated > oneMinuteAgo
+    const bRecent = bUpdated > oneMinuteAgo
+    if (aRecent && bRecent) return a.id.localeCompare(b.id)
+    if (aRecent && !bRecent) return -1
+    if (!aRecent && bRecent) return 1
+    return bUpdated - aUpdated
   }
 
   function scrollToSession(sessionId: string) {
     if (!scrollContainerRef) return
     const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
     if (element) {
-      element.scrollIntoView({ block: "center", behavior: "smooth" })
+      element.scrollIntoView({ block: "nearest", behavior: "smooth" })
     }
   }
 
   function projectSessions(directory: string) {
     if (!directory) return []
-    const sessions = globalSync
-      .child(directory)[0]
-      .session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
-    return flattenSessions(sessions ?? [])
+    const sessions = globalSync.child(directory)[0].session.toSorted(sortSessions)
+    return (sessions ?? []).filter((s) => !s.parentID)
   }
 
   const currentSessions = createMemo(() => {
@@ -199,57 +323,102 @@ export default function Layout(props: ParentProps) {
     }
   }
 
-  command.register(() => [
-    {
-      id: "sidebar.toggle",
-      title: "Toggle sidebar",
-      category: "View",
-      keybind: "mod+b",
-      onSelect: () => layout.sidebar.toggle(),
-    },
-    ...(platform.openDirectoryPickerDialog
-      ? [
-          {
-            id: "project.open",
-            title: "Open project",
-            category: "Project",
-            keybind: "mod+o",
-            onSelect: () => chooseProject(),
-          },
-        ]
-      : []),
-    {
-      id: "provider.connect",
-      title: "Connect provider",
-      category: "Provider",
-      onSelect: () => connectProvider(),
-    },
-    {
-      id: "session.previous",
-      title: "Previous session",
-      category: "Session",
-      keybind: "alt+arrowup",
-      onSelect: () => navigateSessionByOffset(-1),
-    },
-    {
-      id: "session.next",
-      title: "Next session",
-      category: "Session",
-      keybind: "alt+arrowdown",
-      onSelect: () => navigateSessionByOffset(1),
-    },
-    {
-      id: "session.archive",
-      title: "Archive session",
-      category: "Session",
-      keybind: "mod+shift+backspace",
-      disabled: !params.dir || !params.id,
-      onSelect: () => {
-        const session = currentSessions().find((s) => s.id === params.id)
-        if (session) archiveSession(session)
+  command.register(() => {
+    const commands: CommandOption[] = [
+      {
+        id: "sidebar.toggle",
+        title: "Toggle sidebar",
+        category: "View",
+        keybind: "mod+b",
+        onSelect: () => layout.sidebar.toggle(),
       },
-    },
-  ])
+      ...(platform.openDirectoryPickerDialog
+        ? [
+            {
+              id: "project.open",
+              title: "Open project",
+              category: "Project",
+              keybind: "mod+o",
+              onSelect: () => chooseProject(),
+            },
+          ]
+        : []),
+      {
+        id: "provider.connect",
+        title: "Connect provider",
+        category: "Provider",
+        onSelect: () => connectProvider(),
+      },
+      {
+        id: "session.previous",
+        title: "Previous session",
+        category: "Session",
+        keybind: "alt+arrowup",
+        onSelect: () => navigateSessionByOffset(-1),
+      },
+      {
+        id: "session.next",
+        title: "Next session",
+        category: "Session",
+        keybind: "alt+arrowdown",
+        onSelect: () => navigateSessionByOffset(1),
+      },
+      {
+        id: "session.archive",
+        title: "Archive session",
+        category: "Session",
+        keybind: "mod+shift+backspace",
+        disabled: !params.dir || !params.id,
+        onSelect: () => {
+          const session = currentSessions().find((s) => s.id === params.id)
+          if (session) archiveSession(session)
+        },
+      },
+      {
+        id: "theme.cycle",
+        title: "Cycle theme",
+        category: "Theme",
+        keybind: "mod+shift+t",
+        onSelect: () => cycleTheme(1),
+      },
+    ]
+
+    for (const [id, definition] of availableThemeEntries()) {
+      commands.push({
+        id: `theme.set.${id}`,
+        title: `Use theme: ${definition.name ?? id}`,
+        category: "Theme",
+        onSelect: () => theme.commitPreview(),
+        onHighlight: () => {
+          theme.previewTheme(id)
+          return () => theme.cancelPreview()
+        },
+      })
+    }
+
+    commands.push({
+      id: "theme.scheme.cycle",
+      title: "Cycle color scheme",
+      category: "Theme",
+      keybind: "mod+shift+s",
+      onSelect: () => cycleColorScheme(1),
+    })
+
+    for (const scheme of colorSchemeOrder) {
+      commands.push({
+        id: `theme.scheme.${scheme}`,
+        title: `Use color scheme: ${colorSchemeLabel[scheme]}`,
+        category: "Theme",
+        onSelect: () => theme.commitPreview(),
+        onHighlight: () => {
+          theme.previewColorScheme(scheme)
+          return () => theme.cancelPreview()
+        },
+      })
+    }
+
+    return commands
+  })
 
   function connectProvider() {
     dialog.show(() => <DialogSelectProvider />)
@@ -259,11 +428,13 @@ export default function Layout(props: ParentProps) {
     if (!directory) return
     const lastSession = store.lastSession[directory]
     navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
+    mobileSidebar.hide()
   }
 
   function navigateToSession(session: Session | undefined) {
     if (!session) return
     navigate(`/${params.dir}/session/${session?.id}`)
+    mobileSidebar.hide()
   }
 
   function openProject(directory: string, navigate = true) {
@@ -297,13 +468,20 @@ export default function Layout(props: ParentProps) {
   createEffect(() => {
     if (!params.dir || !params.id) return
     const directory = base64Decode(params.dir)
-    setStore("lastSession", directory, params.id)
-    notification.session.markViewed(params.id)
+    const id = params.id
+    setStore("lastSession", directory, id)
+    notification.session.markViewed(id)
+    untrack(() => layout.projects.expand(directory))
+    requestAnimationFrame(() => scrollToSession(id))
   })
 
   createEffect(() => {
-    const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
-    document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
+    if (isLargeViewport()) {
+      const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
+      document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
+    } else {
+      document.documentElement.style.setProperty("--dialog-left-margin", "0px")
+    }
   })
 
   function getDraggableId(event: unknown): string | undefined {
@@ -345,7 +523,7 @@ export default function Layout(props: ParentProps) {
     const notification = useNotification()
     const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
     const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
-    const name = createMemo(() => getFilename(props.project.worktree))
+    const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
     const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)"
     const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
 
@@ -381,7 +559,7 @@ export default function Layout(props: ParentProps) {
   }
 
   const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => {
-    const name = createMemo(() => getFilename(props.project.worktree))
+    const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
     const current = createMemo(() => base64Decode(params.dir ?? ""))
     return (
       <Switch>
@@ -417,17 +595,26 @@ export default function Layout(props: ParentProps) {
     session: Session
     slug: string
     project: LocalProject
-    depth?: number
-    childrenMap: Map<string, Session[]>
+    mobile?: boolean
   }): JSX.Element => {
     const notification = useNotification()
-    const depth = props.depth ?? 0
-    const children = createMemo(() => props.childrenMap.get(props.session.id) ?? [])
     const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
     const notifications = createMemo(() => notification.session.unseen(props.session.id))
     const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
+    const hasPermissions = createMemo(() => {
+      const store = globalSync.child(props.project.worktree)[0]
+      const permissions = store.permission?.[props.session.id] ?? []
+      if (permissions.length > 0) return true
+      const childSessions = store.session.filter((s) => s.parentID === props.session.id)
+      for (const child of childSessions) {
+        const childPermissions = store.permission?.[child.id] ?? []
+        if (childPermissions.length > 0) return true
+      }
+      return false
+    })
     const isWorking = createMemo(() => {
       if (props.session.id === params.id) return false
+      if (hasPermissions()) return false
       const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
       return status?.type === "busy" || status?.type === "retry"
     })
@@ -437,15 +624,20 @@ export default function Layout(props: ParentProps) {
           data-session-id={props.session.id}
           class="group/session relative w-full pr-2 py-1 rounded-md cursor-default transition-colors
                  hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
-          style={{ "padding-left": `${16 + depth * 12}px` }}
+          style={{ "padding-left": "16px" }}
         >
-          <Tooltip placement="right" value={props.session.title} gutter={10}>
+          <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
             <A
               href={`${props.slug}/session/${props.session.id}`}
               class="flex flex-col min-w-0 text-left w-full focus:outline-none"
             >
               <div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
-                <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
+                <span
+                  classList={{
+                    "text-14-regular text-text-strong overflow-hidden text-ellipsis truncate": true,
+                    "animate-pulse": isWorking(),
+                  }}
+                >
                   {props.session.title}
                 </span>
                 <div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
@@ -453,6 +645,9 @@ export default function Layout(props: ParentProps) {
                     <Match when={isWorking()}>
                       <Spinner class="size-2.5 mr-0.5" />
                     </Match>
+                    <Match when={hasPermissions()}>
+                      <div class="size-1.5 mr-1.5 rounded-full bg-surface-warning-strong" />
+                    </Match>
                     <Match when={hasError()}>
                       <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
                     </Match>
@@ -486,66 +681,54 @@ export default function Layout(props: ParentProps) {
             </A>
           </Tooltip>
           <div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
-            <Tooltip placement="right" value="Archive session">
+            <Tooltip
+              placement={props.mobile ? "bottom" : "right"}
+              value={
+                <div class="flex items-center gap-2">
+                  <span>Archive session</span>
+                  <span class="text-icon-base text-12-medium">{command.keybind("session.archive")}</span>
+                </div>
+              }
+            >
               <IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
             </Tooltip>
           </div>
         </div>
-        <For each={children()}>
-          {(child) => (
-            <SessionItem
-              session={child}
-              slug={props.slug}
-              project={props.project}
-              depth={depth + 1}
-              childrenMap={props.childrenMap}
-            />
-          )}
-        </For>
       </>
     )
   }
 
-  const SortableProject = (props: { project: LocalProject }): JSX.Element => {
+  const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
     const sortable = createSortable(props.project.worktree)
+    const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
     const slug = createMemo(() => base64Encode(props.project.worktree))
-    const name = createMemo(() => getFilename(props.project.worktree))
+    const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
     const [store, setProjectStore] = globalSync.child(props.project.worktree)
-    const sessions = createMemo(() =>
-      store.session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)),
-    )
+    const sessions = createMemo(() => store.session.toSorted(sortSessions))
     const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
-    const childSessionsByParent = createMemo(() => {
-      const map = new Map<string, Session[]>()
-      for (const session of sessions()) {
-        if (session.parentID) {
-          const children = map.get(session.parentID) ?? []
-          children.push(session)
-          map.set(session.parentID, children)
-        }
-      }
-      return map
-    })
     const hasMoreSessions = createMemo(() => store.session.length >= store.limit)
     const loadMoreSessions = async () => {
       setProjectStore("limit", (limit) => limit + 5)
       await globalSync.project.loadSessions(props.project.worktree)
     }
+    const isExpanded = createMemo(() =>
+      props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded,
+    )
     const handleOpenChange = (open: boolean) => {
-      if (open) layout.projects.expand(props.project.worktree)
-      else layout.projects.collapse(props.project.worktree)
+      if (props.mobile) {
+        if (open) mobileProjects.expand(props.project.worktree)
+        else mobileProjects.collapse(props.project.worktree)
+      } else {
+        if (open) layout.projects.expand(props.project.worktree)
+        else layout.projects.collapse(props.project.worktree)
+      }
     }
     return (
       // @ts-ignore
       <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
         <Switch>
-          <Match when={layout.sidebar.opened()}>
-            <Collapsible
-              variant="ghost"
-              open={props.project.expanded}
-              class="gap-2 shrink-0"
-              onOpenChange={handleOpenChange}
-            >
+          <Match when={showExpanded()}>
+            <Collapsible variant="ghost" open={isExpanded()} class="gap-2 shrink-0" onOpenChange={handleOpenChange}>
               <Button
                 as={"div"}
                 variant="ghost"
@@ -556,7 +739,7 @@ export default function Layout(props: ParentProps) {
                     project={props.project}
                     class="group-hover/session:hidden"
                     expandable
-                    notify={!props.project.expanded}
+                    notify={!isExpanded()}
                   />
                   <span class="truncate text-14-medium text-text-strong">{name()}</span>
                 </Collapsible.Trigger>
@@ -565,13 +748,26 @@ export default function Layout(props: ParentProps) {
                     <DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" />
                     <DropdownMenu.Portal>
                       <DropdownMenu.Content>
+                        <DropdownMenu.Item
+                          onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)}
+                        >
+                          <DropdownMenu.ItemLabel>Edit project</DropdownMenu.ItemLabel>
+                        </DropdownMenu.Item>
                         <DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}>
-                          <DropdownMenu.ItemLabel>Close Project</DropdownMenu.ItemLabel>
+                          <DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
                         </DropdownMenu.Item>
                       </DropdownMenu.Content>
                     </DropdownMenu.Portal>
                   </DropdownMenu>
-                  <Tooltip placement="top" value="New session">
+                  <Tooltip
+                    placement="top"
+                    value={
+                      <div class="flex items-center gap-2">
+                        <span>New session</span>
+                        <span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
+                      </div>
+                    }
+                  >
                     <IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
                   </Tooltip>
                 </div>
@@ -580,12 +776,7 @@ export default function Layout(props: ParentProps) {
                 <nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
                   <For each={rootSessions()}>
                     {(session) => (
-                      <SessionItem
-                        session={session}
-                        slug={slug()}
-                        project={props.project}
-                        childrenMap={childSessionsByParent()}
-                      />
+                      <SessionItem session={session} slug={slug()} project={props.project} mobile={props.mobile} />
                     )}
                   </For>
                   <Show when={rootSessions().length === 0}>
@@ -595,7 +786,7 @@ export default function Layout(props: ParentProps) {
                     >
                       <div class="flex items-center self-stretch w-full">
                         <div class="flex-1 min-w-0">
-                          <Tooltip placement="right" value="New session">
+                          <Tooltip placement={props.mobile ? "bottom" : "right"} value="New session">
                             <A
                               href={`${slug()}/session`}
                               class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none"
@@ -650,30 +841,12 @@ export default function Layout(props: ParentProps) {
     )
   }
 
-  return (
-    <div class="relative flex-1 min-h-0 flex flex-col">
-      <Header navigateToProject={navigateToProject} navigateToSession={navigateToSession} />
-      <div class="flex-1 min-h-0 flex">
-        <div
-          classList={{
-            "relative @container w-12 pb-5 shrink-0 bg-background-base": true,
-            "flex flex-col gap-5.5 items-start self-stretch justify-between": true,
-            "border-r border-border-weak-base contain-strict": true,
-          }}
-          style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
-        >
-          <Show when={layout.sidebar.opened()}>
-            <ResizeHandle
-              direction="horizontal"
-              size={layout.sidebar.width()}
-              min={150}
-              max={window.innerWidth * 0.3}
-              collapseThreshold={80}
-              onResize={layout.sidebar.resize}
-              onCollapse={layout.sidebar.close}
-            />
-          </Show>
-          <div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
+  const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
+    const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
+    return (
+      <>
+        <div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
+          <Show when={!sidebarProps.mobile}>
             <Tooltip
               class="shrink-0"
               placement="right"
@@ -683,7 +856,7 @@ export default function Layout(props: ParentProps) {
                   <span class="text-icon-base text-12-medium">{command.keybind("sidebar.toggle")}</span>
                 </div>
               }
-              inactive={layout.sidebar.opened()}
+              inactive={expanded()}
             >
               <Button
                 variant="ghost"
@@ -715,110 +888,162 @@ export default function Layout(props: ParentProps) {
                 </Show>
               </Button>
             </Tooltip>
-            <DragDropProvider
-              onDragStart={handleDragStart}
-              onDragEnd={handleDragEnd}
-              onDragOver={handleDragOver}
-              collisionDetector={closestCenter}
+          </Show>
+          <DragDropProvider
+            onDragStart={handleDragStart}
+            onDragEnd={handleDragEnd}
+            onDragOver={handleDragOver}
+            collisionDetector={closestCenter}
+          >
+            <DragDropSensors />
+            <ConstrainDragXAxis />
+            <div
+              ref={(el) => {
+                if (!sidebarProps.mobile) scrollContainerRef = el
+              }}
+              class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
             >
-              <DragDropSensors />
-              <ConstrainDragXAxis />
-              <div
-                ref={scrollContainerRef}
-                class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
-              >
-                <SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
-                  <For each={layout.projects.list()}>{(project) => <SortableProject project={project} />}</For>
-                </SortableProvider>
-              </div>
-              <DragOverlay>
-                <ProjectDragOverlay />
-              </DragOverlay>
-            </DragDropProvider>
-          </div>
-          <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
-            <Switch>
-              <Match when={!providers.paid().length && layout.sidebar.opened()}>
-                <div class="rounded-md bg-background-stronger shadow-xs-border-base">
-                  <div class="p-3 flex flex-col gap-2">
-                    <div class="text-12-medium text-text-strong">Getting started</div>
-                    <div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
-                    <div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
-                  </div>
-                  <Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
-                    <Button
-                      class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
-                      size="large"
-                      icon="plus"
-                      onClick={connectProvider}
-                    >
-                      <Show when={layout.sidebar.opened()}>Connect provider</Show>
-                    </Button>
-                  </Tooltip>
+              <SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
+                <For each={layout.projects.list()}>
+                  {(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
+                </For>
+              </SortableProvider>
+            </div>
+            <DragOverlay>
+              <ProjectDragOverlay />
+            </DragOverlay>
+          </DragDropProvider>
+        </div>
+        <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
+          <Switch>
+            <Match when={!providers.paid().length && expanded()}>
+              <div class="rounded-md bg-background-stronger shadow-xs-border-base">
+                <div class="p-3 flex flex-col gap-2">
+                  <div class="text-12-medium text-text-strong">Getting started</div>
+                  <div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
+                  <div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
                 </div>
-              </Match>
-              <Match when={true}>
-                <Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
+                <Tooltip placement="right" value="Connect provider" inactive={expanded()}>
                   <Button
-                    class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
-                    variant="ghost"
+                    class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
                     size="large"
                     icon="plus"
                     onClick={connectProvider}
                   >
-                    <Show when={layout.sidebar.opened()}>Connect provider</Show>
+                    Connect provider
                   </Button>
                 </Tooltip>
-              </Match>
-            </Switch>
-            <Show when={platform.openDirectoryPickerDialog}>
-              <Tooltip
-                placement="right"
-                value={
-                  <div class="flex items-center gap-2">
-                    <span>Open project</span>
-                    <span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
-                  </div>
-                }
-                inactive={layout.sidebar.opened()}
-              >
+              </div>
+            </Match>
+            <Match when={true}>
+              <Tooltip placement="right" value="Connect provider" inactive={expanded()}>
                 <Button
                   class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
                   variant="ghost"
                   size="large"
-                  icon="folder-add-left"
-                  onClick={chooseProject}
+                  icon="plus"
+                  onClick={connectProvider}
                 >
-                  <Show when={layout.sidebar.opened()}>Open project</Show>
+                  <Show when={expanded()}>Connect provider</Show>
                 </Button>
               </Tooltip>
-            </Show>
-            {/* <Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}> */}
-            {/*   <Button */}
-            {/*     disabled */}
-            {/*     class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2" */}
-            {/*     variant="ghost" */}
-            {/*     size="large" */}
-            {/*     icon="settings-gear" */}
-            {/*   > */}
-            {/*     <Show when={layout.sidebar.opened()}>Settings</Show> */}
-            {/*   </Button> */}
-            {/* </Tooltip> */}
-            <Tooltip placement="right" value="Share feedback" inactive={layout.sidebar.opened()}>
+            </Match>
+          </Switch>
+          <Show when={platform.openDirectoryPickerDialog}>
+            <Tooltip
+              placement="right"
+              value={
+                <div class="flex items-center gap-2">
+                  <span>Open project</span>
+                  <Show when={!sidebarProps.mobile}>
+                    <span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
+                  </Show>
+                </div>
+              }
+              inactive={expanded()}
+            >
               <Button
-                as={"a"}
-                href="https://opencode.ai/desktop-feedback"
-                target="_blank"
                 class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
                 variant="ghost"
                 size="large"
-                icon="bubble-5"
+                icon="folder-add-left"
+                onClick={chooseProject}
               >
-                <Show when={layout.sidebar.opened()}>Share feedback</Show>
+                <Show when={expanded()}>Open project</Show>
               </Button>
             </Tooltip>
+          </Show>
+          <Tooltip placement="right" value="Share feedback" inactive={expanded()}>
+            <Button
+              as={"a"}
+              href="https://opencode.ai/desktop-feedback"
+              target="_blank"
+              class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
+              variant="ghost"
+              size="large"
+              icon="bubble-5"
+            >
+              <Show when={expanded()}>Share feedback</Show>
+            </Button>
+          </Tooltip>
+        </div>
+      </>
+    )
+  }
+
+  return (
+    <div class="relative flex-1 min-h-0 flex flex-col">
+      <Header
+        navigateToProject={navigateToProject}
+        navigateToSession={navigateToSession}
+        onMobileMenuToggle={mobileSidebar.toggle}
+      />
+      <div class="flex-1 min-h-0 flex">
+        <div
+          classList={{
+            "hidden xl:flex": true,
+            "relative @container w-12 pb-5 shrink-0 bg-background-base": true,
+            "flex-col gap-5.5 items-start self-stretch justify-between": true,
+            "border-r border-border-weak-base contain-strict": true,
+          }}
+          style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
+        >
+          <Show when={layout.sidebar.opened()}>
+            <ResizeHandle
+              direction="horizontal"
+              size={layout.sidebar.width()}
+              min={150}
+              max={window.innerWidth * 0.3}
+              collapseThreshold={80}
+              onResize={layout.sidebar.resize}
+              onCollapse={layout.sidebar.close}
+            />
+          </Show>
+          <SidebarContent />
+        </div>
+        <div class="xl:hidden">
+          <div
+            classList={{
+              "fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
+              "opacity-100 pointer-events-auto": mobileSidebar.open(),
+              "opacity-0 pointer-events-none": !mobileSidebar.open(),
+            }}
+            onClick={(e) => {
+              if (e.target === e.currentTarget) mobileSidebar.hide()
+            }}
+          />
+          <div
+            classList={{
+              "@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pt-12 pb-5 transition-transform duration-200 ease-out": true,
+              "translate-x-0": mobileSidebar.open(),
+              "-translate-x-full": !mobileSidebar.open(),
+            }}
+            onClick={(e) => e.stopPropagation()}
+          >
+            <SidebarContent mobile />
           </div>
         </div>
+
         <main class="size-full overflow-x-hidden flex flex-col items-start contain-strict">{props.children}</main>
       </div>
       <Toast.Region />

+ 208 - 82
packages/desktop/src/pages/session.tsx → packages/app/src/pages/session.tsx

@@ -12,6 +12,7 @@ import {
   createRenderEffect,
   batch,
 } from "solid-js"
+
 import { Dynamic } from "solid-js/web"
 import { useLocal, type LocalFile } from "@/context/local"
 import { createStore } from "solid-js/store"
@@ -26,6 +27,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { useCodeComponent } from "@opencode-ai/ui/context/code"
 import { SessionTurn } from "@opencode-ai/ui/session-turn"
+import { createAutoScroll } from "@opencode-ai/ui/hooks"
 import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
 import { SessionReview } from "@opencode-ai/ui/session-review"
 import {
@@ -47,6 +49,7 @@ import { checksum } from "@opencode-ai/util/encode"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { DialogSelectFile } from "@/components/dialog-select-file"
 import { DialogSelectModel } from "@/components/dialog-select-model"
+import { DialogSelectMcp } from "@/components/dialog-select-mcp"
 import { useCommand } from "@/context/command"
 import { useNavigate, useParams } from "@solidjs/router"
 import { UserMessage } from "@opencode-ai/sdk/v2"
@@ -54,6 +57,9 @@ import { useSDK } from "@/context/sdk"
 import { usePrompt } from "@/context/prompt"
 import { extractPromptFromParts } from "@/utils/prompt"
 import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
+import { StatusBar } from "@/components/status-bar"
+import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
+import { SessionLspIndicator } from "@/components/session-lsp-indicator"
 
 export default function Page() {
   const layout = useLayout()
@@ -70,16 +76,10 @@ export default function Page() {
 
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const tabs = createMemo(() => layout.tabs(sessionKey()))
-
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
   const revertMessageID = createMemo(() => info()?.revert?.messageID)
   const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
-  const userMessages = createMemo(() =>
-    messages()
-      .filter((m) => m.role === "user")
-      .sort((a, b) => a.id.localeCompare(b.id)),
-  )
-  // Visible user messages excludes reverted messages (those >= revertMessageID)
+  const userMessages = createMemo(() => messages().filter((m) => m.role === "user"))
   const visibleUserMessages = createMemo(() => {
     const revert = revertMessageID()
     if (!revert) return userMessages()
@@ -87,15 +87,36 @@ export default function Page() {
   })
   const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1))
 
-  const [messageStore, setMessageStore] = createStore<{ messageId?: string }>({})
+  createEffect(
+    on(
+      () => lastUserMessage()?.id,
+      () => {
+        const msg = lastUserMessage()
+        if (!msg) return
+        if (msg.agent) local.agent.set(msg.agent)
+        if (msg.model) local.model.set(msg.model)
+      },
+    ),
+  )
+
+  const [store, setStore] = createStore({
+    clickTimer: undefined as number | undefined,
+    activeDraggable: undefined as string | undefined,
+    activeTerminalDraggable: undefined as string | undefined,
+    userInteracted: false,
+    stepsExpanded: true,
+    mobileStepsExpanded: {} as Record<string, boolean>,
+    messageId: undefined as string | undefined,
+  })
+
   const activeMessage = createMemo(() => {
-    if (!messageStore.messageId) return lastUserMessage()
+    if (!store.messageId) return lastUserMessage()
     // If the stored message is no longer visible (e.g., was reverted), fall back to last visible
-    const found = visibleUserMessages()?.find((m) => m.id === messageStore.messageId)
+    const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
     return found ?? lastUserMessage()
   })
   const setActiveMessage = (message: UserMessage | undefined) => {
-    setMessageStore("messageId", message?.id)
+    setStore("messageId", message?.id)
   }
 
   function navigateMessageByOffset(offset: number) {
@@ -119,13 +140,6 @@ export default function Page() {
 
   const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
 
-  const [store, setStore] = createStore({
-    clickTimer: undefined as number | undefined,
-    activeDraggable: undefined as string | undefined,
-    activeTerminalDraggable: undefined as string | undefined,
-    userInteracted: false,
-    stepsExpanded: true,
-  })
   let inputRef!: HTMLDivElement
 
   createEffect(() => {
@@ -146,7 +160,7 @@ export default function Page() {
       () => visibleUserMessages().at(-1)?.id,
       (lastId, prevLastId) => {
         if (lastId && prevLastId && lastId > prevLastId) {
-          setMessageStore("messageId", undefined)
+          setStore("messageId", undefined)
         }
       },
       { defer: true },
@@ -219,6 +233,14 @@ export default function Page() {
       slash: "terminal",
       onSelect: () => layout.terminal.toggle(),
     },
+    {
+      id: "review.toggle",
+      title: "Toggle review",
+      description: "Show or hide the review panel",
+      category: "View",
+      keybind: "mod+shift+r",
+      onSelect: () => layout.review.toggle(),
+    },
     {
       id: "terminal.new",
       title: "New terminal",
@@ -264,6 +286,15 @@ export default function Page() {
       slash: "model",
       onSelect: () => dialog.show(() => <DialogSelectModel />),
     },
+    {
+      id: "mcp.toggle",
+      title: "Toggle MCPs",
+      description: "Toggle MCPs",
+      category: "MCP",
+      keybind: "mod+;",
+      slash: "mcp",
+      onSelect: () => dialog.show(() => <DialogSelectMcp />),
+    },
     {
       id: "agent.cycle",
       title: "Cycle agent",
@@ -531,74 +562,165 @@ export default function Page() {
     )
   }
 
-  const showTabs = createMemo(() => diffs().length > 0 || tabs().all().length > 0)
+  const showTabs = createMemo(() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0))
+
+  const mobileWorking = createMemo(() => status().type !== "idle")
+  const mobileAutoScroll = createAutoScroll({
+    working: mobileWorking,
+    onUserInteracted: () => setStore("userInteracted", true),
+  })
+
+  const MobileTurns = () => (
+    <div
+      ref={mobileAutoScroll.scrollRef}
+      onScroll={mobileAutoScroll.handleScroll}
+      onClick={mobileAutoScroll.handleInteraction}
+      class="relative mt-2 min-w-0 w-full h-full overflow-y-auto no-scrollbar pb-12"
+    >
+      <div ref={mobileAutoScroll.contentRef} class="flex flex-col gap-45 items-start justify-start mt-4">
+        <For each={visibleUserMessages()}>
+          {(message) => (
+            <SessionTurn
+              sessionID={params.id!}
+              messageID={message.id}
+              lastUserMessageID={lastUserMessage()?.id}
+              stepsExpanded={store.mobileStepsExpanded[message.id] ?? false}
+              onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)}
+              onUserInteracted={() => setStore("userInteracted", true)}
+              classes={{
+                root: "min-w-0 w-full relative",
+                content:
+                  "flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
+                container: "px-4",
+              }}
+            />
+          )}
+        </For>
+      </div>
+    </div>
+  )
+
+  const NewSessionView = () => (
+    <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
+      <div class="text-20-medium text-text-weaker">New session</div>
+      <div class="flex justify-center items-center gap-3">
+        <Icon name="folder" size="small" />
+        <div class="text-12-medium text-text-weak">
+          {getDirectory(sync.data.path.directory)}
+          <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
+        </div>
+      </div>
+      <Show when={sync.project}>
+        {(project) => (
+          <div class="flex justify-center items-center gap-3">
+            <Icon name="pencil-line" size="small" />
+            <div class="text-12-medium text-text-weak">
+              Last modified&nbsp;
+              <span class="text-text-strong">
+                {DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
+              </span>
+            </div>
+          </div>
+        )}
+      </Show>
+    </div>
+  )
+
+  const DesktopSessionContent = () => (
+    <Switch>
+      <Match when={params.id}>
+        <div class="flex items-start justify-start h-full min-h-0">
+          <SessionMessageRail
+            messages={visibleUserMessages()}
+            current={activeMessage()}
+            onMessageSelect={setActiveMessage}
+            wide={!showTabs()}
+          />
+          <Show when={activeMessage()}>
+            <SessionTurn
+              sessionID={params.id!}
+              messageID={activeMessage()!.id}
+              lastUserMessageID={lastUserMessage()?.id}
+              stepsExpanded={store.stepsExpanded}
+              onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)}
+              onUserInteracted={() => setStore("userInteracted", true)}
+              classes={{
+                root: "pb-20 flex-1 min-w-0",
+                content: "pb-20",
+                container:
+                  "w-full " +
+                  (!showTabs() ? "max-w-200 mx-auto px-6" : visibleUserMessages().length > 1 ? "pr-6 pl-18" : "px-6"),
+              }}
+            />
+          </Show>
+        </div>
+      </Match>
+      <Match when={true}>
+        <NewSessionView />
+      </Match>
+    </Switch>
+  )
 
   return (
     <div class="relative bg-background-base size-full overflow-hidden flex flex-col">
-      <div class="min-h-0 grow w-full flex">
-        {/* Session pane - always visible */}
+      <div class="md:hidden flex-1 min-h-0 flex flex-col bg-background-stronger">
+        <Switch>
+          <Match when={!params.id}>
+            <div class="flex-1 min-h-0 overflow-hidden">
+              <NewSessionView />
+            </div>
+          </Match>
+          <Match when={diffs().length > 0}>
+            <Tabs class="flex-1 min-h-0 flex flex-col pb-28">
+              <Tabs.List>
+                <Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
+                  Session
+                </Tabs.Trigger>
+                <Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
+                  {diffs().length} Files Changed
+                </Tabs.Trigger>
+              </Tabs.List>
+              <Tabs.Content value="session" class="flex-1 !overflow-hidden">
+                <MobileTurns />
+              </Tabs.Content>
+              <Tabs.Content forceMount value="review" class="flex-1 !overflow-hidden hidden data-[selected]:block">
+                <div class="relative h-full mt-6 overflow-y-auto no-scrollbar">
+                  <SessionReview
+                    diffs={diffs()}
+                    classes={{
+                      root: "pb-32",
+                      header: "px-4",
+                      container: "px-4",
+                    }}
+                  />
+                </div>
+              </Tabs.Content>
+            </Tabs>
+          </Match>
+          <Match when={true}>
+            <div class="flex-1 min-h-0 overflow-hidden">
+              <MobileTurns />
+            </div>
+          </Match>
+        </Switch>
+        <div class="absolute inset-x-0 bottom-4 flex flex-col justify-center items-center z-50 px-4">
+          <div class="w-full">
+            <PromptInput
+              ref={(el) => {
+                inputRef = el
+              }}
+            />
+          </div>
+        </div>
+      </div>
+
+      <div class="hidden md:flex min-h-0 grow w-full">
         <div
           class="@container relative shrink-0 py-3 flex flex-col gap-6 min-h-0 h-full bg-background-stronger"
           style={{ width: showTabs() ? `${layout.session.width()}px` : "100%" }}
         >
           <div class="flex-1 min-h-0 overflow-hidden">
-            <Switch>
-              <Match when={params.id}>
-                <div class="flex items-start justify-start h-full min-h-0">
-                  <SessionMessageRail
-                    messages={visibleUserMessages()}
-                    current={activeMessage()}
-                    onMessageSelect={setActiveMessage}
-                    wide={!showTabs()}
-                  />
-                  <Show when={activeMessage()}>
-                    <SessionTurn
-                      sessionID={params.id!}
-                      messageID={activeMessage()!.id}
-                      stepsExpanded={store.stepsExpanded}
-                      onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)}
-                      onUserInteracted={() => setStore("userInteracted", true)}
-                      classes={{
-                        root: "pb-20 flex-1 min-w-0",
-                        content: "pb-20",
-                        container:
-                          "w-full " +
-                          (!showTabs()
-                            ? "max-w-200 mx-auto px-6"
-                            : visibleUserMessages().length > 1
-                              ? "pr-6 pl-18"
-                              : "px-6"),
-                      }}
-                    />
-                  </Show>
-                </div>
-              </Match>
-              <Match when={true}>
-                <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
-                  <div class="text-20-medium text-text-weaker">New session</div>
-                  <div class="flex justify-center items-center gap-3">
-                    <Icon name="folder" size="small" />
-                    <div class="text-12-medium text-text-weak">
-                      {getDirectory(sync.data.path.directory)}
-                      <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
-                    </div>
-                  </div>
-                  <Show when={sync.project}>
-                    {(project) => (
-                      <div class="flex justify-center items-center gap-3">
-                        <Icon name="pencil-line" size="small" />
-                        <div class="text-12-medium text-text-weak">
-                          Last modified&nbsp;
-                          <span class="text-text-strong">
-                            {DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
-                          </span>
-                        </div>
-                      </div>
-                    )}
-                  </Show>
-                </div>
-              </Match>
-            </Switch>
+            <DesktopSessionContent />
           </div>
           <div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
             <div
@@ -625,7 +747,6 @@ export default function Page() {
           </Show>
         </div>
 
-        {/* Tabs pane - visible when there are diffs or file tabs */}
         <Show when={showTabs()}>
           <div class="relative flex-1 min-w-0 h-full border-l border-border-weak-base">
             <DragDropProvider
@@ -683,7 +804,7 @@ export default function Page() {
                 </div>
                 <Show when={diffs().length}>
                   <Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden contain-strict">
-                    <div class="relative pt-3 flex-1 min-h-0 overflow-hidden">
+                    <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
                       <SessionReview
                         classes={{
                           root: "pb-40",
@@ -754,9 +875,10 @@ export default function Page() {
           </div>
         </Show>
       </div>
+
       <Show when={layout.terminal.opened()}>
         <div
-          class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
+          class="hidden md:flex relative w-full flex-col shrink-0 border-t border-border-weak-base"
           style={{ height: `${layout.terminal.height()}px` }}
         >
           <ResizeHandle
@@ -822,6 +944,10 @@ export default function Page() {
           </DragDropProvider>
         </div>
       </Show>
+      <StatusBar>
+        <SessionLspIndicator />
+        <SessionMcpIndicator />
+      </StatusBar>
     </div>
   )
 }

+ 0 - 0
packages/desktop/src/sst-env.d.ts → packages/app/src/sst-env.d.ts


+ 0 - 0
packages/desktop/src/utils/dom.ts → packages/app/src/utils/dom.ts


+ 0 - 0
packages/desktop/src/utils/id.ts → packages/app/src/utils/id.ts


+ 0 - 0
packages/desktop/src/utils/index.ts → packages/app/src/utils/index.ts


+ 0 - 0
packages/desktop/src/utils/persist.ts → packages/app/src/utils/persist.ts


+ 0 - 0
packages/desktop/src/utils/prompt.ts → packages/app/src/utils/prompt.ts


+ 0 - 0
packages/desktop/src/utils/solid-dnd.tsx → packages/app/src/utils/solid-dnd.tsx


+ 0 - 0
packages/desktop/src/utils/speech.ts → packages/app/src/utils/speech.ts


+ 0 - 0
packages/tauri/sst-env.d.ts → packages/app/sst-env.d.ts


+ 11 - 5
packages/tauri/tsconfig.json → packages/app/tsconfig.json

@@ -1,5 +1,7 @@
 {
+  "$schema": "https://json.schemastore.org/tsconfig",
   "compilerOptions": {
+    "composite": true,
     "target": "ESNext",
     "module": "ESNext",
     "skipLibCheck": true,
@@ -9,12 +11,16 @@
     "jsx": "preserve",
     "jsxImportSource": "solid-js",
     "allowJs": true,
+    "resolveJsonModule": true,
     "strict": true,
+    "noEmit": false,
+    "emitDeclarationOnly": true,
+    "outDir": "node_modules/.ts-dist",
     "isolatedModules": true,
-    "noEmit": true,
-    "emitDeclarationOnly": false,
-    "outDir": "node_modules/.ts-dist"
+    "paths": {
+      "@/*": ["./src/*"]
+    }
   },
-  "references": [{ "path": "../desktop" }],
-  "include": ["src"]
+  "include": ["src", "package.json"],
+  "exclude": ["dist", "ts-dist"]
 }

+ 15 - 0
packages/app/vite.config.ts

@@ -0,0 +1,15 @@
+import { defineConfig } from "vite"
+import desktopPlugin from "./vite"
+
+export default defineConfig({
+  plugins: [desktopPlugin] as any,
+  server: {
+    host: "0.0.0.0",
+    allowedHosts: true,
+    port: 3000,
+  },
+  build: {
+    target: "esnext",
+    sourcemap: true,
+  },
+})

+ 0 - 0
packages/desktop/vite.js → packages/app/vite.js


+ 1 - 1
packages/console/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/console-app",
-  "version": "1.0.184",
+  "version": "1.0.207",
   "type": "module",
   "scripts": {
     "typecheck": "tsgo --noEmit",

+ 4 - 4
packages/console/app/src/config.ts

@@ -9,8 +9,8 @@ export const config = {
   github: {
     repoUrl: "https://github.com/sst/opencode",
     starsFormatted: {
-      compact: "38K",
-      full: "38,000",
+      compact: "41K",
+      full: "41,000",
     },
   },
 
@@ -22,8 +22,8 @@ export const config = {
 
   // Static stats (used on landing page)
   stats: {
-    contributors: "400",
-    commits: "5,000",
+    contributors: "450",
+    commits: "6,000",
     monthlyUsers: "400,000",
   },
 } as const

+ 31 - 23
packages/console/app/src/routes/auth/callback.ts

@@ -5,28 +5,36 @@ import { useAuthSession } from "~/context/auth.session"
 
 export async function GET(input: APIEvent) {
   const url = new URL(input.request.url)
-  const code = url.searchParams.get("code")
-  if (!code) throw new Error("No code found")
-  const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`)
-  if (result.err) {
-    throw new Error(result.err.message)
-  }
-  const decoded = AuthClient.decode(result.tokens.access, {} as any)
-  if (decoded.err) throw new Error(decoded.err.message)
-  const session = await useAuthSession()
-  const id = decoded.subject.properties.accountID
-  await session.update((value) => {
-    return {
-      ...value,
-      account: {
-        ...value.account,
-        [id]: {
-          id,
-          email: decoded.subject.properties.email,
+  try {
+    const code = url.searchParams.get("code")
+    if (!code) throw new Error("No code found")
+    const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`)
+    if (result.err) throw new Error(result.err.message)
+    const decoded = AuthClient.decode(result.tokens.access, {} as any)
+    if (decoded.err) throw new Error(decoded.err.message)
+    const session = await useAuthSession()
+    const id = decoded.subject.properties.accountID
+    await session.update((value) => {
+      return {
+        ...value,
+        account: {
+          ...value.account,
+          [id]: {
+            id,
+            email: decoded.subject.properties.email,
+          },
         },
-      },
-      current: id,
-    }
-  })
-  return redirect("/auth")
+        current: id,
+      }
+    })
+    return redirect("/auth")
+  } catch (e: any) {
+    return new Response(
+      JSON.stringify({
+        error: e.message,
+        cause: Object.fromEntries(url.searchParams.entries()),
+      }),
+      { status: 500 },
+    )
+  }
 }

+ 365 - 0
packages/console/app/src/routes/bench/[id].tsx

@@ -0,0 +1,365 @@
+import { Title } from "@solidjs/meta"
+import { createAsync, query, useParams } from "@solidjs/router"
+import { createSignal, For, Show } from "solid-js"
+import { Database, desc, eq } from "@opencode-ai/console-core/drizzle/index.js"
+import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
+
+interface TaskSource {
+  repo: string
+  from: string
+  to: string
+}
+
+interface Judge {
+  score: number
+  rationale: string
+  judge: string
+}
+
+interface ScoreDetail {
+  criterion: string
+  weight: number
+  average: number
+  variance?: number
+  judges?: Judge[]
+}
+
+interface RunUsage {
+  input: number
+  output: number
+  cost: number
+}
+
+interface Run {
+  task: string
+  model: string
+  agent: string
+  score: {
+    final: number
+    base: number
+    penalty: number
+  }
+  scoreDetails: ScoreDetail[]
+  usage?: RunUsage
+  duration?: number
+}
+
+interface Prompt {
+  commit: string
+  prompt: string
+}
+
+interface AverageUsage {
+  input: number
+  output: number
+  cost: number
+}
+
+interface Task {
+  averageScore: number
+  averageDuration?: number
+  averageUsage?: AverageUsage
+  model?: string
+  agent?: string
+  summary?: string
+  runs?: Run[]
+  task: {
+    id: string
+    source: TaskSource
+    prompts?: Prompt[]
+  }
+}
+
+interface BenchmarkResult {
+  averageScore: number
+  tasks: Task[]
+}
+
+async function getTaskDetail(benchmarkId: string, taskId: string) {
+  "use server"
+  const rows = await Database.use((tx) =>
+    tx.select().from(BenchmarkTable).where(eq(BenchmarkTable.id, benchmarkId)).limit(1),
+  )
+  if (!rows[0]) return null
+  const parsed = JSON.parse(rows[0].result) as BenchmarkResult
+  const task = parsed.tasks.find((t) => t.task.id === taskId)
+  return task ?? null
+}
+
+const queryTaskDetail = query(getTaskDetail, "benchmark.task.detail")
+
+function formatDuration(ms: number): string {
+  const seconds = Math.floor(ms / 1000)
+  const minutes = Math.floor(seconds / 60)
+  const remainingSeconds = seconds % 60
+  if (minutes > 0) {
+    return `${minutes}m ${remainingSeconds}s`
+  }
+  return `${remainingSeconds}s`
+}
+
+export default function BenchDetail() {
+  const params = useParams()
+  const [benchmarkId, taskId] = (params.id ?? "").split(":")
+  const task = createAsync(() => queryTaskDetail(benchmarkId, taskId))
+
+  return (
+    <main data-page="bench-detail">
+      <Title>Benchmark - {taskId}</Title>
+      <div style={{ padding: "1rem" }}>
+        <Show when={task()} fallback={<p>Task not found</p>}>
+          <div style={{ "margin-bottom": "1rem" }}>
+            <div>
+              <strong>Agent: </strong>
+              {task()?.agent ?? "N/A"}
+            </div>
+            <div>
+              <strong>Model: </strong>
+              {task()?.model ?? "N/A"}
+            </div>
+            <div>
+              <strong>Task: </strong>
+              {task()!.task.id}
+            </div>
+          </div>
+
+          <div style={{ "margin-bottom": "1rem" }}>
+            <div>
+              <strong>Repo: </strong>
+              <a
+                href={`https://github.com/${task()!.task.source.repo}`}
+                target="_blank"
+                rel="noopener noreferrer"
+                style={{ color: "#0066cc" }}
+              >
+                {task()!.task.source.repo}
+              </a>
+            </div>
+            <div>
+              <strong>From: </strong>
+              <a
+                href={`https://github.com/${task()!.task.source.repo}/commit/${task()!.task.source.from}`}
+                target="_blank"
+                rel="noopener noreferrer"
+                style={{ color: "#0066cc" }}
+              >
+                {task()!.task.source.from.slice(0, 7)}
+              </a>
+            </div>
+            <div>
+              <strong>To: </strong>
+              <a
+                href={`https://github.com/${task()!.task.source.repo}/commit/${task()!.task.source.to}`}
+                target="_blank"
+                rel="noopener noreferrer"
+                style={{ color: "#0066cc" }}
+              >
+                {task()!.task.source.to.slice(0, 7)}
+              </a>
+            </div>
+          </div>
+
+          <Show when={task()?.task.prompts && task()!.task.prompts!.length > 0}>
+            <div style={{ "margin-bottom": "1rem" }}>
+              <strong>Prompt:</strong>
+              <For each={task()!.task.prompts}>
+                {(p) => (
+                  <div style={{ "margin-top": "0.5rem" }}>
+                    <div style={{ "font-size": "0.875rem", color: "#666" }}>Commit: {p.commit.slice(0, 7)}</div>
+                    <p style={{ "margin-top": "0.25rem", "white-space": "pre-wrap" }}>{p.prompt}</p>
+                  </div>
+                )}
+              </For>
+            </div>
+          </Show>
+
+          <hr style={{ margin: "1rem 0", border: "none", "border-top": "1px solid #ccc" }} />
+
+          <div style={{ "margin-bottom": "1rem" }}>
+            <div>
+              <strong>Average Duration: </strong>
+              {task()?.averageDuration ? formatDuration(task()!.averageDuration!) : "N/A"}
+            </div>
+            <div>
+              <strong>Average Score: </strong>
+              {task()?.averageScore?.toFixed(3) ?? "N/A"}
+            </div>
+            <div>
+              <strong>Average Cost: </strong>
+              {task()?.averageUsage?.cost ? `$${task()!.averageUsage!.cost.toFixed(4)}` : "N/A"}
+            </div>
+          </div>
+
+          <Show when={task()?.summary}>
+            <div style={{ "margin-bottom": "1rem" }}>
+              <strong>Summary:</strong>
+              <p style={{ "margin-top": "0.5rem", "white-space": "pre-wrap" }}>{task()!.summary}</p>
+            </div>
+          </Show>
+
+          <Show when={task()?.runs && task()!.runs!.length > 0}>
+            <div style={{ "margin-bottom": "1rem" }}>
+              <strong>Runs:</strong>
+              <table style={{ "margin-top": "0.5rem", "border-collapse": "collapse", width: "100%" }}>
+                <thead>
+                  <tr>
+                    <th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Run</th>
+                    <th
+                      style={{
+                        border: "1px solid #ccc",
+                        padding: "0.5rem",
+                        "text-align": "left",
+                        "white-space": "nowrap",
+                      }}
+                    >
+                      Score (Base - Penalty)
+                    </th>
+                    <th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Cost</th>
+                    <th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Duration</th>
+                    <For each={task()!.runs![0]?.scoreDetails}>
+                      {(detail) => (
+                        <th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>
+                          {detail.criterion} ({detail.weight})
+                        </th>
+                      )}
+                    </For>
+                  </tr>
+                </thead>
+                <tbody>
+                  <For each={task()!.runs}>
+                    {(run, index) => (
+                      <tr>
+                        <td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>{index() + 1}</td>
+                        <td style={{ border: "1px solid #ccc", padding: "0.5rem", "white-space": "nowrap" }}>
+                          {run.score.final.toFixed(3)} ({run.score.base.toFixed(3)} - {run.score.penalty.toFixed(3)})
+                        </td>
+                        <td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>
+                          {run.usage?.cost ? `$${run.usage.cost.toFixed(4)}` : "N/A"}
+                        </td>
+                        <td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>
+                          {run.duration ? formatDuration(run.duration) : "N/A"}
+                        </td>
+                        <For each={run.scoreDetails}>
+                          {(detail) => (
+                            <td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>
+                              <For each={detail.judges}>
+                                {(judge) => (
+                                  <span
+                                    style={{
+                                      color: judge.score === 1 ? "green" : judge.score === 0 ? "red" : "inherit",
+                                      "margin-right": "0.25rem",
+                                    }}
+                                  >
+                                    {judge.score === 1 ? "✓" : judge.score === 0 ? "✗" : judge.score}
+                                  </span>
+                                )}
+                              </For>
+                            </td>
+                          )}
+                        </For>
+                      </tr>
+                    )}
+                  </For>
+                </tbody>
+              </table>
+              <For each={task()!.runs}>
+                {(run, index) => (
+                  <div style={{ "margin-top": "1rem" }}>
+                    <h3 style={{ margin: "0 0 0.5rem 0" }}>Run {index() + 1}</h3>
+                    <div>
+                      <strong>Score: </strong>
+                      {run.score.final.toFixed(3)} (Base: {run.score.base.toFixed(3)} - Penalty:{" "}
+                      {run.score.penalty.toFixed(3)})
+                    </div>
+                    <For each={run.scoreDetails}>
+                      {(detail) => (
+                        <div style={{ "margin-top": "1rem", "padding-left": "1rem", "border-left": "2px solid #ccc" }}>
+                          <div>
+                            {detail.criterion} (weight: {detail.weight}){" "}
+                            <For each={detail.judges}>
+                              {(judge) => (
+                                <span
+                                  style={{
+                                    color: judge.score === 1 ? "green" : judge.score === 0 ? "red" : "inherit",
+                                    "margin-right": "0.25rem",
+                                  }}
+                                >
+                                  {judge.score === 1 ? "✓" : judge.score === 0 ? "✗" : judge.score}
+                                </span>
+                              )}
+                            </For>
+                          </div>
+                          <Show when={detail.judges && detail.judges.length > 0}>
+                            <For each={detail.judges}>
+                              {(judge) => {
+                                const [expanded, setExpanded] = createSignal(false)
+                                return (
+                                  <div style={{ "margin-top": "0.5rem", "padding-left": "1rem" }}>
+                                    <div
+                                      style={{ "font-size": "0.875rem", cursor: "pointer" }}
+                                      onClick={() => setExpanded(!expanded())}
+                                    >
+                                      <span style={{ "margin-right": "0.5rem" }}>{expanded() ? "▼" : "▶"}</span>
+                                      <span
+                                        style={{
+                                          color: judge.score === 1 ? "green" : judge.score === 0 ? "red" : "inherit",
+                                        }}
+                                      >
+                                        {judge.score === 1 ? "✓" : judge.score === 0 ? "✗" : judge.score}
+                                      </span>{" "}
+                                      {judge.judge}
+                                    </div>
+                                    <Show when={expanded()}>
+                                      <p
+                                        style={{
+                                          margin: "0.25rem 0 0 0",
+                                          "white-space": "pre-wrap",
+                                          "font-size": "0.875rem",
+                                        }}
+                                      >
+                                        {judge.rationale}
+                                      </p>
+                                    </Show>
+                                  </div>
+                                )
+                              }}
+                            </For>
+                          </Show>
+                        </div>
+                      )}
+                    </For>
+                  </div>
+                )}
+              </For>
+            </div>
+          </Show>
+
+          {(() => {
+            const [jsonExpanded, setJsonExpanded] = createSignal(false)
+            return (
+              <div style={{ "margin-top": "1rem" }}>
+                <button
+                  style={{
+                    cursor: "pointer",
+                    padding: "0.75rem 1.5rem",
+                    "font-size": "1rem",
+                    background: "#f0f0f0",
+                    border: "1px solid #ccc",
+                    "border-radius": "4px",
+                  }}
+                  onClick={() => setJsonExpanded(!jsonExpanded())}
+                >
+                  <span style={{ "margin-right": "0.5rem" }}>{jsonExpanded() ? "▼" : "▶"}</span>
+                  Raw JSON
+                </button>
+                <Show when={jsonExpanded()}>
+                  <pre>{JSON.stringify(task(), null, 2)}</pre>
+                </Show>
+              </div>
+            )
+          })()}
+        </Show>
+      </div>
+    </main>
+  )
+}

+ 86 - 0
packages/console/app/src/routes/bench/index.tsx

@@ -0,0 +1,86 @@
+import { Title } from "@solidjs/meta"
+import { A, createAsync, query } from "@solidjs/router"
+import { createMemo, For, Show } from "solid-js"
+import { Database, desc } from "@opencode-ai/console-core/drizzle/index.js"
+import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
+
+interface BenchmarkResult {
+  averageScore: number
+  tasks: { averageScore: number; task: { id: string } }[]
+}
+
+async function getBenchmarks() {
+  "use server"
+  const rows = await Database.use((tx) =>
+    tx.select().from(BenchmarkTable).orderBy(desc(BenchmarkTable.timeCreated)).limit(100),
+  )
+  return rows.map((row) => {
+    const parsed = JSON.parse(row.result) as BenchmarkResult
+    const taskScores: Record<string, number> = {}
+    for (const t of parsed.tasks) {
+      taskScores[t.task.id] = t.averageScore
+    }
+    return {
+      id: row.id,
+      agent: row.agent,
+      model: row.model,
+      averageScore: parsed.averageScore,
+      taskScores,
+    }
+  })
+}
+
+const queryBenchmarks = query(getBenchmarks, "benchmarks.list")
+
+export default function Bench() {
+  const benchmarks = createAsync(() => queryBenchmarks())
+
+  const taskIds = createMemo(() => {
+    const ids = new Set<string>()
+    for (const row of benchmarks() ?? []) {
+      for (const id of Object.keys(row.taskScores)) {
+        ids.add(id)
+      }
+    }
+    return [...ids].sort()
+  })
+
+  return (
+    <main data-page="bench" style={{ padding: "2rem" }}>
+      <Title>Benchmark</Title>
+      <h1 style={{ "margin-bottom": "1.5rem" }}>Benchmarks</h1>
+      <table style={{ "border-collapse": "collapse", width: "100%" }}>
+        <thead>
+          <tr>
+            <th style={{ "text-align": "left", padding: "0.75rem" }}>Agent</th>
+            <th style={{ "text-align": "left", padding: "0.75rem" }}>Model</th>
+            <th style={{ "text-align": "left", padding: "0.75rem" }}>Score</th>
+            <For each={taskIds()}>{(id) => <th style={{ "text-align": "left", padding: "0.75rem" }}>{id}</th>}</For>
+          </tr>
+        </thead>
+        <tbody>
+          <For each={benchmarks()}>
+            {(row) => (
+              <tr>
+                <td style={{ padding: "0.75rem" }}>{row.agent}</td>
+                <td style={{ padding: "0.75rem" }}>{row.model}</td>
+                <td style={{ padding: "0.75rem" }}>{row.averageScore.toFixed(3)}</td>
+                <For each={taskIds()}>
+                  {(id) => (
+                    <td style={{ padding: "0.75rem" }}>
+                      <Show when={row.taskScores[id] !== undefined} fallback="">
+                        <A href={`/bench/${row.id}:${id}`} style={{ color: "#0066cc" }}>
+                          {row.taskScores[id]?.toFixed(3)}
+                        </A>
+                      </Show>
+                    </td>
+                  )}
+                </For>
+              </tr>
+            )}
+          </For>
+        </tbody>
+      </table>
+    </main>
+  )
+}

+ 29 - 0
packages/console/app/src/routes/bench/submission.ts

@@ -0,0 +1,29 @@
+import type { APIEvent } from "@solidjs/start/server"
+import { Database } from "@opencode-ai/console-core/drizzle/index.js"
+import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
+import { Identifier } from "@opencode-ai/console-core/identifier.js"
+
+interface SubmissionBody {
+  model: string
+  agent: string
+  result: string
+}
+
+export async function POST(event: APIEvent) {
+  const body = (await event.request.json()) as SubmissionBody
+
+  if (!body.model || !body.agent || !body.result) {
+    return Response.json({ error: "All fields are required" }, { status: 400 })
+  }
+
+  await Database.use((tx) =>
+    tx.insert(BenchmarkTable).values({
+      id: Identifier.create("benchmark"),
+      model: body.model,
+      agent: body.agent,
+      result: body.result,
+    }),
+  )
+
+  return Response.json({ success: true }, { status: 200 })
+}

+ 1 - 0
packages/console/app/src/routes/download/[platform].ts

@@ -6,6 +6,7 @@ const assetNames: Record<string, string> = {
   "darwin-x64-dmg": "opencode-desktop-darwin-x64.dmg",
   "windows-x64-nsis": "opencode-desktop-windows-x64.exe",
   "linux-x64-deb": "opencode-desktop-linux-amd64.deb",
+  "linux-x64-appimage": "opencode-desktop-linux-amd64.AppImage",
   "linux-x64-rpm": "opencode-desktop-linux-x86_64.rpm",
 } satisfies Record<DownloadPlatform, string>
 

Разница между файлами не показана из-за своего большого размера
+ 5 - 0
packages/console/app/src/routes/download/index.tsx


+ 4 - 1
packages/console/app/src/routes/download/types.ts

@@ -1 +1,4 @@
-export type DownloadPlatform = `darwin-${"x64" | "aarch64"}-dmg` | "windows-x64-nsis" | `linux-x64-${"deb" | "rpm"}`
+export type DownloadPlatform =
+  | `darwin-${"x64" | "aarch64"}-dmg`
+  | "windows-x64-nsis"
+  | `linux-x64-${"deb" | "rpm" | "appimage"}`

Некоторые файлы не были показаны из-за большого количества измененных файлов