Prechádzať zdrojové kódy

Merge branch 'dev' into go-page

Frank 6 mesiacov pred
rodič
commit
e9de2505f6
100 zmenil súbory, kde vykonal 989 pridanie a 314 odobranie
  1. 127 1
      .github/workflows/publish.yml
  2. 11 0
      AGENTS.md
  3. 289 24
      bun.lock
  4. 4 4
      nix/hashes.json
  5. 2 1
      package.json
  6. 1 1
      packages/app/package.json
  7. 7 4
      packages/app/src/app.tsx
  8. 23 22
      packages/app/src/components/terminal.tsx
  9. 2 0
      packages/app/src/components/titlebar.tsx
  10. 2 0
      packages/app/src/pages/layout.tsx
  11. 0 8
      packages/app/src/pages/layout/sidebar-items.tsx
  12. 1 0
      packages/app/src/pages/session.tsx
  13. 41 2
      packages/app/src/pages/session/composer/session-composer-region.tsx
  14. 2 5
      packages/app/src/pages/session/composer/session-todo-dock.tsx
  15. 210 209
      packages/app/src/pages/session/message-timeline.tsx
  16. 8 2
      packages/app/src/pages/session/terminal-panel.tsx
  17. 19 18
      packages/app/src/utils/notification-click.test.ts
  18. 8 8
      packages/app/src/utils/notification-click.ts
  19. 1 1
      packages/console/app/package.json
  20. 1 1
      packages/console/app/src/routes/zen/util/provider/anthropic.ts
  21. 1 1
      packages/console/core/package.json
  22. 1 1
      packages/console/function/package.json
  23. 1 1
      packages/console/mail/package.json
  24. 28 0
      packages/desktop-electron/.gitignore
  25. 4 0
      packages/desktop-electron/AGENTS.md
  26. 32 0
      packages/desktop-electron/README.md
  27. 97 0
      packages/desktop-electron/electron-builder.config.ts
  28. 41 0
      packages/desktop-electron/electron.vite.config.ts
  29. 11 0
      packages/desktop-electron/icons/README.md
  30. BIN
      packages/desktop-electron/icons/beta/128x128.png
  31. BIN
      packages/desktop-electron/icons/beta/128x128@2x.png
  32. BIN
      packages/desktop-electron/icons/beta/32x32.png
  33. BIN
      packages/desktop-electron/icons/beta/64x64.png
  34. BIN
      packages/desktop-electron/icons/beta/Square107x107Logo.png
  35. BIN
      packages/desktop-electron/icons/beta/Square142x142Logo.png
  36. BIN
      packages/desktop-electron/icons/beta/Square150x150Logo.png
  37. BIN
      packages/desktop-electron/icons/beta/Square284x284Logo.png
  38. BIN
      packages/desktop-electron/icons/beta/Square30x30Logo.png
  39. BIN
      packages/desktop-electron/icons/beta/Square310x310Logo.png
  40. BIN
      packages/desktop-electron/icons/beta/Square44x44Logo.png
  41. BIN
      packages/desktop-electron/icons/beta/Square71x71Logo.png
  42. BIN
      packages/desktop-electron/icons/beta/Square89x89Logo.png
  43. BIN
      packages/desktop-electron/icons/beta/StoreLogo.png
  44. 5 0
      packages/desktop-electron/icons/beta/android/mipmap-anydpi-v26/ic_launcher.xml
  45. BIN
      packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher.png
  46. BIN
      packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher_foreground.png
  47. BIN
      packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher_round.png
  48. BIN
      packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher.png
  49. BIN
      packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher_foreground.png
  50. BIN
      packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher_round.png
  51. BIN
      packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher.png
  52. BIN
      packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher_foreground.png
  53. BIN
      packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher_round.png
  54. BIN
      packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher.png
  55. BIN
      packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher_foreground.png
  56. BIN
      packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher_round.png
  57. BIN
      packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher.png
  58. BIN
      packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher_foreground.png
  59. BIN
      packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher_round.png
  60. 4 0
      packages/desktop-electron/icons/beta/android/values/ic_launcher_background.xml
  61. BIN
      packages/desktop-electron/icons/beta/icon.icns
  62. BIN
      packages/desktop-electron/icons/beta/icon.ico
  63. BIN
      packages/desktop-electron/icons/beta/icon.png
  64. BIN
      packages/desktop-electron/icons/beta/ios/AppIcon-20x20@1x.png
  65. BIN
      packages/desktop-electron/icons/beta/ios/AppIcon-20x20@2x-1.png
  66. BIN
      packages/desktop-electron/icons/beta/ios/AppIcon-20x20@2x.png
  67. BIN
      packages/desktop-electron/icons/beta/ios/AppIcon-20x20@3x.png
  68. BIN
      packages/desktop-electron/icons/beta/ios/AppIcon-29x29@1x.png
  69. BIN
      packages/desktop-electron/icons/beta/ios/AppIcon-29x29@2x-1.png
  70. BIN
      packages/desktop-electron/icons/beta/ios/AppIcon-29x29@2x.png
  71. BIN
      packages/desktop-electron/icons/beta/ios/AppIcon-29x29@3x.png
  72. BIN
      packages/desktop-electron/icons/beta/ios/AppIcon-40x40@1x.png
  73. BIN
      packages/desktop-electron/icons/beta/ios/AppIcon-40x40@2x-1.png
  74. BIN
      packages/desktop-electron/icons/beta/ios/AppIcon-40x40@2x.png
  75. BIN
      packages/desktop-electron/icons/beta/ios/AppIcon-40x40@3x.png
  76. BIN
      packages/desktop-electron/icons/beta/ios/AppIcon-512@2x.png
  77. BIN
      packages/desktop-electron/icons/beta/ios/AppIcon-60x60@2x.png
  78. BIN
      packages/desktop-electron/icons/beta/ios/AppIcon-60x60@3x.png
  79. BIN
      packages/desktop-electron/icons/beta/ios/AppIcon-76x76@1x.png
  80. BIN
      packages/desktop-electron/icons/beta/ios/AppIcon-76x76@2x.png
  81. BIN
      packages/desktop-electron/icons/beta/ios/AppIcon-83.5x83.5@2x.png
  82. BIN
      packages/desktop-electron/icons/dev/128x128.png
  83. BIN
      packages/desktop-electron/icons/dev/128x128@2x.png
  84. BIN
      packages/desktop-electron/icons/dev/32x32.png
  85. BIN
      packages/desktop-electron/icons/dev/64x64.png
  86. BIN
      packages/desktop-electron/icons/dev/Square107x107Logo.png
  87. BIN
      packages/desktop-electron/icons/dev/Square142x142Logo.png
  88. BIN
      packages/desktop-electron/icons/dev/Square150x150Logo.png
  89. BIN
      packages/desktop-electron/icons/dev/Square284x284Logo.png
  90. BIN
      packages/desktop-electron/icons/dev/Square30x30Logo.png
  91. BIN
      packages/desktop-electron/icons/dev/Square310x310Logo.png
  92. BIN
      packages/desktop-electron/icons/dev/Square44x44Logo.png
  93. BIN
      packages/desktop-electron/icons/dev/Square71x71Logo.png
  94. BIN
      packages/desktop-electron/icons/dev/Square89x89Logo.png
  95. BIN
      packages/desktop-electron/icons/dev/StoreLogo.png
  96. 5 0
      packages/desktop-electron/icons/dev/android/mipmap-anydpi-v26/ic_launcher.xml
  97. BIN
      packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher.png
  98. BIN
      packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.png
  99. BIN
      packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher_round.png
  100. BIN
      packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher.png

