diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index ddfa7fd161b..831f32edbf4 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -10,6 +10,7 @@ adamdotdevin -agusbasari29 AI PR slop ariane-emory +-danieljoshuanazareth edemaine -florianleibert fwang @@ -17,8 +18,9 @@ iamdavidhill jayair kitlangton kommander +-opencode2026 r44vc0rp rekram1-node -spider-yamet clawdbot/llm psychosis, spam pinging the team thdxr --OpenCode2026 +-danieljoshuanazareth diff --git a/assets/happy-cat.gif b/assets/happy-cat.gif new file mode 100644 index 00000000000..fbc3131fee7 Binary files /dev/null and b/assets/happy-cat.gif differ diff --git a/assets/head-empty-cat.gif b/assets/head-empty-cat.gif new file mode 100644 index 00000000000..b5ae28632f8 Binary files /dev/null and b/assets/head-empty-cat.gif differ diff --git a/bun.lock b/bun.lock index 58cfe892f39..7dc8ad51f84 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.2.27", + "version": "1.3.0", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -44,6 +44,7 @@ "@solid-primitives/websocket": "1.3.1", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", + "@tanstack/solid-query": "5.91.4", "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", "effect": "catalog:", @@ -77,7 +78,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.2.27", + "version": "1.3.0", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -111,7 +112,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.2.27", + "version": "1.3.0", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -138,7 +139,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.2.27", + "version": "1.3.0", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -162,7 +163,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.2.27", + "version": "1.3.0", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -186,7 +187,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.2.27", + "version": "1.3.0", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -219,7 +220,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.2.27", + "version": "1.3.0", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -250,7 +251,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.2.27", + "version": "1.3.0", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -279,7 +280,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.2.27", + "version": "1.3.0", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -295,7 +296,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.2.27", + "version": "1.3.0", "bin": { "opencode": "./bin/opencode", }, @@ -336,8 +337,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.4", - "@opentui/core": "0.1.87", - "@opentui/solid": "0.1.87", + "@opentui/core": "0.1.88", + "@opentui/solid": "0.1.88", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -419,7 +420,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.2.27", + "version": "1.3.0", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -443,7 +444,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.2.27", + "version": "1.3.0", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -454,7 +455,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.2.27", + "version": "1.3.0", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -489,7 +490,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.2.27", + "version": "1.3.0", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -535,7 +536,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.2.27", + "version": "1.3.0", "dependencies": { "zod": "catalog:", }, @@ -546,7 +547,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.2.27", + "version": "1.3.0", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1446,21 +1447,21 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "@opentui/core": ["@opentui/core@0.1.87", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.87", "@opentui/core-darwin-x64": "0.1.87", "@opentui/core-linux-arm64": "0.1.87", "@opentui/core-linux-x64": "0.1.87", "@opentui/core-win32-arm64": "0.1.87", "@opentui/core-win32-x64": "0.1.87", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dhsmMv0IqKftwG7J/pBrLBj2armsYIg5R3LBvciRQI/6X89GufP4l1u0+QTACAx6iR4SYJJNVNQ2tdX8LM9rMw=="], + "@opentui/core": ["@opentui/core@0.1.88", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.88", "@opentui/core-darwin-x64": "0.1.88", "@opentui/core-linux-arm64": "0.1.88", "@opentui/core-linux-x64": "0.1.88", "@opentui/core-win32-arm64": "0.1.88", "@opentui/core-win32-x64": "0.1.88", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-eaDVZfAzZraddOIkgWSHMVkyaY0O20foYnPWKPQx1TY4t7G1oatIoan2zkytx67epW+4BZQ9vGib+61/uNM1MA=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.87", "", { "os": "darwin", "cpu": "arm64" }, "sha512-G8oq85diOfkU6n0T1CxCle7oDmpKxwhcdhZ9khBMU5IrfLx9ZDuCM3F6MsiRQWdvPPCq2oomNbd64bYkPamYgw=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.88", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oGRexWwZFeQJymOK5ORrLrwJUbPHMYaFa0EcLnlhvPnymm1xyMcRKm39ez0WSIdtiCCi/PmMHX95CfyyJB5VMA=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.87", "", { "os": "darwin", "cpu": "x64" }, "sha512-MYTFQfOHm6qO7YaY4GHK9u/oJlXY6djaaxl5I+k4p2mk3vvuFIl/AP1ypITwBFjyV5gyp7PRWFp4nGfY9oN8bw=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.88", "", { "os": "darwin", "cpu": "x64" }, "sha512-ddnruYpXt7gXsAqZoQzNrHtZ50niYQfESVT3rhE5qgsz7zoWBdKe/RxLKcb6zQmHMZML6SjSh0NrMG86lsH4dQ=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.87", "", { "os": "linux", "cpu": "arm64" }, "sha512-he8o1h5M6oskRJ7wE+xKJgmWnv5ZwN6gB3M/Z+SeHtOMPa5cZmi3TefTjG54llEgFfx0F9RcqHof7TJ/GNxRkw=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.88", "", { "os": "linux", "cpu": "arm64" }, "sha512-jfcU/Sw8re3aWWb9cQ4OXmVNp/pchu6lgDRqvfy0EKTpzd7CNIu6a0xm+rcUKiPO7BrTrwtumT5/jZWWgCdHlg=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.87", "", { "os": "linux", "cpu": "x64" }, "sha512-aiUwjPlH4yDcB8/6YDKSmMkaoGAAltL0Xo0AzXyAtJXWK5tkCSaYjEVwzJ/rYRkr4Magnad+Mjth4AQUWdR2AA=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.88", "", { "os": "linux", "cpu": "x64" }, "sha512-nyfilOYLu6XWRlPl1R0Y6WzdL+jVdIFnwShBWcZL+QC5HiJnQc6LKy5yX8uv0fVbY5xs1wBvlHVeUj1UwFQyFQ=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.87", "", { "os": "win32", "cpu": "arm64" }, "sha512-cmP0pOyREjWGniHqbDmaMY7U+1AyagrD8VseJbU0cGpNgVpG2/gbrJUGdfdLB0SNb+mzLdx6SOjdxtrElwRCQA=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.88", "", { "os": "win32", "cpu": "arm64" }, "sha512-jv/dQwcku7YZ4lNnYjivVvjPwTfDfzGfcplUqHxmirnv1Q1pZL1qS5wH1PV6RhAKN779vHTvnYMD4OgHWzqVaA=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.87", "", { "os": "win32", "cpu": "x64" }, "sha512-N2GErAAP8iODf2RPp86pilPaVKiD6G4pkpZL5nLGbKsl0bndrVTpSqZcn8+/nQwFZDPD/AsiRTYNOfWOblhzOw=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.88", "", { "os": "win32", "cpu": "x64" }, "sha512-saGvsQqwL8H7B0VBCQ+szMCKh9WIfTebOR8cwPa2+DR+1FnrEG2I4kiikoj4hfYfRMX18A0A11vQxSh3vvy8Ig=="], - "@opentui/solid": ["@opentui/solid@0.1.87", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.87", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-lRT9t30l8+FtgOjjWJcdb2MT6hP8/RKqwGgYwTI7fXrOqdhxxwdP2SM+rH2l3suHeASheiTdlvPAo230iUcsvg=="], + "@opentui/solid": ["@opentui/solid@0.1.88", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.88", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-hAqMBk3u/MnUapOmRPdMZinXPOFC+5ccmW1rEQRf9HpShRlZfyg9/u+wUI5rUavyeNFtka92Mtjf/N4AKQpwuA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -1966,10 +1967,14 @@ "@tanstack/directive-functions-plugin": ["@tanstack/directive-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/router-utils": "1.133.19", "babel-dead-code-elimination": "^1.0.10", "pathe": "^2.0.3", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "vite": ">=6.0.0 || >=7.0.0" } }, "sha512-J3oawV8uBRBbPoLgMdyHt+LxzTNuWRKNJJuCLWsm/yq6v0IQSvIVCgfD2+liIiSnDPxGZ8ExduPXy8IzS70eXw=="], + "@tanstack/query-core": ["@tanstack/query-core@5.91.2", "", {}, "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw=="], + "@tanstack/router-utils": ["@tanstack/router-utils@1.133.19", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.5", "@babel/preset-typescript": "^7.27.1", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-WEp5D2gPxvlLDRXwD/fV7RXjYtqaqJNXKB/L6OyZEbT+9BG/Ib2d7oG9GSUZNNMGPGYAlhBUOi3xutySsk6rxA=="], "@tanstack/server-functions-plugin": ["@tanstack/server-functions-plugin@1.134.5", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@tanstack/directive-functions-plugin": "1.134.5", "babel-dead-code-elimination": "^1.0.9", "tiny-invariant": "^1.3.3" } }, "sha512-2sWxq70T+dOEUlE3sHlXjEPhaFZfdPYlWTSkHchWXrFGw2YOAa+hzD6L9wHMjGDQezYd03ue8tQlHG+9Jzbzgw=="], + "@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="], + "@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], "@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="], @@ -5198,8 +5203,6 @@ "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], - "@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="], - "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], "@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], diff --git a/nix/hashes.json b/nix/hashes.json index 8f48d1aabae..53b17622efc 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-P0RJfQF8APTYVGP6hLJRrOkRSl5nVDNxdcGcZECPPJE=", - "aarch64-linux": "sha256-ZtMjTcd35X3JhJIdn3DilFsp7i/IZIcNaKZFnSzW/nk=", - "aarch64-darwin": "sha256-Uw/okFDRxxKQMfEsj8MXuHyhpugxZGgIKtu89Getlz8=", - "x86_64-darwin": "sha256-ZySIgT1HbWZWnaQ0W0eURKC43BTupRmmply92JDFPWA=" + "x86_64-linux": "sha256-u+uZX7mhtm5eywGybB7/MjBMG2xl4Ve9VG33AAFgNno=", + "aarch64-linux": "sha256-pc1Xhd2bkwNohGMtzRnEuS5ZN1qWhJncYhNVAXega1g=", + "aarch64-darwin": "sha256-A5qUpqgm9ZFvWVhn/WdiX4lVs4ihbAclJDvCFAmx5Wg=", + "x86_64-darwin": "sha256-ECLrMGE51AlYJ4JKDtziDKxhyK7WLt8R+8RVFdXH1WU=" } } diff --git a/packages/app/e2e/terminal/terminal-tabs.spec.ts b/packages/app/e2e/terminal/terminal-tabs.spec.ts index ca1f7eee8b7..6b6fa4c62b4 100644 --- a/packages/app/e2e/terminal/terminal-tabs.spec.ts +++ b/packages/app/e2e/terminal/terminal-tabs.spec.ts @@ -1,7 +1,7 @@ import type { Page } from "@playwright/test" import { runTerminal, waitTerminalReady } from "../actions" import { test, expect } from "../fixtures" -import { terminalSelector } from "../selectors" +import { dropdownMenuContentSelector, terminalSelector } from "../selectors" import { terminalToggleKey, workspacePersistKey } from "../utils" type State = { @@ -130,3 +130,39 @@ test("closing the active terminal tab falls back to the previous tab", async ({ .toEqual({ count: 1, first: true }) }) }) + +test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => { + await withProject(async ({ directory, gotoSession }) => { + const key = workspacePersistKey(directory, "terminal") + const rename = `E2E term ${Date.now()}` + const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first() + + await gotoSession() + await open(page) + + await expect(tab).toContainText(/Terminal 1/) + await tab.click({ button: "right" }) + + const menu = page.locator(dropdownMenuContentSelector).first() + await expect(menu).toBeVisible() + await menu.getByRole("menuitem", { name: /^Rename$/i }).click() + await expect(menu).toHaveCount(0) + + const input = page.locator('#terminal-panel input[type="text"]').first() + await expect(input).toBeVisible() + await input.fill(rename) + await input.press("Enter") + + await expect(input).toHaveCount(0) + await expect(tab).toContainText(rename) + await expect + .poll( + async () => { + const state = await store(page, key) + return state?.all[0]?.title + }, + { timeout: 5_000 }, + ) + .toBe(rename) + }) +}) diff --git a/packages/app/package.json b/packages/app/package.json index 545d3130980..8181825c060 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.2.27", + "version": "1.3.0", "description": "", "type": "module", "exports": { @@ -54,6 +54,7 @@ "@solid-primitives/websocket": "1.3.1", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", + "@tanstack/solid-query": "5.91.4", "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", "effect": "catalog:", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 9a282bbb708..5247c951d32 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -9,6 +9,7 @@ import { Splash } from "@opencode-ai/ui/logo" import { ThemeProvider } from "@opencode-ai/ui/theme" import { MetaProvider } from "@solidjs/meta" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" +import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { type Duration, Effect } from "effect" import { type Component, @@ -81,6 +82,11 @@ function MarkedProviderWithNativeParser(props: ParentProps) { return {props.children} } +function QueryProvider(props: ParentProps) { + const client = new QueryClient() + return {props.children} +} + function AppShellProviders(props: ParentProps) { return ( @@ -136,11 +142,13 @@ export function AppBaseProviders(props: ParentProps) { }> - - - {props.children} - - + + + + {props.children} + + + diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index e4fe9e7c4ed..734958dd589 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -12,10 +12,9 @@ import { showToast } from "@opencode-ai/ui/toast" import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" -import { useLanguage } from "@/context/language" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" -import { DialogSelectModel } from "./dialog-select-model" +import { useLanguage } from "@/context/language" import { DialogSelectProvider } from "./dialog-select-provider" export function DialogConnectProvider(props: { provider: string }) { diff --git a/packages/app/src/components/dialog-custom-provider-form.ts b/packages/app/src/components/dialog-custom-provider-form.ts index 92d235c3bcc..e26dcb09710 100644 --- a/packages/app/src/components/dialog-custom-provider-form.ts +++ b/packages/app/src/components/dialog-custom-provider-form.ts @@ -34,7 +34,6 @@ export type FormState = { apiKey: string models: ModelRow[] headers: HeaderRow[] - saving: boolean err: { providerID?: string name?: string diff --git a/packages/app/src/components/dialog-custom-provider.test.ts b/packages/app/src/components/dialog-custom-provider.test.ts index 8cfd78ebeb3..07dd26ecd67 100644 --- a/packages/app/src/components/dialog-custom-provider.test.ts +++ b/packages/app/src/components/dialog-custom-provider.test.ts @@ -16,7 +16,6 @@ describe("validateCustomProvider", () => { { row: "h0", key: " X-Test ", value: " enabled ", err: {} }, { row: "h1", key: "", value: "", err: {} }, ], - saving: false, err: {}, }, t, @@ -60,7 +59,6 @@ describe("validateCustomProvider", () => { { row: "h0", key: "Authorization", value: "one", err: {} }, { row: "h1", key: "authorization", value: "two", err: {} }, ], - saving: false, err: {}, }, t, diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 4d220a0b191..53b66fb451d 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { IconButton } from "@opencode-ai/ui/icon-button" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { useMutation } from "@tanstack/solid-query" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" import { batch, For } from "solid-js" @@ -31,7 +32,6 @@ export function DialogCustomProvider(props: Props) { apiKey: "", models: [modelRow()], headers: [headerRow()], - saving: false, err: {}, }) @@ -116,48 +116,49 @@ export function DialogCustomProvider(props: Props) { return output.result } - const save = async (e: SubmitEvent) => { - e.preventDefault() - if (form.saving) return - - const result = validate() - if (!result) return + const saveMutation = useMutation(() => ({ + mutationFn: async (result: NonNullable>) => { + const disabledProviders = globalSync.data.config.disabled_providers ?? [] + const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) - setForm("saving", true) - - const disabledProviders = globalSync.data.config.disabled_providers ?? [] - const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) - - const auth = result.key - ? globalSDK.client.auth.set({ + if (result.key) { + await globalSDK.client.auth.set({ providerID: result.providerID, auth: { type: "api", key: result.key, }, }) - : Promise.resolve() + } - auth - .then(() => - globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }), - ) - .then(() => { - dialog.close() - showToast({ - variant: "success", - icon: "circle-check", - title: language.t("provider.connect.toast.connected.title", { provider: result.name }), - description: language.t("provider.connect.toast.connected.description", { provider: result.name }), - }) + await globalSync.updateConfig({ + provider: { [result.providerID]: result.config }, + disabled_providers: nextDisabled, }) - .catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) - }) - .finally(() => { - setForm("saving", false) + return result + }, + onSuccess: (result) => { + dialog.close() + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("provider.connect.toast.connected.title", { provider: result.name }), + description: language.t("provider.connect.toast.connected.description", { provider: result.name }), }) + }, + onError: (err) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }, + })) + + const save = (e: SubmitEvent) => { + e.preventDefault() + if (saveMutation.isPending) return + + const result = validate() + if (!result) return + saveMutation.mutate(result) } return ( @@ -312,8 +313,14 @@ export function DialogCustomProvider(props: Props) { - diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index ec0793c540e..eb962f47eb0 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -2,6 +2,7 @@ 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 { useMutation } from "@tanstack/solid-query" import { Icon } from "@opencode-ai/ui/icon" import { createMemo, For, Show } from "solid-js" import { createStore } from "solid-js/store" @@ -28,7 +29,6 @@ export function DialogEditProject(props: { project: LocalProject }) { color: props.project.icon?.color || "pink", iconUrl: props.project.icon?.override || "", startup: props.project.commands?.start ?? "", - saving: false, dragOver: false, iconHover: false, }) @@ -71,38 +71,37 @@ export function DialogEditProject(props: { project: LocalProject }) { setStore("iconUrl", "") } - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - - await Promise.resolve() - .then(async () => { - setStore("saving", true) - const name = store.name.trim() === folderName() ? "" : store.name.trim() - const start = store.startup.trim() + const saveMutation = useMutation(() => ({ + mutationFn: async () => { + const name = store.name.trim() === folderName() ? "" : store.name.trim() + const start = store.startup.trim() - if (props.project.id && props.project.id !== "global") { - await globalSDK.client.project.update({ - projectID: props.project.id, - directory: props.project.worktree, - name, - icon: { color: store.color, override: store.iconUrl }, - commands: { start }, - }) - globalSync.project.icon(props.project.worktree, store.iconUrl || undefined) - dialog.close() - return - } - - globalSync.project.meta(props.project.worktree, { + if (props.project.id && props.project.id !== "global") { + await globalSDK.client.project.update({ + projectID: props.project.id, + directory: props.project.worktree, name, - icon: { color: store.color, override: store.iconUrl || undefined }, - commands: { start: start || undefined }, + icon: { color: store.color, override: store.iconUrl }, + commands: { start }, }) + globalSync.project.icon(props.project.worktree, store.iconUrl || undefined) dialog.close() + return + } + + globalSync.project.meta(props.project.worktree, { + name, + icon: { color: store.color, override: store.iconUrl || undefined }, + commands: { start: start || undefined }, }) - .finally(() => { - setStore("saving", false) - }) + dialog.close() + }, + })) + + function handleSubmit(e: SubmitEvent) { + e.preventDefault() + if (saveMutation.isPending) return + saveMutation.mutate() } return ( @@ -246,8 +245,8 @@ export function DialogEditProject(props: { project: LocalProject }) { - diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index f8913eee4fb..fafba6168cc 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -1,4 +1,5 @@ -import { Component, createMemo, createSignal, Show } from "solid-js" +import { useMutation } from "@tanstack/solid-query" +import { Component, createMemo, Show } from "solid-js" import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" import { Dialog } from "@opencode-ai/ui/dialog" @@ -17,7 +18,6 @@ export const DialogSelectMcp: Component = () => { const sync = useSync() const sdk = useSDK() const language = useLanguage() - const [loading, setLoading] = createSignal(null) const items = createMemo(() => Object.entries(sync.data.mcp ?? {}) @@ -25,10 +25,8 @@ export const DialogSelectMcp: Component = () => { .sort((a, b) => a.name.localeCompare(b.name)), ) - const toggle = async (name: string) => { - if (loading()) return - setLoading(name) - try { + const toggle = useMutation(() => ({ + mutationFn: async (name: string) => { const status = sync.data.mcp[name] if (status?.status === "connected") { await sdk.client.mcp.disconnect({ name }) @@ -38,10 +36,8 @@ export const DialogSelectMcp: Component = () => { const result = await sdk.client.mcp.status() if (result.data) sync.set("mcp", result.data) - } finally { - setLoading(null) - } - } + }, + })) const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) const totalCount = createMemo(() => items().length) @@ -59,7 +55,8 @@ export const DialogSelectMcp: Component = () => { filterKeys={["name", "status"]} sortBy={(a, b) => a.name.localeCompare(b.name)} onSelect={(x) => { - if (x) toggle(x.name) + if (!x || toggle.isPending) return + toggle.mutate(x.name) }} > {(i) => { @@ -83,7 +80,7 @@ export const DialogSelectMcp: Component = () => { {statusLabel()} - + {language.t("common.loading.ellipsis")} @@ -92,7 +89,14 @@ export const DialogSelectMcp: Component = () => {
e.stopPropagation()}> - toggle(i.name)} /> + { + if (toggle.isPending) return + toggle.mutate(i.name) + }} + />
) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index f8d14cbb943..ca4c42a3769 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -6,6 +6,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { List } from "@opencode-ai/ui/list" import { TextField } from "@opencode-ai/ui/text-field" +import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" import { useNavigate } from "@solidjs/router" import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" @@ -186,7 +187,6 @@ export function DialogSelectServer() { name: "", username: DEFAULT_USERNAME, password: "", - adding: false, error: "", showForm: false, status: undefined as boolean | undefined, @@ -198,7 +198,6 @@ export function DialogSelectServer() { username: "", password: "", error: "", - busy: false, status: undefined as boolean | undefined, }, }) @@ -209,7 +208,6 @@ export function DialogSelectServer() { name: "", username: DEFAULT_USERNAME, password: "", - adding: false, error: "", showForm: false, status: undefined, @@ -224,10 +222,78 @@ export function DialogSelectServer() { password: "", error: "", status: undefined, - busy: false, }) } + const addMutation = useMutation(() => ({ + mutationFn: async (value: string) => { + const normalized = normalizeServerUrl(value) + if (!normalized) { + resetAdd() + return + } + + const conn: ServerConnection.Http = { + type: "http", + http: { url: normalized }, + } + if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim() + if (store.addServer.password) conn.http.password = store.addServer.password + if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username + const result = await checkServerHealth(conn.http) + if (!result.healthy) { + setStore("addServer", { error: language.t("dialog.server.add.error") }) + return + } + + resetAdd() + await select(conn, true) + }, + })) + + const editMutation = useMutation(() => ({ + mutationFn: async (input: { original: ServerConnection.Any; value: string }) => { + if (input.original.type !== "http") return + const normalized = normalizeServerUrl(input.value) + if (!normalized) { + resetEdit() + return + } + + const name = store.editServer.name.trim() || undefined + const username = store.editServer.username || undefined + const password = store.editServer.password || undefined + const existingName = input.original.displayName + if ( + normalized === input.original.http.url && + name === existingName && + username === input.original.http.username && + password === input.original.http.password + ) { + resetEdit() + return + } + + const conn: ServerConnection.Http = { + type: "http", + displayName: name, + http: { url: normalized, username, password }, + } + const result = await checkServerHealth(conn.http) + if (!result.healthy) { + setStore("editServer", { error: language.t("dialog.server.add.error") }) + return + } + if (normalized === input.original.http.url) { + server.add(conn) + } else { + replaceServer(input.original, conn) + } + + resetEdit() + }, + })) + const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => { const active = server.key const newConn = server.add(next) @@ -296,7 +362,7 @@ export function DialogSelectServer() { } const handleAddChange = (value: string) => { - if (store.addServer.adding) return + if (addMutation.isPending) return setStore("addServer", { url: value, error: "" }) void previewStatus(value, store.addServer.username, store.addServer.password, (next) => setStore("addServer", { status: next }), @@ -304,12 +370,12 @@ export function DialogSelectServer() { } const handleAddNameChange = (value: string) => { - if (store.addServer.adding) return + if (addMutation.isPending) return setStore("addServer", { name: value, error: "" }) } const handleAddUsernameChange = (value: string) => { - if (store.addServer.adding) return + if (addMutation.isPending) return setStore("addServer", { username: value, error: "" }) void previewStatus(store.addServer.url, value, store.addServer.password, (next) => setStore("addServer", { status: next }), @@ -317,7 +383,7 @@ export function DialogSelectServer() { } const handleAddPasswordChange = (value: string) => { - if (store.addServer.adding) return + if (addMutation.isPending) return setStore("addServer", { password: value, error: "" }) void previewStatus(store.addServer.url, store.addServer.username, value, (next) => setStore("addServer", { status: next }), @@ -325,7 +391,7 @@ export function DialogSelectServer() { } const handleEditChange = (value: string) => { - if (store.editServer.busy) return + if (editMutation.isPending) return setStore("editServer", { value, error: "" }) void previewStatus(value, store.editServer.username, store.editServer.password, (next) => setStore("editServer", { status: next }), @@ -333,12 +399,12 @@ export function DialogSelectServer() { } const handleEditNameChange = (value: string) => { - if (store.editServer.busy) return + if (editMutation.isPending) return setStore("editServer", { name: value, error: "" }) } const handleEditUsernameChange = (value: string) => { - if (store.editServer.busy) return + if (editMutation.isPending) return setStore("editServer", { username: value, error: "" }) void previewStatus(store.editServer.value, value, store.editServer.password, (next) => setStore("editServer", { status: next }), @@ -346,85 +412,13 @@ export function DialogSelectServer() { } const handleEditPasswordChange = (value: string) => { - if (store.editServer.busy) return + if (editMutation.isPending) return setStore("editServer", { password: value, error: "" }) void previewStatus(store.editServer.value, store.editServer.username, value, (next) => setStore("editServer", { status: next }), ) } - async function handleAdd(value: string) { - if (store.addServer.adding) return - const normalized = normalizeServerUrl(value) - if (!normalized) { - resetAdd() - return - } - - setStore("addServer", { adding: true, error: "" }) - - const conn: ServerConnection.Http = { - type: "http", - http: { url: normalized }, - } - if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim() - if (store.addServer.password) conn.http.password = store.addServer.password - if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username - const result = await checkServerHealth(conn.http) - setStore("addServer", { adding: false }) - if (!result.healthy) { - setStore("addServer", { error: language.t("dialog.server.add.error") }) - return - } - - resetAdd() - await select(conn, true) - } - - async function handleEdit(original: ServerConnection.Any, value: string) { - if (store.editServer.busy || original.type !== "http") return - const normalized = normalizeServerUrl(value) - if (!normalized) { - resetEdit() - return - } - - const name = store.editServer.name.trim() || undefined - const username = store.editServer.username || undefined - const password = store.editServer.password || undefined - const existingName = original.displayName - if ( - normalized === original.http.url && - name === existingName && - username === original.http.username && - password === original.http.password - ) { - resetEdit() - return - } - - setStore("editServer", { busy: true, error: "" }) - - const conn: ServerConnection.Http = { - type: "http", - displayName: name, - http: { url: normalized, username, password }, - } - const result = await checkServerHealth(conn.http) - setStore("editServer", { busy: false }) - if (!result.healthy) { - setStore("editServer", { error: language.t("dialog.server.add.error") }) - return - } - if (normalized === original.http.url) { - server.add(conn) - } else { - replaceServer(original, conn) - } - - resetEdit() - } - const mode = createMemo<"list" | "add" | "edit">(() => { if (store.editServer.id) return "edit" if (store.addServer.showForm) return "add" @@ -464,23 +458,26 @@ export function DialogSelectServer() { password: conn.http.password ?? "", error: "", status: store.status[ServerConnection.key(conn)]?.healthy, - busy: false, }) } const submitForm = () => { if (mode() === "add") { - void handleAdd(store.addServer.url) + if (addMutation.isPending) return + setStore("addServer", { error: "" }) + addMutation.mutate(store.addServer.url) return } const original = editing() if (!original) return - void handleEdit(original, store.editServer.value) + if (editMutation.isPending) return + setStore("editServer", { error: "" }) + editMutation.mutate({ original, value: store.editServer.value }) } const isFormMode = createMemo(() => mode() !== "list") const isAddMode = createMemo(() => mode() === "add") - const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy)) + const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending)) const formTitle = createMemo(() => { if (!isFormMode()) return language.t("dialog.server.title") diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx index 89895874250..ba697f91af6 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx @@ -24,6 +24,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => }) let input: HTMLInputElement | undefined let blurFrame: number | undefined + let editRequested = false const isDefaultTitle = () => { const number = props.terminal.titleNumber @@ -168,8 +169,14 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => left: `${store.menuPosition.x}px`, top: `${store.menuPosition.y}px`, }} + onCloseAutoFocus={(e) => { + if (!editRequested) return + e.preventDefault() + editRequested = false + requestAnimationFrame(() => edit()) + }} > - + (editRequested = true)}> {language.t("common.rename")} diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 063205f0c30..464522443f1 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -4,6 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { Popover } from "@opencode-ai/ui/popover" import { Switch } from "@opencode-ai/ui/switch" import { Tabs } from "@opencode-ai/ui/tabs" +import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" import { useNavigate } from "@solidjs/router" import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js" @@ -130,41 +131,30 @@ const useDefaultServerKey = ( } } -const useMcpToggle = (input: { - sync: ReturnType - sdk: ReturnType - language: ReturnType -}) => { - const [loading, setLoading] = createSignal(null) - - const toggle = async (name: string) => { - if (loading()) return - setLoading(name) +const useMcpToggleMutation = () => { + const sync = useSync() + const sdk = useSDK() + const language = useLanguage() - try { - const status = input.sync.data.mcp[name] - await (status?.status === "connected" - ? input.sdk.client.mcp.disconnect({ name }) - : input.sdk.client.mcp.connect({ name })) - const result = await input.sdk.client.mcp.status() - if (result.data) input.sync.set("mcp", result.data) - } catch (err) { + return useMutation(() => ({ + mutationFn: async (name: string) => { + const status = sync.data.mcp[name] + await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name })) + const result = await sdk.client.mcp.status() + if (result.data) sync.set("mcp", result.data) + }, + onError: (err) => { showToast({ variant: "error", - title: input.language.t("common.requestFailed"), + title: language.t("common.requestFailed"), description: err instanceof Error ? err.message : String(err), }) - } finally { - setLoading(null) - } - } - - return { loading, toggle } + }, + })) } export function StatusPopover() { const sync = useSync() - const sdk = useSDK() const server = useServer() const platform = usePlatform() const dialog = useDialog() @@ -181,7 +171,7 @@ export function StatusPopover() { }) const health = useServerHealth(servers) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) - const mcp = useMcpToggle({ sync, sdk, language }) + const toggleMcp = useMcpToggleMutation() const defaultServer = useDefaultServerKey(platform.getDefaultServer) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status @@ -337,8 +327,11 @@ export function StatusPopover() { diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 72caed40ad9..8efd9d3bc9f 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -23,6 +23,8 @@ export const dict = { "command.sidebar.toggle": "Toggle sidebar", "command.project.open": "Open project", + "command.project.previous": "Previous project", + "command.project.next": "Next project", "command.provider.connect": "Connect provider", "command.server.switch": "Switch server", "command.settings.open": "Open settings", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 8e2248469de..0c10cc89bce 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -936,6 +936,26 @@ export default function Layout(props: ParentProps) { navigateToSession(session) } + function navigateProjectByOffset(offset: number) { + const projects = layout.projects.list() + if (projects.length === 0) return + + const current = currentProject()?.worktree + const fallback = currentDir() ? projectRoot(currentDir()) : undefined + const active = current ?? fallback + const index = active ? projects.findIndex((project) => project.worktree === active) : -1 + + const target = + index === -1 + ? offset > 0 + ? projects[0] + : projects[projects.length - 1] + : projects[(index + offset + projects.length) % projects.length] + if (!target) return + + openProject(target.worktree) + } + function navigateSessionByUnseen(offset: number) { const sessions = currentSessions() if (sessions.length === 0) return @@ -1002,6 +1022,20 @@ export default function Layout(props: ParentProps) { keybind: "mod+o", onSelect: () => chooseProject(), }, + { + id: "project.previous", + title: language.t("command.project.previous"), + category: language.t("command.category.project"), + keybind: "mod+alt+arrowup", + onSelect: () => navigateProjectByOffset(-1), + }, + { + id: "project.next", + title: language.t("command.project.next"), + category: language.t("command.category.project"), + keybind: "mod+alt+arrowdown", + onSelect: () => navigateProjectByOffset(1), + }, { id: "provider.connect", title: language.t("command.provider.connect"), @@ -2334,14 +2368,12 @@ export default function Layout(props: ParentProps) { size={layout.sidebar.width()} min={244} max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64} - collapseThreshold={244} onResize={(w) => { setState("sizing", true) if (sizet !== undefined) clearTimeout(sizet) sizet = window.setTimeout(() => setState("sizing", false), 120) layout.sidebar.resize(w) }} - onCollapse={layout.sidebar.close} /> diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 6d29170081a..428826f6ad9 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,5 +1,6 @@ import type { Project, UserMessage } from "@opencode-ai/sdk/v2" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useMutation } from "@tanstack/solid-query" import { batch, onCleanup, @@ -327,10 +328,7 @@ export default function Page() { }) const [ui, setUi] = createStore({ - git: false, pendingMessage: undefined as string | undefined, - restoring: undefined as string | undefined, - reverting: false, reviewSnap: false, scrollGesture: 0, scroll: { @@ -506,7 +504,6 @@ export default function Page() { const [followup, setFollowup] = createStore({ items: {} as Record, - sending: {} as Record, failed: {} as Record, paused: {} as Record, edit: {} as Record< @@ -644,25 +641,24 @@ export default function Page() { globalSync.set("project", [...list, next]) } - function initGit() { - if (ui.git) return - setUi("git", true) - void sdk.client.project - .initGit() - .then((x) => { - if (!x.data) return - upsert(x.data) - }) - .catch((err) => { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: formatServerError(err, language.t), - }) - }) - .finally(() => { - setUi("git", false) + const gitMutation = useMutation(() => ({ + mutationFn: () => sdk.client.project.initGit(), + onSuccess: (x) => { + if (!x.data) return + upsert(x.data) + }, + onError: (err) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: formatServerError(err, language.t), }) + }, + })) + + function initGit() { + if (gitMutation.isPending) return + gitMutation.mutate() } let inputRef!: HTMLDivElement @@ -961,8 +957,8 @@ export default function Page() { {language.t("session.review.noVcs.createGit.description")} - @@ -1379,10 +1375,40 @@ export default function Page() { return followup.edit[id] }) + const followupMutation = useMutation(() => ({ + mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => { + const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id) + if (!item) return + + if (input.manual) setFollowup("paused", input.sessionID, undefined) + setFollowup("failed", input.sessionID, undefined) + + const ok = await sendFollowupDraft({ + client: sdk.client, + sync, + globalSync, + draft: item, + optimisticBusy: item.sessionDirectory === sdk.directory, + }).catch((err) => { + setFollowup("failed", input.sessionID, input.id) + fail(err) + return false + }) + if (!ok) return + + setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id)) + if (input.manual) resumeScroll() + }, + })) + + const followupBusy = (sessionID: string) => + followupMutation.isPending && followupMutation.variables?.sessionID === sessionID + const sendingFollowup = createMemo(() => { const id = params.id if (!id) return - return followup.sending[id] + if (!followupBusy(id)) return + return followupMutation.variables?.id }) const queueEnabled = createMemo(() => { @@ -1422,37 +1448,15 @@ export default function Page() { const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id) if (!item) return Promise.resolve() - if (followup.sending[sessionID]) return Promise.resolve() - - if (opts?.manual) setFollowup("paused", sessionID, undefined) - setFollowup("sending", sessionID, id) - setFollowup("failed", sessionID, undefined) - - return sendFollowupDraft({ - client: sdk.client, - sync, - globalSync, - draft: item, - optimisticBusy: item.sessionDirectory === sdk.directory, - }) - .then((ok) => { - if (ok === false) return - setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id)) - if (opts?.manual) resumeScroll() - }) - .catch((err) => { - setFollowup("failed", sessionID, id) - fail(err) - }) - .finally(() => { - setFollowup("sending", sessionID, (value) => (value === id ? undefined : value)) - }) + if (followupBusy(sessionID)) return Promise.resolve() + + return followupMutation.mutateAsync({ sessionID, id, manual: opts?.manual }) } const editFollowup = (id: string) => { const sessionID = params.id if (!sessionID) return - if (followup.sending[sessionID]) return + if (followupBusy(sessionID)) return const item = queuedFollowups().find((entry) => entry.id === id) if (!item) return @@ -1475,6 +1479,74 @@ export default function Page() { const halt = (sessionID: string) => busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve() + const revertMutation = useMutation(() => ({ + mutationFn: async (input: { sessionID: string; messageID: string }) => { + const prev = prompt.current().slice() + const last = info()?.revert + const value = draft(input.messageID) + batch(() => { + roll(input.sessionID, { messageID: input.messageID }) + prompt.set(value) + }) + await halt(input.sessionID) + .then(() => sdk.client.session.revert(input)) + .then((result) => { + if (result.data) merge(result.data) + }) + .catch((err) => { + batch(() => { + roll(input.sessionID, last) + prompt.set(prev) + }) + fail(err) + }) + }, + })) + + const restoreMutation = useMutation(() => ({ + mutationFn: async (id: string) => { + const sessionID = params.id + if (!sessionID) return + + const next = userMessages().find((item) => item.id > id) + const prev = prompt.current().slice() + const last = info()?.revert + + batch(() => { + roll(sessionID, next ? { messageID: next.id } : undefined) + if (next) { + prompt.set(draft(next.id)) + return + } + prompt.reset() + }) + + const task = !next + ? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID })) + : halt(sessionID).then(() => + sdk.client.session.revert({ + sessionID, + messageID: next.id, + }), + ) + + await task + .then((result) => { + if (result.data) merge(result.data) + }) + .catch((err) => { + batch(() => { + roll(sessionID, last) + prompt.set(prev) + }) + fail(err) + }) + }, + })) + + const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending) + const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined)) + const fork = (input: { sessionID: string; messageID: string }) => { const value = draft(input.messageID) const dir = base64Encode(sdk.directory) @@ -1496,77 +1568,13 @@ export default function Page() { } const revert = (input: { sessionID: string; messageID: string }) => { - if (ui.reverting || ui.restoring) return - const prev = prompt.current().slice() - const last = info()?.revert - const value = draft(input.messageID) - batch(() => { - setUi("reverting", true) - roll(input.sessionID, { messageID: input.messageID }) - prompt.set(value) - }) - return halt(input.sessionID) - .then(() => sdk.client.session.revert(input)) - .then((result) => { - if (result.data) merge(result.data) - }) - .catch((err) => { - batch(() => { - roll(input.sessionID, last) - prompt.set(prev) - }) - fail(err) - }) - .finally(() => { - setUi("reverting", false) - }) + if (reverting()) return + return revertMutation.mutateAsync(input) } const restore = (id: string) => { - const sessionID = params.id - if (!sessionID || ui.restoring || ui.reverting) return - - const next = userMessages().find((item) => item.id > id) - const prev = prompt.current().slice() - const last = info()?.revert - - batch(() => { - setUi("restoring", id) - setUi("reverting", true) - roll(sessionID, next ? { messageID: next.id } : undefined) - if (next) { - prompt.set(draft(next.id)) - return - } - prompt.reset() - }) - - const task = !next - ? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID })) - : halt(sessionID).then(() => - sdk.client.session.revert({ - sessionID, - messageID: next.id, - }), - ) - - return task - .then((result) => { - if (result.data) merge(result.data) - }) - .catch((err) => { - batch(() => { - roll(sessionID, last) - prompt.set(prev) - }) - fail(err) - }) - .finally(() => { - batch(() => { - setUi("restoring", (value) => (value === id ? undefined : value)) - setUi("reverting", false) - }) - }) + if (!params.id || reverting()) return + return restoreMutation.mutateAsync(id) } const rolled = createMemo(() => { @@ -1585,7 +1593,7 @@ export default function Page() { const item = queuedFollowups()[0] if (!item) return - if (followup.sending[sessionID]) return + if (followupBusy(sessionID)) return if (followup.failed[sessionID] === item.id) return if (followup.paused[sessionID]) return if (composer.blocked()) return @@ -1780,8 +1788,8 @@ export default function Page() { rolled().length > 0 ? { items: rolled(), - restoring: ui.restoring, - disabled: ui.reverting, + restoring: restoring(), + disabled: reverting(), onRestore: restore, } : undefined diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index b66c27579a9..7ba07b15d0c 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -1,5 +1,6 @@ import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js" import { createStore } from "solid-js/store" +import { useMutation } from "@tanstack/solid-query" import { Button } from "@opencode-ai/ui/button" import { DockPrompt } from "@opencode-ai/ui/dock-prompt" import { Icon } from "@opencode-ai/ui/icon" @@ -24,7 +25,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit custom: cached?.custom ?? ([] as string[]), customOn: cached?.customOn ?? ([] as boolean[]), editing: false, - sending: false, }) let root: HTMLDivElement | undefined @@ -126,36 +126,40 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit showToast({ title: language.t("common.requestFailed"), description: message }) } - const reply = async (answers: QuestionAnswer[]) => { - if (store.sending) return - - props.onSubmit() - setStore("sending", true) - try { - await sdk.client.question.reply({ requestID: props.request.id, answers }) + const replyMutation = useMutation(() => ({ + mutationFn: (answers: QuestionAnswer[]) => sdk.client.question.reply({ requestID: props.request.id, answers }), + onMutate: () => { + props.onSubmit() + }, + onSuccess: () => { replied = true cache.delete(props.request.id) - } catch (err) { - fail(err) - } finally { - setStore("sending", false) - } + }, + onError: fail, + })) + + const rejectMutation = useMutation(() => ({ + mutationFn: () => sdk.client.question.reject({ requestID: props.request.id }), + onMutate: () => { + props.onSubmit() + }, + onSuccess: () => { + replied = true + cache.delete(props.request.id) + }, + onError: fail, + })) + + const sending = createMemo(() => replyMutation.isPending || rejectMutation.isPending) + + const reply = async (answers: QuestionAnswer[]) => { + if (sending()) return + await replyMutation.mutateAsync(answers) } const reject = async () => { - if (store.sending) return - - props.onSubmit() - setStore("sending", true) - try { - await sdk.client.question.reject({ requestID: props.request.id }) - replied = true - cache.delete(props.request.id) - } catch (err) { - fail(err) - } finally { - setStore("sending", false) - } + if (sending()) return + await rejectMutation.mutateAsync() } const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) @@ -175,7 +179,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } const customToggle = () => { - if (store.sending) return + if (sending()) return if (!multi()) { setStore("customOn", store.tab, true) @@ -198,14 +202,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } const customOpen = () => { - if (store.sending) return + if (sending()) return if (!on()) setStore("customOn", store.tab, true) setStore("editing", true) customUpdate(input(), true) } const selectOption = (optIndex: number) => { - if (store.sending) return + if (sending()) return if (optIndex === options().length) { customOpen() @@ -227,7 +231,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } const next = () => { - if (store.sending) return + if (sending()) return if (store.editing) commitCustom() if (store.tab >= total() - 1) { @@ -240,14 +244,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } const back = () => { - if (store.sending) return + if (sending()) return if (store.tab <= 0) return setStore("tab", store.tab - 1) setStore("editing", false) } const jump = (tab: number) => { - if (store.sending) return + if (sending()) return setStore("tab", tab) setStore("editing", false) } @@ -270,7 +274,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit (store.answers[i()]?.length ?? 0) > 0 || (store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0) } - disabled={store.sending} + disabled={sending()} onClick={() => jump(i())} aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} /> @@ -281,16 +285,16 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } footer={ <> -
0}> - -
@@ -311,7 +315,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit data-picked={picked()} role={multi() ? "checkbox" : "radio"} aria-checked={picked()} - disabled={store.sending} + disabled={sending()} onClick={() => selectOption(i())} >