+ 127 - 1
.github/workflows/publish.yml

@@ -99,7 +99,6 @@ jobs:
         with:
           name: opencode-cli
           path: packages/opencode/dist
-
     outputs:
       version: ${{ needs.version.outputs.version }}
 
@@ -240,11 +239,131 @@ jobs:
           APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
           APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
 
+  build-electron:
+    needs:
+      - build-cli
+      - version
+    continue-on-error: false
+    strategy:
+      fail-fast: false
+      matrix:
+        settings:
+          - host: macos-latest
+            target: x86_64-apple-darwin
+            platform_flag: --mac --x64
+          - host: macos-latest
+            target: aarch64-apple-darwin
+            platform_flag: --mac --arm64
+          - host: "blacksmith-4vcpu-windows-2025"
+            target: x86_64-pc-windows-msvc
+            platform_flag: --win
+          - host: "blacksmith-4vcpu-ubuntu-2404"
+            target: x86_64-unknown-linux-gnu
+            platform_flag: --linux
+          - host: "blacksmith-4vcpu-ubuntu-2404"
+            target: aarch64-unknown-linux-gnu
+            platform_flag: --linux
+    runs-on: ${{ matrix.settings.host }}
+    # if: github.ref_name == 'beta'
+    steps:
+      - uses: actions/checkout@v3
+
+      - uses: apple-actions/import-codesign-certs@v2
+        if: runner.os == 'macOS'
+        with:
+          keychain: build
+          p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
+          p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+
+      - name: Setup Apple API Key
+        if: runner.os == 'macOS'
+        run: echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
+
+      - uses: ./.github/actions/setup-bun
+
+      - uses: actions/setup-node@v4
+        with:
+          node-version: "24"
+
+      - name: Cache apt packages
+        if: contains(matrix.settings.host, 'ubuntu')
+        uses: actions/cache@v4
+        with:
+          path: ~/apt-cache
+          key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-${{ hashFiles('.github/workflows/publish.yml') }}
+          restore-keys: |
+            ${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-
+
+      - name: Install dependencies (ubuntu only)
+        if: contains(matrix.settings.host, 'ubuntu')
+        run: |
+          mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
+          sudo apt-get update
+          sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" rpm
+          sudo chmod -R a+rw ~/apt-cache
+
+      - name: Setup git committer
+        id: committer
+        uses: ./.github/actions/setup-git-committer
+        with:
+          opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
+          opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
+
+      - name: Prepare
+        run: bun ./scripts/prepare.ts
+        working-directory: packages/desktop-electron
+        env:
+          OPENCODE_VERSION: ${{ needs.version.outputs.version }}
+          OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
+          RUST_TARGET: ${{ matrix.settings.target }}
+          GH_TOKEN: ${{ github.token }}
+          GITHUB_RUN_ID: ${{ github.run_id }}
+
+      - name: Build
+        run: bun run build
+        working-directory: packages/desktop-electron
+        env:
+          OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
+
+      - name: Package and publish
+        if: needs.version.outputs.release
+        run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish always --config electron-builder.config.ts
+        working-directory: packages/desktop-electron
+        timeout-minutes: 60
+        env:
+          OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
+          GH_TOKEN: ${{ steps.committer.outputs.token }}
+          CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
+          CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+          APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
+          APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
+          APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
+
+      - name: Package (no publish)
+        if: ${{ !needs.version.outputs.release }}
+        run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish never --config electron-builder.config.ts
+        working-directory: packages/desktop-electron
+        timeout-minutes: 60
+        env:
+          OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
+
+      - uses: actions/upload-artifact@v4
+        with:
+          name: opencode-electron-${{ matrix.settings.target }}
+          path: packages/desktop-electron/dist/*
+
+      - uses: actions/upload-artifact@v4
+        if: needs.version.outputs.release
+        with:
+          name: latest-yml-${{ matrix.settings.target }}
+          path: packages/desktop-electron/dist/latest*.yml
+
   publish:
     needs:
       - version
       - build-cli
       - build-tauri
+      - build-electron
     runs-on: blacksmith-4vcpu-ubuntu-2404
     steps:
       - uses: actions/checkout@v3
@@ -281,6 +400,12 @@ jobs:
           name: opencode-cli
           path: packages/opencode/dist
 
+      - uses: actions/download-artifact@v4
+        if: needs.version.outputs.release
+        with:
+          pattern: latest-yml-*
+          path: /tmp/latest-yml
+
       - name: Cache apt packages (AUR)
         uses: actions/cache@v4
         with:
@@ -308,3 +433,4 @@ jobs:
           GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
           GH_REPO: ${{ needs.version.outputs.repo }}
           NPM_CONFIG_PROVENANCE: false
+          LATEST_YML_DIR: /tmp/latest-yml

+ 11 - 0
AGENTS.md

@@ -20,6 +20,17 @@
 
 Prefer single word names for variables and functions. Only use multiple words if necessary.
 
+### Naming Enforcement (Read This)
+
+THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
+
+- Use single word names by default for new locals, params, and helper functions.
+- Multi-word names are allowed only when a single word would be unclear or ambiguous.
+- Do not introduce new camelCase compounds when a short single-word alternative is clear.
+- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
+- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
+- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
+
 ```ts
 // Good
 const foo = 1

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 289 - 24
bun.lock


+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-8jEwsY7X7N/vKKbVZ0L8Djj2SfH9HCY+2jKSlaCrm9o=",
-    "aarch64-linux": "sha256-L0G7mSzzR+sZW0uACosJGsE2y/Uh3Vi4piXL4UJOmCw=",
-    "aarch64-darwin": "sha256-1S/g/51MSHjDfsL+U8wlt9Rl50hFf7I3fHgbhSqBIP4=",
-    "x86_64-darwin": "sha256-cveFpKVwcrUOzomU4J3wgYEKRwmJQF0KQiRqKgLJqWs="
+    "x86_64-linux": "sha256-jtBYpfiE9g0otqZEtOksW1Nbg+O8CJP9OEOEhsa7sa8=",
+    "aarch64-linux": "sha256-m+YNZIB7I7EMPyfqkKsvDvmBX9R1szmEKxXpxTNFLH8=",
+    "aarch64-darwin": "sha256-1gVmtkC1/I8sdHZcaeSFJheySVlpCyKCjf9zbVsVqAQ=",
+    "x86_64-darwin": "sha256-Tvk5YL6Z0xRul4jopbGme/997iHBylXC0Cq3RnjQb+I="
   }
 }

+ 2 - 1
package.json

@@ -99,7 +99,8 @@
     "protobufjs",
     "tree-sitter",
     "tree-sitter-bash",
-    "web-tree-sitter"
+    "web-tree-sitter",
+    "electron"
   ],
   "overrides": {
     "@types/bun": "catalog:",

+ 1 - 1
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/app",
-  "version": "1.2.15",
+  "version": "1.2.17",
   "description": "",
   "type": "module",
   "exports": {

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

@@ -7,8 +7,8 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked"
 import { Font } from "@opencode-ai/ui/font"
 import { ThemeProvider } from "@opencode-ai/ui/theme"
 import { MetaProvider } from "@solidjs/meta"
-import { Navigate, Route, Router } from "@solidjs/router"
-import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
+import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
+import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
 import { CommandProvider } from "@/context/command"
 import { CommentsProvider } from "@/context/comments"
 import { FileProvider } from "@/context/file"
@@ -28,6 +28,7 @@ import { TerminalProvider } from "@/context/terminal"
 import DirectoryLayout from "@/pages/directory-layout"
 import Layout from "@/pages/layout"
 import { ErrorPage } from "./pages/error"
+import { Dynamic } from "solid-js/web"
 
 const Home = lazy(() => import("@/pages/home"))
 const Session = lazy(() => import("@/pages/session"))
@@ -144,13 +145,15 @@ export function AppInterface(props: {
   children?: JSX.Element
   defaultServer: ServerConnection.Key
   servers?: Array<ServerConnection.Any>
+  router?: Component<BaseRouterProps>
 }) {
   return (
     <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
       <ServerKey>
         <GlobalSDKProvider>
           <GlobalSyncProvider>
-            <Router
+            <Dynamic
+              component={props.router ?? Router}
               root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
             >
               <Route path="/" component={HomeRoute} />
@@ -158,7 +161,7 @@ export function AppInterface(props: {
                 <Route path="/" component={SessionIndexRoute} />
                 <Route path="/session/:id?" component={SessionRoute} />
               </Route>
-            </Router>
+            </Dynamic>
           </GlobalSyncProvider>
         </GlobalSDKProvider>
       </ServerKey>

+ 23 - 22
packages/app/src/components/terminal.tsx

@@ -18,7 +18,7 @@ const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
 export interface TerminalProps extends ComponentProps<"div"> {
   pty: LocalPTY
   onSubmit?: () => void
-  onCleanup?: (pty: LocalPTY) => void
+  onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
   onConnect?: () => void
   onConnectError?: (error: unknown) => void
 }
@@ -126,8 +126,8 @@ const persistTerminal = (input: {
   term: Term | undefined
   addon: SerializeAddon | undefined
   cursor: number
-  pty: LocalPTY
-  onCleanup?: (pty: LocalPTY) => void
+  id: string
+  onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
 }) => {
   if (!input.addon || !input.onCleanup || !input.term) return
   const buffer = (() => {
@@ -140,7 +140,7 @@ const persistTerminal = (input: {
   })()
 
   input.onCleanup({
-    ...input.pty,
+    id: input.id,
     buffer,
     cursor: input.cursor,
     rows: input.term.rows,
@@ -158,6 +158,19 @@ export const Terminal = (props: TerminalProps) => {
   const server = useServer()
   let container!: HTMLDivElement
   const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
+  const id = local.pty.id
+  const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
+  const restoreSize =
+    restore &&
+    typeof local.pty.cols === "number" &&
+    Number.isSafeInteger(local.pty.cols) &&
+    local.pty.cols > 0 &&
+    typeof local.pty.rows === "number" &&
+    Number.isSafeInteger(local.pty.rows) &&
+    local.pty.rows > 0
+      ? { cols: local.pty.cols, rows: local.pty.rows }
+      : undefined
+  const scrollY = typeof local.pty.scrollY === "number" ? local.pty.scrollY : undefined
   let ws: WebSocket | undefined
   let term: Term | undefined
   let ghostty: Ghostty
@@ -190,7 +203,7 @@ export const Terminal = (props: TerminalProps) => {
   const pushSize = (cols: number, rows: number) => {
     return sdk.client.pty
       .update({
-        ptyID: local.pty.id,
+        ptyID: id,
         size: { cols, rows },
       })
       .catch((err) => {
@@ -319,18 +332,6 @@ export const Terminal = (props: TerminalProps) => {
       const mod = loaded.mod
       const g = loaded.ghostty
 
-      const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
-      const restoreSize =
-        restore &&
-        typeof local.pty.cols === "number" &&
-        Number.isSafeInteger(local.pty.cols) &&
-        local.pty.cols > 0 &&
-        typeof local.pty.rows === "number" &&
-        Number.isSafeInteger(local.pty.rows) &&
-        local.pty.rows > 0
-          ? { cols: local.pty.cols, rows: local.pty.rows }
-          : undefined
-
       const t = new mod.Terminal({
         cursorBlink: true,
         cursorStyle: "bar",
@@ -427,14 +428,14 @@ export const Terminal = (props: TerminalProps) => {
         await write(restore)
         fit.fit()
         scheduleSize(t.cols, t.rows)
-        if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
+        if (scrollY !== undefined) t.scrollToLine(scrollY)
         startResize()
       } else {
         fit.fit()
         scheduleSize(t.cols, t.rows)
         if (restore) {
           await write(restore)
-          if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
+          if (scrollY !== undefined) t.scrollToLine(scrollY)
         }
         startResize()
       }
@@ -446,9 +447,9 @@ export const Terminal = (props: TerminalProps) => {
       const once = { value: false }
       let closing = false
 
-      const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
+      const url = new URL(sdk.url + `/pty/${id}/connect`)
       url.searchParams.set("directory", sdk.directory)
-      url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
+      url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0))
       url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
       url.username = server.current?.http.username ?? ""
       url.password = server.current?.http.password ?? ""
@@ -542,7 +543,7 @@ export const Terminal = (props: TerminalProps) => {
     if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
 
     const finalize = () => {
-      persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
+      persistTerminal({ term, addon: serializeAddon, cursor, id, onCleanup: props.onCleanup })
       cleanup()
     }
 

+ 2 - 0
packages/app/src/components/titlebar.tsx

@@ -157,6 +157,7 @@ export function Titlebar() {
     <header
       class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
       style={{ "min-height": minHeight() }}
+      data-tauri-drag-region
       onMouseDown={drag}
       onDblClick={maximize}
     >
@@ -276,6 +277,7 @@ export function Titlebar() {
           "flex items-center min-w-0 justify-end": true,
           "pr-2": !windows(),
         }}
+        data-tauri-drag-region
         onMouseDown={drag}
       >
         <div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />

+ 2 - 0
packages/app/src/pages/layout.tsx

@@ -42,6 +42,7 @@ import { Binary } from "@opencode-ai/util/binary"
 import { retry } from "@opencode-ai/util/retry"
 import { playSound, soundSrc } from "@/utils/sound"
 import { createAim } from "@/utils/aim"
+import { setNavigate } from "@/utils/notification-click"
 import { Worktree as WorktreeState } from "@/utils/worktree"
 
 import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -107,6 +108,7 @@ export default function Layout(props: ParentProps) {
   const notification = useNotification()
   const permission = usePermission()
   const navigate = useNavigate()
+  setNavigate(navigate)
   const providers = useProviders()
   const dialog = useDialog()
   const command = useCommand()

+ 0 - 8
packages/app/src/pages/layout/sidebar-items.tsx

@@ -6,7 +6,6 @@ import { useNotification } from "@/context/notification"
 import { usePermission } from "@/context/permission"
 import { base64Encode } from "@opencode-ai/util/encode"
 import { Avatar } from "@opencode-ai/ui/avatar"
-import { DiffChanges } from "@opencode-ai/ui/diff-changes"
 import { HoverCard } from "@opencode-ai/ui/hover-card"
 import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -137,13 +136,6 @@ const SessionRow = (props: {
       <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
         {props.session.title}
       </span>
-      <Show when={props.session.summary}>
-        {(summary) => (
-          <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
-            <DiffChanges changes={summary()} />
-          </div>
-        )}
-      </Show>
     </div>
   </A>
 )

+ 1 - 0
packages/app/src/pages/session.tsx

@@ -1254,6 +1254,7 @@ export default function Page() {
 
           <SessionComposerRegion
             state={composer}
+            ready={!store.deferRender && messagesReady()}
             centered={centered()}
             inputRef={(el) => {
               inputRef = el

+ 41 - 2
packages/app/src/pages/session/composer/session-composer-region.tsx

@@ -1,4 +1,5 @@
 import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
 import { useParams } from "@solidjs/router"
 import { useSpring } from "@opencode-ai/ui/motion-spring"
 import { PromptInput } from "@/components/prompt-input"
@@ -12,6 +13,7 @@ import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
 
 export function SessionComposerRegion(props: {
   state: SessionComposerState
+  ready: boolean
   centered: boolean
   inputRef: (el: HTMLDivElement) => void
   newSessionWorktree: string
@@ -61,7 +63,44 @@ export function SessionComposerRegion(props: {
     setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
   })
 
-  const open = createMemo(() => props.state.dock() && !props.state.closing())
+  const [gate, setGate] = createStore({
+    ready: false,
+  })
+  let timer: number | undefined
+  let frame: number | undefined
+
+  const clear = () => {
+    if (timer !== undefined) {
+      window.clearTimeout(timer)
+      timer = undefined
+    }
+    if (frame !== undefined) {
+      cancelAnimationFrame(frame)
+      frame = undefined
+    }
+  }
+
+  createEffect(() => {
+    sessionKey()
+    const ready = props.ready
+    const delay = 140
+
+    clear()
+    setGate("ready", false)
+    if (!ready) return
+
+    frame = requestAnimationFrame(() => {
+      frame = undefined
+      timer = window.setTimeout(() => {
+        setGate("ready", true)
+        timer = undefined
+      }, delay)
+    })
+  })
+
+  onCleanup(clear)
+
+  const open = createMemo(() => gate.ready && props.state.dock() && !props.state.closing())
   const config = createMemo(() =>
     open()
       ? {
@@ -76,7 +115,7 @@ export function SessionComposerRegion(props: {
   const progress = useSpring(() => (open() ? 1 : 0), config)
   const value = createMemo(() => Math.max(0, Math.min(1, progress())))
   const [height, setHeight] = createSignal(320)
-  const dock = createMemo(() => props.state.dock() || value() > 0.001)
+  const dock = createMemo(() => (gate.ready && props.state.dock()) || value() > 0.001)
   const full = createMemo(() => Math.max(78, height()))
   const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
 

+ 2 - 5
packages/app/src/pages/session/composer/session-todo-dock.tsx

@@ -281,10 +281,8 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
               style={{
                 "--checkbox-align": "flex-start",
                 "--checkbox-offset": "1px",
-                transition:
-                  "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), filter 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
+                transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
                 opacity: todo().status === "pending" ? "0.94" : "1",
-                filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)",
               }}
             >
               <TextStrikethrough
@@ -294,13 +292,12 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
                 style={{
                   "line-height": "var(--line-height-normal)",
                   transition:
-                    "color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), filter 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
+                    "color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
                   color:
                     todo().status === "completed" || todo().status === "cancelled"
                       ? "var(--text-weak)"
                       : "var(--text-strong)",
                   opacity: todo().status === "pending" ? "0.92" : "1",
-                  filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)",
                 }}
               />
             </Checkbox>

+ 210 - 209
packages/app/src/pages/session/message-timeline.tsx

@@ -550,227 +550,228 @@ export function MessageTimeline(props: {
             "--sticky-accordion-top": showHeader() ? "48px" : "0px",
           }}
         >
-          <Show when={showHeader()}>
-            <div
-              data-session-title
-              classList={{
-                "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
-                "w-full": true,
-                "pb-4": true,
-                "pl-2 pr-3 md:pl-4 md:pr-3": true,
-                "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
-              }}
-            >
-              <div class="h-12 w-full flex items-center justify-between gap-2">
-                <div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
-                  <Show when={parentID()}>
-                    <IconButton
-                      tabIndex={-1}
-                      icon="arrow-left"
-                      variant="ghost"
-                      onClick={navigateParent}
-                      aria-label={language.t("common.goBack")}
-                    />
-                  </Show>
-                  <Show when={titleValue() || title.editing}>
-                    <Show
-                      when={title.editing}
-                      fallback={
-                        <h1
-                          class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
-                          onDblClick={openTitleEditor}
-                        >
-                          {titleValue()}
-                        </h1>
-                      }
-                    >
-                      <InlineInput
-                        ref={(el) => {
-                          titleRef = el
-                        }}
-                        value={title.draft}
-                        disabled={title.saving}
-                        class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
-                        style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
-                        onInput={(event) => setTitle("draft", event.currentTarget.value)}
-                        onKeyDown={(event) => {
-                          event.stopPropagation()
-                          if (event.key === "Enter") {
-                            event.preventDefault()
-                            void saveTitleEditor()
-                            return
-                          }
-                          if (event.key === "Escape") {
-                            event.preventDefault()
-                            closeTitleEditor()
-                          }
-                        }}
-                        onBlur={closeTitleEditor}
+          <div ref={props.setContentRef} class="min-w-0 w-full">
+            <Show when={showHeader()}>
+              <div
+                data-session-title
+                classList={{
+                  "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
+                  "w-full": true,
+                  "pb-4": true,
+                  "pl-2 pr-3 md:pl-4 md:pr-3": true,
+                  "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
+                }}
+              >
+                <div class="h-12 w-full flex items-center justify-between gap-2">
+                  <div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
+                    <Show when={parentID()}>
+                      <IconButton
+                        tabIndex={-1}
+                        icon="arrow-left"
+                        variant="ghost"
+                        onClick={navigateParent}
+                        aria-label={language.t("common.goBack")}
                       />
                     </Show>
-                  </Show>
-                </div>
-                <Show when={sessionID()}>
-                  {(id) => (
-                    <div class="shrink-0 flex items-center gap-3">
-                      <SessionContextUsage placement="bottom" />
-                      <DropdownMenu
-                        gutter={4}
-                        placement="bottom-end"
-                        open={title.menuOpen}
-                        onOpenChange={(open) => setTitle("menuOpen", open)}
+                    <Show when={titleValue() || title.editing}>
+                      <Show
+                        when={title.editing}
+                        fallback={
+                          <h1
+                            class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
+                            onDblClick={openTitleEditor}
+                          >
+                            {titleValue()}
+                          </h1>
+                        }
                       >
-                        <DropdownMenu.Trigger
-                          as={IconButton}
-                          icon="dot-grid"
-                          variant="ghost"
-                          class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
-                          aria-label={language.t("common.moreOptions")}
-                        />
-                        <DropdownMenu.Portal>
-                          <DropdownMenu.Content
-                            style={{ "min-width": "104px" }}
-                            onCloseAutoFocus={(event) => {
-                              if (!title.pendingRename) return
+                        <InlineInput
+                          ref={(el) => {
+                            titleRef = el
+                          }}
+                          value={title.draft}
+                          disabled={title.saving}
+                          class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
+                          style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
+                          onInput={(event) => setTitle("draft", event.currentTarget.value)}
+                          onKeyDown={(event) => {
+                            event.stopPropagation()
+                            if (event.key === "Enter") {
                               event.preventDefault()
-                              setTitle("pendingRename", false)
-                              openTitleEditor()
-                            }}
-                          >
-                            <DropdownMenu.Item
-                              onSelect={() => {
-                                setTitle("pendingRename", true)
-                                setTitle("menuOpen", false)
+                              void saveTitleEditor()
+                              return
+                            }
+                            if (event.key === "Escape") {
+                              event.preventDefault()
+                              closeTitleEditor()
+                            }
+                          }}
+                          onBlur={closeTitleEditor}
+                        />
+                      </Show>
+                    </Show>
+                  </div>
+                  <Show when={sessionID()}>
+                    {(id) => (
+                      <div class="shrink-0 flex items-center gap-3">
+                        <SessionContextUsage placement="bottom" />
+                        <DropdownMenu
+                          gutter={4}
+                          placement="bottom-end"
+                          open={title.menuOpen}
+                          onOpenChange={(open) => setTitle("menuOpen", open)}
+                        >
+                          <DropdownMenu.Trigger
+                            as={IconButton}
+                            icon="dot-grid"
+                            variant="ghost"
+                            class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
+                            aria-label={language.t("common.moreOptions")}
+                          />
+                          <DropdownMenu.Portal>
+                            <DropdownMenu.Content
+                              style={{ "min-width": "104px" }}
+                              onCloseAutoFocus={(event) => {
+                                if (!title.pendingRename) return
+                                event.preventDefault()
+                                setTitle("pendingRename", false)
+                                openTitleEditor()
                               }}
                             >
-                              <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
-                            </DropdownMenu.Item>
-                            <DropdownMenu.Item onSelect={() => void archiveSession(id())}>
-                              <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
-                            </DropdownMenu.Item>
-                            <DropdownMenu.Separator />
-                            <DropdownMenu.Item
-                              onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
-                            >
-                              <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
-                            </DropdownMenu.Item>
-                          </DropdownMenu.Content>
-                        </DropdownMenu.Portal>
-                      </DropdownMenu>
-                    </div>
-                  )}
-                </Show>
-              </div>
-            </div>
-          </Show>
-
-          <div
-            ref={props.setContentRef}
-            role="log"
-            class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
-            classList={{
-              "w-full": true,
-              "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
-              "mt-0.5": props.centered,
-              "mt-0": !props.centered,
-            }}
-          >
-            <Show when={props.turnStart > 0 || props.historyMore}>
-              <div class="w-full flex justify-center">
-                <Button
-                  variant="ghost"
-                  size="large"
-                  class="text-12-medium opacity-50"
-                  disabled={props.historyLoading}
-                  onClick={props.onLoadEarlier}
-                >
-                  {props.historyLoading
-                    ? language.t("session.messages.loadingEarlier")
-                    : language.t("session.messages.loadEarlier")}
-                </Button>
+                              <DropdownMenu.Item
+                                onSelect={() => {
+                                  setTitle("pendingRename", true)
+                                  setTitle("menuOpen", false)
+                                }}
+                              >
+                                <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
+                              </DropdownMenu.Item>
+                              <DropdownMenu.Item onSelect={() => void archiveSession(id())}>
+                                <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
+                              </DropdownMenu.Item>
+                              <DropdownMenu.Separator />
+                              <DropdownMenu.Item
+                                onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
+                              >
+                                <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
+                              </DropdownMenu.Item>
+                            </DropdownMenu.Content>
+                          </DropdownMenu.Portal>
+                        </DropdownMenu>
+                      </div>
+                    )}
+                  </Show>
+                </div>
               </div>
             </Show>
-            <For each={rendered()}>
-              {(messageID) => {
-                const active = createMemo(() => activeMessageID() === messageID)
-                const queued = createMemo(() => {
-                  if (active()) return false
-                  const activeID = activeMessageID()
-                  if (activeID) return messageID > activeID
-                  return false
-                })
-                const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
-                  equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
-                })
-                const commentCount = createMemo(() => comments().length)
-                return (
-                  <div
-                    id={props.anchor(messageID)}
-                    data-message-id={messageID}
-                    ref={(el) => {
-                      props.onRegisterMessage(el, messageID)
-                      onCleanup(() => props.onUnregisterMessage(messageID))
-                    }}
-                    classList={{
-                      "min-w-0 w-full max-w-full": true,
-                      "md:max-w-200 2xl:max-w-[1000px]": props.centered,
-                    }}
+
+            <div
+              role="log"
+              class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
+              classList={{
+                "w-full": true,
+                "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
+                "mt-0.5": props.centered,
+                "mt-0": !props.centered,
+              }}
+            >
+              <Show when={props.turnStart > 0 || props.historyMore}>
+                <div class="w-full flex justify-center">
+                  <Button
+                    variant="ghost"
+                    size="large"
+                    class="text-12-medium opacity-50"
+                    disabled={props.historyLoading}
+                    onClick={props.onLoadEarlier}
                   >
-                    <Show when={commentCount() > 0}>
-                      <div class="w-full px-4 md:px-5 pb-2">
-                        <div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
-                          <div class="flex w-max min-w-full justify-end gap-2">
-                            <Index each={comments()}>
-                              {(commentAccessor: () => MessageComment) => {
-                                const comment = createMemo(() => commentAccessor())
-                                return (
-                                  <div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
-                                    <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
-                                      <FileIcon
-                                        node={{ path: comment().path, type: "file" }}
-                                        class="size-3.5 shrink-0"
-                                      />
-                                      <span class="truncate">{getFilename(comment().path)}</span>
-                                      <Show when={comment().selection}>
-                                        {(selection) => (
-                                          <span class="shrink-0 text-text-weak">
-                                            {selection().startLine === selection().endLine
-                                              ? `:${selection().startLine}`
-                                              : `:${selection().startLine}-${selection().endLine}`}
-                                          </span>
-                                        )}
-                                      </Show>
-                                    </div>
-                                    <div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
-                                      {comment().comment}
+                    {props.historyLoading
+                      ? language.t("session.messages.loadingEarlier")
+                      : language.t("session.messages.loadEarlier")}
+                  </Button>
+                </div>
+              </Show>
+              <For each={rendered()}>
+                {(messageID) => {
+                  const active = createMemo(() => activeMessageID() === messageID)
+                  const queued = createMemo(() => {
+                    if (active()) return false
+                    const activeID = activeMessageID()
+                    if (activeID) return messageID > activeID
+                    return false
+                  })
+                  const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
+                    equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
+                  })
+                  const commentCount = createMemo(() => comments().length)
+                  return (
+                    <div
+                      id={props.anchor(messageID)}
+                      data-message-id={messageID}
+                      ref={(el) => {
+                        props.onRegisterMessage(el, messageID)
+                        onCleanup(() => props.onUnregisterMessage(messageID))
+                      }}
+                      classList={{
+                        "min-w-0 w-full max-w-full": true,
+                        "md:max-w-200 2xl:max-w-[1000px]": props.centered,
+                      }}
+                    >
+                      <Show when={commentCount() > 0}>
+                        <div class="w-full px-4 md:px-5 pb-2">
+                          <div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
+                            <div class="flex w-max min-w-full justify-end gap-2">
+                              <Index each={comments()}>
+                                {(commentAccessor: () => MessageComment) => {
+                                  const comment = createMemo(() => commentAccessor())
+                                  return (
+                                    <div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
+                                      <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
+                                        <FileIcon
+                                          node={{ path: comment().path, type: "file" }}
+                                          class="size-3.5 shrink-0"
+                                        />
+                                        <span class="truncate">{getFilename(comment().path)}</span>
+                                        <Show when={comment().selection}>
+                                          {(selection) => (
+                                            <span class="shrink-0 text-text-weak">
+                                              {selection().startLine === selection().endLine
+                                                ? `:${selection().startLine}`
+                                                : `:${selection().startLine}-${selection().endLine}`}
+                                            </span>
+                                          )}
+                                        </Show>
+                                      </div>
+                                      <div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
+                                        {comment().comment}
+                                      </div>
                                     </div>
-                                  </div>
-                                )
-                              }}
-                            </Index>
+                                  )
+                                }}
+                              </Index>
+                            </div>
                           </div>
                         </div>
-                      </div>
-                    </Show>
-                    <SessionTurn
-                      sessionID={sessionID() ?? ""}
-                      messageID={messageID}
-                      active={active()}
-                      queued={queued()}
-                      status={active() ? sessionStatus() : undefined}
-                      showReasoningSummaries={settings.general.showReasoningSummaries()}
-                      shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
-                      editToolDefaultOpen={settings.general.editToolPartsExpanded()}
-                      classes={{
-                        root: "min-w-0 w-full relative",
-                        content: "flex flex-col justify-between !overflow-visible",
-                        container: "w-full px-4 md:px-5",
-                      }}
-                    />
-                  </div>
-                )
-              }}
-            </For>
+                      </Show>
+                      <SessionTurn
+                        sessionID={sessionID() ?? ""}
+                        messageID={messageID}
+                        active={active()}
+                        queued={queued()}
+                        status={active() ? sessionStatus() : undefined}
+                        showReasoningSummaries={settings.general.showReasoningSummaries()}
+                        shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
+                        editToolDefaultOpen={settings.general.editToolPartsExpanded()}
+                        classes={{
+                          root: "min-w-0 w-full relative",
+                          content: "flex flex-col justify-between !overflow-visible",
+                          container: "w-full px-4 md:px-5",
+                        }}
+                      />
+                    </div>
+                  )
+                }}
+              </For>
+            </div>
           </div>
         </ScrollView>
       </div>

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

@@ -102,7 +102,7 @@ export function TerminalPanel() {
 
   const all = createMemo(() => terminal.all())
   const ids = createMemo(() => all().map((pty) => pty.id))
-  const byId = createMemo(() => new Map(all().map((pty) => [pty.id, pty])))
+  const byId = createMemo(() => new Map(all().map((pty) => [pty.id, { ...pty }])))
 
   const handleTerminalDragStart = (event: unknown) => {
     const id = getDraggableId(event)
@@ -189,7 +189,13 @@ export function TerminalPanel() {
               >
                 <Tabs.List class="h-10">
                   <SortableProvider ids={ids()}>
-                    <For each={all()}>{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}</For>
+                    <For each={ids()}>
+                      {(id) => (
+                        <Show when={byId().get(id)}>
+                          {(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
+                        </Show>
+                      )}
+                    </For>
                   </SortableProvider>
                   <div class="h-full flex items-center justify-center">
                     <TooltipKeybind

+ 19 - 18
packages/app/src/utils/notification-click.test.ts

@@ -1,26 +1,27 @@
-import { describe, expect, test } from "bun:test"
-import { handleNotificationClick } from "./notification-click"
+import { afterEach, describe, expect, test } from "bun:test"
+import { handleNotificationClick, setNavigate } from "./notification-click"
 
 describe("notification click", () => {
-  test("focuses and navigates when href exists", () => {
+  afterEach(() => {
+    setNavigate(undefined as any)
+  })
+
+  test("navigates via registered navigate function", () => {
     const calls: string[] = []
-    handleNotificationClick("/abc/session/123", {
-      focus: () => calls.push("focus"),
-      location: {
-        assign: (href) => calls.push(href),
-      },
-    })
-    expect(calls).toEqual(["focus", "/abc/session/123"])
+    setNavigate((href) => calls.push(href))
+    handleNotificationClick("/abc/session/123")
+    expect(calls).toEqual(["/abc/session/123"])
   })
 
-  test("only focuses when href is missing", () => {
+  test("does not navigate when href is missing", () => {
     const calls: string[] = []
-    handleNotificationClick(undefined, {
-      focus: () => calls.push("focus"),
-      location: {
-        assign: (href) => calls.push(href),
-      },
-    })
-    expect(calls).toEqual(["focus"])
+    setNavigate((href) => calls.push(href))
+    handleNotificationClick(undefined)
+    expect(calls).toEqual([])
+  })
+
+  test("falls back to location.assign without registered navigate", () => {
+    handleNotificationClick("/abc/session/123")
+    // falls back to window.location.assign — no error thrown
   })
 })

+ 8 - 8
packages/app/src/utils/notification-click.ts

@@ -1,12 +1,12 @@
-type WindowTarget = {
-  focus: () => void
-  location: {
-    assign: (href: string) => void
-  }
+let nav: ((href: string) => void) | undefined
+
+export const setNavigate = (fn: (href: string) => void) => {
+  nav = fn
 }
 
-export const handleNotificationClick = (href?: string, target: WindowTarget = window) => {
-  target.focus()
+export const handleNotificationClick = (href?: string) => {
+  window.focus()
   if (!href) return
-  target.location.assign(href)
+  if (nav) nav(href)
+  else window.location.assign(href)
 }

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

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/console-app",
-  "version": "1.2.15",
+  "version": "1.2.17",
   "type": "module",
   "license": "MIT",
   "scripts": {

+ 1 - 1
packages/console/app/src/routes/zen/util/provider/anthropic.ts

@@ -43,7 +43,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
       ...(isBedrock
         ? {
             anthropic_version: "bedrock-2023-05-31",
-            anthropic_beta: supports1m ? "context-1m-2025-08-07" : undefined,
+            anthropic_beta: supports1m ? ["context-1m-2025-08-07"] : undefined,
             model: undefined,
             stream: undefined,
           }

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

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/console-core",
-  "version": "1.2.15",
+  "version": "1.2.17",
   "private": true,
   "type": "module",
   "license": "MIT",

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

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/console-function",
-  "version": "1.2.15",
+  "version": "1.2.17",
   "$schema": "https://json.schemastore.org/package.json",
   "private": true,
   "type": "module",

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

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/console-mail",
-  "version": "1.2.15",
+  "version": "1.2.17",
   "dependencies": {
     "@jsx-email/all": "2.2.3",
     "@jsx-email/cli": "1.4.3",

+ 28 - 0
packages/desktop-electron/.gitignore

@@ -0,0 +1,28 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+out/
+
+resources/opencode-cli*
+resources/icons

+ 4 - 0
packages/desktop-electron/AGENTS.md

@@ -0,0 +1,4 @@
+# Desktop package notes
+
+- Renderer process should only call `window.api` from `src/preload`.
+- Main process should register IPC handlers in `src/main/ipc.ts`.

+ 32 - 0
packages/desktop-electron/README.md

@@ -0,0 +1,32 @@
+# OpenCode Desktop
+
+Native OpenCode desktop app, built with Tauri v2.
+
+## Development
+
+From the repo root:
+
+```bash
+bun install
+bun run --cwd packages/desktop tauri dev
+```
+
+This starts the Vite dev server on http://localhost:1420 and opens the native window.
+
+If you only want the web dev server (no native shell):
+
+```bash
+bun run --cwd packages/desktop dev
+```
+
+## Build
+
+To create a production `dist/` and build the native app bundle:
+
+```bash
+bun run --cwd packages/desktop tauri build
+```
+
+## Prerequisites
+
+Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions.

+ 97 - 0
packages/desktop-electron/electron-builder.config.ts

@@ -0,0 +1,97 @@
+import type { Configuration } from "electron-builder"
+
+const channel = (() => {
+  const raw = process.env.OPENCODE_CHANNEL
+  if (raw === "dev" || raw === "beta" || raw === "prod") return raw
+  return "dev"
+})()
+
+const getBase = (): Configuration => ({
+  artifactName: "opencode-electron-${os}-${arch}.${ext}",
+  directories: {
+    output: "dist",
+    buildResources: "resources",
+  },
+  files: ["out/**/*", "resources/**/*"],
+  extraResources: [
+    {
+      from: "resources/",
+      to: "",
+      filter: ["opencode-cli*"],
+    },
+    {
+      from: "native/",
+      to: "native/",
+      filter: ["index.js", "index.d.ts", "build/Release/mac_window.node", "swift-build/**"],
+    },
+  ],
+  mac: {
+    category: "public.app-category.developer-tools",
+    icon: `resources/icons/icon.icns`,
+    hardenedRuntime: true,
+    gatekeeperAssess: false,
+    entitlements: "resources/entitlements.plist",
+    entitlementsInherit: "resources/entitlements.plist",
+    notarize: true,
+    target: ["dmg", "zip"],
+  },
+  dmg: {
+    sign: true,
+  },
+  protocols: {
+    name: "OpenCode",
+    schemes: ["opencode"],
+  },
+  win: {
+    icon: `resources/icons/icon.ico`,
+    target: ["nsis"],
+  },
+  nsis: {
+    oneClick: false,
+    allowToChangeInstallationDirectory: true,
+    installerIcon: `resources/icons/icon.ico`,
+    installerHeaderIcon: `resources/icons/icon.ico`,
+  },
+  linux: {
+    icon: `resources/icons`,
+    category: "Development",
+    target: ["AppImage", "deb", "rpm"],
+  },
+})
+
+function getConfig() {
+  const base = getBase()
+
+  switch (channel) {
+    case "dev": {
+      return {
+        ...base,
+        appId: "ai.opencode.desktop.dev",
+        productName: "OpenCode Dev",
+        rpm: { packageName: "opencode-dev" },
+      }
+    }
+    case "beta": {
+      return {
+        ...base,
+        appId: "ai.opencode.desktop.beta",
+        productName: "OpenCode Beta",
+        protocols: { name: "OpenCode Beta", schemes: ["opencode"] },
+        publish: { provider: "github", owner: "anomalyco", repo: "opencode-beta", channel: "latest" },
+        rpm: { packageName: "opencode-beta" },
+      }
+    }
+    case "prod": {
+      return {
+        ...base,
+        appId: "ai.opencode.desktop",
+        productName: "OpenCode",
+        protocols: { name: "OpenCode", schemes: ["opencode"] },
+        publish: { provider: "github", owner: "anomalyco", repo: "opencode", channel: "latest" },
+        rpm: { packageName: "opencode" },
+      }
+    }
+  }
+}
+
+export default getConfig()

+ 41 - 0
packages/desktop-electron/electron.vite.config.ts

@@ -0,0 +1,41 @@
+import { defineConfig } from "electron-vite"
+import appPlugin from "@opencode-ai/app/vite"
+
+const channel = (() => {
+  const raw = process.env.OPENCODE_CHANNEL
+  if (raw === "dev" || raw === "beta" || raw === "prod") return raw
+  return "dev"
+})()
+
+export default defineConfig({
+  main: {
+    define: {
+      "import.meta.env.OPENCODE_CHANNEL": JSON.stringify(channel),
+    },
+    build: {
+      rollupOptions: {
+        input: { index: "src/main/index.ts" },
+      },
+    },
+  },
+  preload: {
+    build: {
+      rollupOptions: {
+        input: { index: "src/preload/index.ts" },
+      },
+    },
+  },
+  renderer: {
+    plugins: [appPlugin],
+    publicDir: "../app/public",
+    root: "src/renderer",
+    build: {
+      rollupOptions: {
+        input: {
+          main: "src/renderer/index.html",
+          loading: "src/renderer/loading.html",
+        },
+      },
+    },
+  },
+})

+ 11 - 0
packages/desktop-electron/icons/README.md

@@ -0,0 +1,11 @@
+# Tauri Icons
+
+Here's the process I've been using to create icons:
+
+- Save source image as `app-icon.png` in `packages/desktop`
+- `cd` to `packages/desktop`
+- Run `bun tauri icon -o src-tauri/icons/{environment}`
+- Use [Image2Icon](https://img2icnsapp.com/)'s 'Big Sur Icon' preset to generate an `icon.icns` file and place it in the appropriate icons folder
+
+The Image2Icon step is necessary as the `icon.icns` generated by `app-icon.png` does not apply the shadow/padding expected by macOS,
+so app icons appear larger than expected.

BIN
packages/desktop-electron/icons/beta/128x128.png


BIN
packages/desktop-electron/icons/beta/128x128@2x.png


BIN
packages/desktop-electron/icons/beta/32x32.png


BIN
packages/desktop-electron/icons/beta/64x64.png


BIN
packages/desktop-electron/icons/beta/Square107x107Logo.png


BIN
packages/desktop-electron/icons/beta/Square142x142Logo.png


BIN
packages/desktop-electron/icons/beta/Square150x150Logo.png


BIN
packages/desktop-electron/icons/beta/Square284x284Logo.png


BIN
packages/desktop-electron/icons/beta/Square30x30Logo.png


BIN
packages/desktop-electron/icons/beta/Square310x310Logo.png


BIN
packages/desktop-electron/icons/beta/Square44x44Logo.png


BIN
packages/desktop-electron/icons/beta/Square71x71Logo.png


BIN
packages/desktop-electron/icons/beta/Square89x89Logo.png


BIN
packages/desktop-electron/icons/beta/StoreLogo.png


+ 5 - 0
packages/desktop-electron/icons/beta/android/mipmap-anydpi-v26/ic_launcher.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+  <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
+  <background android:drawable="@color/ic_launcher_background"/>
+</adaptive-icon>

BIN
packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher.png


BIN
packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher_foreground.png


BIN
packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher_round.png


BIN
packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher.png


BIN
packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher_foreground.png


BIN
packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher_round.png


BIN
packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher.png


BIN
packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher_foreground.png


BIN
packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher_round.png


BIN
packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher.png


BIN
packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher_foreground.png


BIN
packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher_round.png


BIN
packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher.png


BIN
packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher_foreground.png


BIN
packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher_round.png


+ 4 - 0
packages/desktop-electron/icons/beta/android/values/ic_launcher_background.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <color name="ic_launcher_background">#fff</color>
+</resources>

BIN
packages/desktop-electron/icons/beta/icon.icns


BIN
packages/desktop-electron/icons/beta/icon.ico


BIN
packages/desktop-electron/icons/beta/icon.png


BIN
packages/desktop-electron/icons/beta/ios/AppIcon-20x20@1x.png


BIN
packages/desktop-electron/icons/beta/ios/AppIcon-20x20@2x-1.png


BIN
packages/desktop-electron/icons/beta/ios/AppIcon-20x20@2x.png


BIN
packages/desktop-electron/icons/beta/ios/AppIcon-20x20@3x.png


BIN
packages/desktop-electron/icons/beta/ios/AppIcon-29x29@1x.png


BIN
packages/desktop-electron/icons/beta/ios/AppIcon-29x29@2x-1.png


BIN
packages/desktop-electron/icons/beta/ios/AppIcon-29x29@2x.png


BIN
packages/desktop-electron/icons/beta/ios/AppIcon-29x29@3x.png


BIN
packages/desktop-electron/icons/beta/ios/AppIcon-40x40@1x.png


BIN
packages/desktop-electron/icons/beta/ios/AppIcon-40x40@2x-1.png


BIN
packages/desktop-electron/icons/beta/ios/AppIcon-40x40@2x.png


BIN
packages/desktop-electron/icons/beta/ios/AppIcon-40x40@3x.png


BIN
packages/desktop-electron/icons/beta/ios/AppIcon-512@2x.png


BIN
packages/desktop-electron/icons/beta/ios/AppIcon-60x60@2x.png


BIN
packages/desktop-electron/icons/beta/ios/AppIcon-60x60@3x.png


BIN
packages/desktop-electron/icons/beta/ios/AppIcon-76x76@1x.png


BIN
packages/desktop-electron/icons/beta/ios/AppIcon-76x76@2x.png


BIN
packages/desktop-electron/icons/beta/ios/AppIcon-83.5x83.5@2x.png


BIN
packages/desktop-electron/icons/dev/128x128.png


BIN
packages/desktop-electron/icons/dev/128x128@2x.png


BIN
packages/desktop-electron/icons/dev/32x32.png


BIN
packages/desktop-electron/icons/dev/64x64.png


BIN
packages/desktop-electron/icons/dev/Square107x107Logo.png


BIN
packages/desktop-electron/icons/dev/Square142x142Logo.png


BIN
packages/desktop-electron/icons/dev/Square150x150Logo.png


BIN
packages/desktop-electron/icons/dev/Square284x284Logo.png


BIN
packages/desktop-electron/icons/dev/Square30x30Logo.png


BIN
packages/desktop-electron/icons/dev/Square310x310Logo.png


BIN
packages/desktop-electron/icons/dev/Square44x44Logo.png


BIN
packages/desktop-electron/icons/dev/Square71x71Logo.png


BIN
packages/desktop-electron/icons/dev/Square89x89Logo.png


BIN
packages/desktop-electron/icons/dev/StoreLogo.png


+ 5 - 0
packages/desktop-electron/icons/dev/android/mipmap-anydpi-v26/ic_launcher.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+  <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
+  <background android:drawable="@color/ic_launcher_background"/>
+</adaptive-icon>

BIN
packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher.png


BIN
packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.png


BIN
packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher_round.png


BIN
packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher.png


Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov