From bd9d7b3221ae144ff098c5fbb688edc75e755d3b Mon Sep 17 00:00:00 2001 From: Mathias Beugnon Date: Mon, 2 Feb 2026 06:10:53 +0100 Subject: [PATCH 01/64] fix: session title generation with OpenAI models. (#11678) --- packages/opencode/src/provider/transform.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index ded416e66d..c05bf75c46 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -658,11 +658,18 @@ export namespace ProviderTransform { } export function smallOptions(model: Provider.Model) { - if (model.providerID === "openai" || model.api.id.includes("gpt-5")) { - if (model.api.id.includes("5.")) { - return { reasoningEffort: "low" } + if ( + model.providerID === "openai" || + model.api.npm === "@ai-sdk/openai" || + model.api.npm === "@ai-sdk/github-copilot" + ) { + if (model.api.id.includes("gpt-5")) { + if (model.api.id.includes("5.")) { + return { store: false, reasoningEffort: "low" } + } + return { store: false, reasoningEffort: "minimal" } } - return { reasoningEffort: "minimal" } + return { store: false } } if (model.providerID === "google") { // gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget From f02499fa44ea3563fec3d0c456550b17dcef4ce5 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Mon, 2 Feb 2026 06:11:25 +0100 Subject: [PATCH 02/64] fix(opencode): give OPENCODE_CONFIG_CONTENT proper priority for setting config based on docs (#11670) --- packages/opencode/src/config/config.ts | 29 ++++++++++++++++---------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 98970ba392..b0164e8aa8 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -62,8 +62,14 @@ export namespace Config { export const state = Instance.state(async () => { const auth = await Auth.all() - // Load remote/well-known config first as the base layer (lowest precedence) - // This allows organizations to provide default configs that users can override + // Config loading order (low -> high precedence): https://opencode.ai/docs/config#precedence-order + // 1) Remote .well-known/opencode (org defaults) + // 2) Global config (~/.config/opencode/opencode.json{,c}) + // 3) Custom config (OPENCODE_CONFIG) + // 4) Project config (opencode.json{,c}) + // 5) .opencode directories (.opencode/agents/, .opencode/commands/, .opencode/plugins/, .opencode/opencode.json{,c}) + // 6) Inline config (OPENCODE_CONFIG_CONTENT) + // Managed config directory is enterprise-only and always overrides everything above. let result: Info = {} for (const [key, value] of Object.entries(auth)) { if (value.type === "wellknown") { @@ -85,16 +91,16 @@ export namespace Config { } } - // Global user config overrides remote config + // Global user config overrides remote config. result = mergeConfigConcatArrays(result, await global()) - // Custom config path overrides global + // Custom config path overrides global config. if (Flag.OPENCODE_CONFIG) { result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG)) log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) } - // Project config has highest precedence (overrides global and remote) + // Project config overrides global and remote config. if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { for (const file of ["opencode.jsonc", "opencode.json"]) { const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree) @@ -104,12 +110,6 @@ export namespace Config { } } - // Inline config content has highest precedence - if (Flag.OPENCODE_CONFIG_CONTENT) { - result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT)) - log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") - } - result.agent = result.agent || {} result.mode = result.mode || {} result.plugin = result.plugin || [] @@ -136,6 +136,7 @@ export namespace Config { )), ] + // .opencode directory config overrides (project and global) config sources. if (Flag.OPENCODE_CONFIG_DIR) { directories.push(Flag.OPENCODE_CONFIG_DIR) log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) @@ -163,6 +164,12 @@ export namespace Config { result.plugin.push(...(await loadPlugin(dir))) } + // Inline config content overrides all non-managed config sources. + if (Flag.OPENCODE_CONFIG_CONTENT) { + result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT)) + log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") + } + // Load managed config files last (highest priority) - enterprise admin-controlled // Kept separate from directories array to avoid write operations when installing plugins // which would fail on system directories requiring elevated permissions From cfbe9d329f50126fa6d7cca3c25e43c48f96adc3 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 2 Feb 2026 00:13:02 -0500 Subject: [PATCH 03/64] Revert "Use opentui OSC52 clipboard (#11718)" This reverts commit 8e985e0a75ca5f2cb859434fe82dee7ea81cb59f. --- bun.lock | 20 +++++++------- nix/hashes.json | 8 +++--- packages/opencode/package.json | 4 +-- packages/opencode/src/cli/cmd/tui/app.tsx | 1 - .../src/cli/cmd/tui/util/clipboard.ts | 26 +++++++++++-------- 5 files changed, 31 insertions(+), 28 deletions(-) diff --git a/bun.lock b/bun.lock index ace2735991..0857283634 100644 --- a/bun.lock +++ b/bun.lock @@ -298,8 +298,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.4", - "@opentui/core": "0.1.76", - "@opentui/solid": "0.1.76", + "@opentui/core": "0.1.75", + "@opentui/solid": "0.1.75", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -1227,21 +1227,21 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "@opentui/core": ["@opentui/core@0.1.76", "", { "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.76", "@opentui/core-darwin-x64": "0.1.76", "@opentui/core-linux-arm64": "0.1.76", "@opentui/core-linux-x64": "0.1.76", "@opentui/core-win32-arm64": "0.1.76", "@opentui/core-win32-x64": "0.1.76", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Y4f4KH6Mbj0J6+MorcvtHSeT+Lbs3YDPEQcTRTWsPOqWz3A0F5/+OPtZKho1EtLWQqJflCWdf/JQj5A3We3qRg=="], + "@opentui/core": ["@opentui/core@0.1.75", "", { "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.75", "@opentui/core-darwin-x64": "0.1.75", "@opentui/core-linux-arm64": "0.1.75", "@opentui/core-linux-x64": "0.1.75", "@opentui/core-win32-arm64": "0.1.75", "@opentui/core-win32-x64": "0.1.75", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8ARRZxSG+BXkJmEVtM2DQ4se7DAF1ZCKD07d+AklgTr2mxCzmdxxPbOwRzboSQ6FM7qGuTVPVbV4O2W9DpUmoA=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.76", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aRYNOPRKL6URovSPhRvXtBV7SqdmR7s6hmEBSdXiYo39AozTcvKviF8gJWXQATcKDEcOtRir6TsASzDq5Coheg=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.75", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gGaGZjkFpqcXJk6321JzhRl66pM2VxBlI470L8W4DQUW4S6iDT1R9L7awSzGB4Cn9toUl7DTV8BemaXZYXV4SA=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.76", "", { "os": "darwin", "cpu": "x64" }, "sha512-KFaRvVQ0Wr1PgaexUkF3KYt41pYmxGJW3otENeE6WDa/nXe2AElibPFRjqSEH54YrY5Q84SDI77/wGP4LZ/Wyg=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.75", "", { "os": "darwin", "cpu": "x64" }, "sha512-tPlvqQI0whZ76amHydpJs5kN+QeWAIcFbI8RAtlAo9baj2EbxTDC+JGwgb9Fnt0/YQx831humbtaNDhV2Jt1bw=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.76", "", { "os": "linux", "cpu": "arm64" }, "sha512-s7v+GDwavfieZg8xZV4V07fXFrHfFq4UZ2JpYFDUgNs9vFp+++WUjh3pfbfE+2ldbhcG2iOtuiV9aG1tVCbTEg=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.75", "", { "os": "linux", "cpu": "arm64" }, "sha512-nVxIQ4Hqf84uBergDpWiVzU6pzpjy6tqBHRQpySxZ2flkJ/U6/aMEizVrQ1jcgIdxZtvqWDETZhzxhG0yDx+cw=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.76", "", { "os": "linux", "cpu": "x64" }, "sha512-ugwuHpmvdKRHXKVsrC3zRYY6bg2JxVCzAQ1NOiWRLq3N3N4ha6BHAkHMCeHgR/ZI4R8MSRB6vtJRVI1F9VHxjA=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.75", "", { "os": "linux", "cpu": "x64" }, "sha512-1CnApef4kxA+ORyLfbuCLgZfEjp4wr3HjFnt7FAfOb73kIZH82cb7JYixeqRyy9eOcKfKqxLmBYy3o8IDkc4Rg=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.76", "", { "os": "win32", "cpu": "arm64" }, "sha512-wjpRWrerPItb5E1fP4SAcNMxQp1yEukbgvP4Azip836/ixxbghL6y0P57Ya/rv7QYLrkNZXoQ+tr9oXhPH5BVA=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.75", "", { "os": "win32", "cpu": "arm64" }, "sha512-j0UB95nmkYGNzmOrs6GqaddO1S90R0YC6IhbKnbKBdjchFPNVLz9JpexAs6MBDXPZwdKAywMxtwG2h3aTJtxng=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.76", "", { "os": "win32", "cpu": "x64" }, "sha512-2YjtZJdd3iO+SY9NKocE4/Pm9VolzAthUOXjpK4Pv5pnR9hBpPvX7FFSXJTfASj7y2j1tATWrlQLocZCFP/oMA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.75", "", { "os": "win32", "cpu": "x64" }, "sha512-ESpVZVGewe3JkB2TwrG3VRbkxT909iPdtvgNT7xTCIYH2VB4jqZomJfvERPTE0tvqAZJm19mHECzJFI8asSJgQ=="], - "@opentui/solid": ["@opentui/solid@0.1.76", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.76", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-PiD62FGoPoVLFpY4g08i4UYlx4sGR2OmHUPj6CuZZwy2UJD4fKn1RYV+kAPHfUW4qN/88I1k/w/Dniz1WvXrAQ=="], + "@opentui/solid": ["@opentui/solid@0.1.75", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.75", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-WjKsZIfrm29znfRlcD9w3uUn/+uvoy2MmeoDwTvg1YOa0OjCTCmjZ43L9imp0m9S4HmVU8ma6o2bR4COzcyDdg=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/nix/hashes.json b/nix/hashes.json index 9843699345..6fe8f61d3d 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-aRFzPzgu32XgNSk8S2z4glTlgHqEmOLZHlBQSIYIMvY=", - "aarch64-linux": "sha256-aCZLkmRrCa0bli0jgsaLcC5GlZdjQPbb6xD6Fc03eX8=", - "aarch64-darwin": "sha256-oZOOR6k8MmabNVDQNY5ywR06rRycdnXZL+gUucKSQ+g=", - "x86_64-darwin": "sha256-LXIcLnjn+1eTFWIsQ9W0U2orGm59P/L470O0KFFkRHg=" + "x86_64-linux": "sha256-06Otz3loT4vn0578VDxUqVudtzQvV7oM3EIzjZnsejo=", + "aarch64-linux": "sha256-88Qai5RkSenCZkakOg52b6xU2ok+h/Ns4/5L3+55sFY=", + "aarch64-darwin": "sha256-x8dgCF0CJBWi2dZLDHMGdlTqys1X755ok0PM6x0HAGo=", + "x86_64-darwin": "sha256-FkLDqorfIfOw+tB7SW5vgyhOIoI0IV9lqPW1iEmvUiI=" } } diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 45074b96c9..4afb724300 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -82,8 +82,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.4", - "@opentui/core": "0.1.76", - "@opentui/solid": "0.1.76", + "@opentui/core": "0.1.75", + "@opentui/solid": "0.1.75", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 2e1ffa4f00..713def2e5a 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -186,7 +186,6 @@ function App() { const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() - Clipboard.setRenderer(renderer) renderer.disableStdoutInterception() const dialog = useDialog() const local = useLocal() diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 5c27a26cd0..0e287fbc41 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -1,12 +1,24 @@ import { $ } from "bun" -import type { CliRenderer } from "@opentui/core" import { platform, release } from "os" import clipboardy from "clipboardy" import { lazy } from "../../../../util/lazy.js" import { tmpdir } from "os" import path from "path" -const rendererRef = { current: undefined as CliRenderer | undefined } +/** + * Writes text to clipboard via OSC 52 escape sequence. + * This allows clipboard operations to work over SSH by having + * the terminal emulator handle the clipboard locally. + */ +function writeOsc52(text: string): void { + if (!process.stdout.isTTY) return + const base64 = Buffer.from(text).toString("base64") + const osc52 = `\x1b]52;c;${base64}\x07` + // tmux and screen require DCS passthrough wrapping + const passthrough = process.env["TMUX"] || process.env["STY"] + const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 + process.stdout.write(sequence) +} export namespace Clipboard { export interface Content { @@ -14,10 +26,6 @@ export namespace Clipboard { mime: string } - export function setRenderer(renderer: CliRenderer | undefined): void { - rendererRef.current = renderer - } - export async function read(): Promise { const os = platform() @@ -146,11 +154,7 @@ export namespace Clipboard { }) export async function copy(text: string): Promise { - const renderer = rendererRef.current - if (renderer) { - const copied = renderer.copyToClipboardOSC52(text) - if (copied) return - } + writeOsc52(text) await getCopyMethod()(text) } } From 7a9290dc9b24f8fd905d68143a17528e708a9fe4 Mon Sep 17 00:00:00 2001 From: Dax Date: Mon, 2 Feb 2026 00:13:47 -0500 Subject: [PATCH 04/64] tui: show exit message banner (#11733) --- .../opencode/src/cli/cmd/tui/context/exit.tsx | 51 +++++++++++++++---- .../src/cli/cmd/tui/routes/session/index.tsx | 13 +++++ 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/exit.tsx b/packages/opencode/src/cli/cmd/tui/context/exit.tsx index 414cb1a41d..2aac152204 100644 --- a/packages/opencode/src/cli/cmd/tui/context/exit.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/exit.tsx @@ -1,23 +1,52 @@ import { useRenderer } from "@opentui/solid" import { createSimpleContext } from "./helper" import { FormatError, FormatUnknownError } from "@/cli/error" +type Exit = ((reason?: unknown) => Promise) & { + message: { + set: (value?: string) => () => void + clear: () => void + get: () => string | undefined + } +} export const { use: useExit, provider: ExitProvider } = createSimpleContext({ name: "Exit", init: (input: { onExit?: () => Promise }) => { const renderer = useRenderer() - return async (reason?: any) => { - // Reset window title before destroying renderer - renderer.setTerminalTitle("") - renderer.destroy() - await input.onExit?.() - if (reason) { - const formatted = FormatError(reason) ?? FormatUnknownError(reason) - if (formatted) { - process.stderr.write(formatted + "\n") + let message: string | undefined + const store = { + set: (value?: string) => { + const prev = message + message = value + return () => { + message = prev } - } - process.exit(0) + }, + clear: () => { + message = undefined + }, + get: () => message, } + const exit: Exit = Object.assign( + async (reason?: unknown) => { + // Reset window title before destroying renderer + renderer.setTerminalTitle("") + renderer.destroy() + await input.onExit?.() + if (reason) { + const formatted = FormatError(reason) ?? FormatUnknownError(reason) + if (formatted) { + process.stderr.write(formatted + "\n") + } + } + const text = store.get() + if (text) process.stdout.write(text + "\n") + process.exit(0) + }, + { + message: store, + }, + ) + return exit }, }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 8316d112c9..f7d83b0555 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -77,6 +77,7 @@ import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" +import { UI } from "@/cli/ui.ts" addDefaultParsers(parsers.parsers) @@ -222,6 +223,18 @@ export function Session() { // Allow exit when in child session (prompt is hidden) const exit = useExit() + + createEffect(() => { + return exit.message.set( + [ + ``, + ` █▀▀█ ${UI.Style.TEXT_DIM}${session()?.title}${UI.Style.TEXT_NORMAL}`, + ` █ █ ${UI.Style.TEXT_DIM}opencode -s ${session()?.id}${UI.Style.TEXT_NORMAL}`, + ` ▀▀▀▀ `, + ].join("\n"), + ) + }) + useKeyboard((evt) => { if (!session()?.parentID) return if (keybind.match("app_exit", evt)) { From 43354eeabd0497ffdbd0f5d4d457205ed7f03537 Mon Sep 17 00:00:00 2001 From: Jigar Date: Mon, 2 Feb 2026 10:58:28 +0530 Subject: [PATCH 05/64] fix: convert system message content to string for Copilot provider (#11600) Co-authored-by: Claude Opus 4.5 --- ...nvert-to-openai-compatible-chat-messages.ts | 7 +------ .../convert-to-copilot-messages.test.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts b/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts index d6f7cb34bb..e1e3ed4c20 100644 --- a/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts +++ b/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts @@ -18,12 +18,7 @@ export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Pro case "system": { messages.push({ role: "system", - content: [ - { - type: "text", - text: content, - }, - ], + content: content, ...metadata, }) break diff --git a/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts b/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts index 9f305123af..6f874db6d2 100644 --- a/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts +++ b/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts @@ -1,6 +1,24 @@ import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } from "@/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages" import { describe, test, expect } from "bun:test" +describe("system messages", () => { + test("should convert system message content to string", () => { + const result = convertToCopilotMessages([ + { + role: "system", + content: "You are a helpful assistant with AGENTS.md instructions.", + }, + ]) + + expect(result).toEqual([ + { + role: "system", + content: "You are a helpful assistant with AGENTS.md instructions.", + }, + ]) + }) +}) + describe("user messages", () => { test("should convert messages with only a text part to a string content", () => { const result = convertToCopilotMessages([ From 4850ecc41961eeda77b1c338fb366e795f23a59d Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 2 Feb 2026 00:29:52 -0500 Subject: [PATCH 06/64] zen: rate limit (#11735) --- .../app/src/routes/zen/util/rateLimiter.ts | 17 ++++++++++++++--- packages/console/core/src/model.ts | 7 ++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/rateLimiter.ts b/packages/console/app/src/routes/zen/util/rateLimiter.ts index 244db072c6..d54bd0306d 100644 --- a/packages/console/app/src/routes/zen/util/rateLimiter.ts +++ b/packages/console/app/src/routes/zen/util/rateLimiter.ts @@ -2,13 +2,17 @@ import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizz import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js" import { RateLimitError } from "./error" import { logger } from "./logger" +import { ZenData } from "@opencode-ai/console-core/model.js" -export function createRateLimiter(limit: number | undefined, rawIp: string) { +export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: string) { if (!limit) return const ip = !rawIp.length ? "unknown" : rawIp const now = Date.now() - const intervals = [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)] + const intervals = + limit.period === "day" + ? [buildYYYYMMDD(now)] + : [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)] return { track: async () => { @@ -28,11 +32,18 @@ export function createRateLimiter(limit: number | undefined, rawIp: string) { ) const total = rows.reduce((sum, r) => sum + r.count, 0) logger.debug(`rate limit total: ${total}`) - if (total >= limit) throw new RateLimitError(`Rate limit exceeded. Please try again later.`) + if (total >= limit.value) throw new RateLimitError(`Rate limit exceeded. Please try again later.`) }, } } +function buildYYYYMMDD(timestamp: number) { + return new Date(timestamp) + .toISOString() + .replace(/[^0-9]/g, "") + .substring(0, 8) +} + function buildYYYYMMDDHH(timestamp: number) { return new Date(timestamp) .toISOString() diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index 880c63a190..fc9674cedb 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -18,8 +18,13 @@ export namespace ZenData { }), ), }) + const RateLimitSchema = z.object({ + period: z.enum(["day", "rolling"]), + value: z.number().int(), + }) export type Format = z.infer export type Trial = z.infer + export type RateLimit = z.infer const ModelCostSchema = z.object({ input: z.number(), @@ -37,7 +42,7 @@ export namespace ZenData { byokProvider: z.enum(["openai", "anthropic", "google"]).optional(), stickyProvider: z.enum(["strict", "prefer"]).optional(), trial: TrialSchema.optional(), - rateLimit: z.number().optional(), + rateLimit: RateLimitSchema.optional(), fallbackProvider: z.string().optional(), providers: z.array( z.object({ From 76745d05943d63e39c6ba9cff863757fbb3a575f Mon Sep 17 00:00:00 2001 From: mohammad Date: Mon, 2 Feb 2026 11:00:46 +0530 Subject: [PATCH 07/64] fix(desktop): kill zombie server process on startup timeout (#11602) Co-authored-by: Brendan Allan --- packages/desktop/src-tauri/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 29ac86f29a..0958481ad2 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -526,6 +526,7 @@ async fn spawn_local_server( let timestamp = Instant::now(); loop { if timestamp.elapsed() > Duration::from_secs(30) { + let _ = child.kill(); break Err(format!( "Failed to spawn OpenCode Server. Logs:\n{}", get_logs(app.clone()).await.unwrap() From 3982c7d99a1fc47a70c6b5436a85220992300f2b Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 2 Feb 2026 08:12:50 +0100 Subject: [PATCH 08/64] Use opentui OSC52 clipboard, again (#11744) --- bun.lock | 20 +++++++------- packages/opencode/package.json | 4 +-- packages/opencode/src/cli/cmd/tui/app.tsx | 2 ++ .../src/cli/cmd/tui/util/clipboard.ts | 26 ++++++++----------- 4 files changed, 25 insertions(+), 27 deletions(-) diff --git a/bun.lock b/bun.lock index 0857283634..c98144251c 100644 --- a/bun.lock +++ b/bun.lock @@ -298,8 +298,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.4", - "@opentui/core": "0.1.75", - "@opentui/solid": "0.1.75", + "@opentui/core": "0.1.77", + "@opentui/solid": "0.1.77", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -1227,21 +1227,21 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "@opentui/core": ["@opentui/core@0.1.75", "", { "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.75", "@opentui/core-darwin-x64": "0.1.75", "@opentui/core-linux-arm64": "0.1.75", "@opentui/core-linux-x64": "0.1.75", "@opentui/core-win32-arm64": "0.1.75", "@opentui/core-win32-x64": "0.1.75", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8ARRZxSG+BXkJmEVtM2DQ4se7DAF1ZCKD07d+AklgTr2mxCzmdxxPbOwRzboSQ6FM7qGuTVPVbV4O2W9DpUmoA=="], + "@opentui/core": ["@opentui/core@0.1.77", "", { "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.77", "@opentui/core-darwin-x64": "0.1.77", "@opentui/core-linux-arm64": "0.1.77", "@opentui/core-linux-x64": "0.1.77", "@opentui/core-win32-arm64": "0.1.77", "@opentui/core-win32-x64": "0.1.77", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-lE3kabm6jdqK3AuBq+O0zZrXdxt6ulmibTc57sf+AsPny6cmwYHnWI4wD6hcreFiYoQVNVvdiJchVgPtowMlEg=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.75", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gGaGZjkFpqcXJk6321JzhRl66pM2VxBlI470L8W4DQUW4S6iDT1R9L7awSzGB4Cn9toUl7DTV8BemaXZYXV4SA=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.77", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SNqmygCMEsPCW7xWjzCZ5caBf36xaprwVdAnFijGDOuIzLA4iaDa6um8cj3TJh7awenN3NTRsuRc7OuH42UH+g=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.75", "", { "os": "darwin", "cpu": "x64" }, "sha512-tPlvqQI0whZ76amHydpJs5kN+QeWAIcFbI8RAtlAo9baj2EbxTDC+JGwgb9Fnt0/YQx831humbtaNDhV2Jt1bw=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.77", "", { "os": "darwin", "cpu": "x64" }, "sha512-/8fsa03swEHTQt/9NrGm98kemlU+VuTURI/OFZiH53vPDRrOYIYoa4Jyga/H7ZMcG+iFhkq97zIe+0Kw95LGmA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.75", "", { "os": "linux", "cpu": "arm64" }, "sha512-nVxIQ4Hqf84uBergDpWiVzU6pzpjy6tqBHRQpySxZ2flkJ/U6/aMEizVrQ1jcgIdxZtvqWDETZhzxhG0yDx+cw=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.77", "", { "os": "linux", "cpu": "arm64" }, "sha512-QfUXZJPc69OvqoMu+AlLgjqXrwu4IeqcBuUWYMuH8nPTeLsVUc3CBbXdV2lv9UDxWzxzrxdS4ALPaxvmEv9lsQ=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.75", "", { "os": "linux", "cpu": "x64" }, "sha512-1CnApef4kxA+ORyLfbuCLgZfEjp4wr3HjFnt7FAfOb73kIZH82cb7JYixeqRyy9eOcKfKqxLmBYy3o8IDkc4Rg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.77", "", { "os": "linux", "cpu": "x64" }, "sha512-Kmfx0yUKnPj67AoXYIgL7qQo0QVsUG5Iw8aRtv6XFzXqa5SzBPhaKkKZ9yHPjOmTalZquUs+9zcCRNKpYYuL7A=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.75", "", { "os": "win32", "cpu": "arm64" }, "sha512-j0UB95nmkYGNzmOrs6GqaddO1S90R0YC6IhbKnbKBdjchFPNVLz9JpexAs6MBDXPZwdKAywMxtwG2h3aTJtxng=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.77", "", { "os": "win32", "cpu": "arm64" }, "sha512-HGTscPXc7gdd23Nh1DbzUNjog1I+5IZp95XPtLftGTpjrWs60VcetXcyJqK2rQcXNxewJK5yDyaa5QyMjfEhCQ=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.75", "", { "os": "win32", "cpu": "x64" }, "sha512-ESpVZVGewe3JkB2TwrG3VRbkxT909iPdtvgNT7xTCIYH2VB4jqZomJfvERPTE0tvqAZJm19mHECzJFI8asSJgQ=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.77", "", { "os": "win32", "cpu": "x64" }, "sha512-c7GijsbvVgnlzd2murIbwuwrGbcv76KdUw6WlVv7a0vex50z6xJCpv1keGzpe0QfxrZ/6fFEFX7JnwGLno0wjA=="], - "@opentui/solid": ["@opentui/solid@0.1.75", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.75", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-WjKsZIfrm29znfRlcD9w3uUn/+uvoy2MmeoDwTvg1YOa0OjCTCmjZ43L9imp0m9S4HmVU8ma6o2bR4COzcyDdg=="], + "@opentui/solid": ["@opentui/solid@0.1.77", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.77", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-JY+hUbXVV+XCk6bC8dvcwawWCEmC3Gid6GDs23AJWBgHZ3TU2kRKrgwTdltm45DOq2cZXrYCt690/yE8bP+Gxg=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 4afb724300..c86aa734d8 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -82,8 +82,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.4", - "@opentui/core": "0.1.75", - "@opentui/solid": "0.1.75", + "@opentui/core": "0.1.77", + "@opentui/solid": "0.1.77", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 713def2e5a..c5f88a7c67 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -169,6 +169,7 @@ export function tui(input: { gatherStats: false, exitOnCtrlC: false, useKittyKeyboard: {}, + autoFocus: false, consoleOptions: { keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], onCopySelection: (text) => { @@ -186,6 +187,7 @@ function App() { const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() + Clipboard.setRenderer(renderer) renderer.disableStdoutInterception() const dialog = useDialog() const local = useLocal() diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 0e287fbc41..5c27a26cd0 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -1,24 +1,12 @@ import { $ } from "bun" +import type { CliRenderer } from "@opentui/core" import { platform, release } from "os" import clipboardy from "clipboardy" import { lazy } from "../../../../util/lazy.js" import { tmpdir } from "os" import path from "path" -/** - * Writes text to clipboard via OSC 52 escape sequence. - * This allows clipboard operations to work over SSH by having - * the terminal emulator handle the clipboard locally. - */ -function writeOsc52(text: string): void { - if (!process.stdout.isTTY) return - const base64 = Buffer.from(text).toString("base64") - const osc52 = `\x1b]52;c;${base64}\x07` - // tmux and screen require DCS passthrough wrapping - const passthrough = process.env["TMUX"] || process.env["STY"] - const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 - process.stdout.write(sequence) -} +const rendererRef = { current: undefined as CliRenderer | undefined } export namespace Clipboard { export interface Content { @@ -26,6 +14,10 @@ export namespace Clipboard { mime: string } + export function setRenderer(renderer: CliRenderer | undefined): void { + rendererRef.current = renderer + } + export async function read(): Promise { const os = platform() @@ -154,7 +146,11 @@ export namespace Clipboard { }) export async function copy(text: string): Promise { - writeOsc52(text) + const renderer = rendererRef.current + if (renderer) { + const copied = renderer.copyToClipboardOSC52(text) + if (copied) return + } await getCopyMethod()(text) } } From 141fdef5886cc5e161fb9e857b61c5081519d2f6 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 07:21:25 +0000 Subject: [PATCH 09/64] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 6fe8f61d3d..5180219533 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-06Otz3loT4vn0578VDxUqVudtzQvV7oM3EIzjZnsejo=", - "aarch64-linux": "sha256-88Qai5RkSenCZkakOg52b6xU2ok+h/Ns4/5L3+55sFY=", - "aarch64-darwin": "sha256-x8dgCF0CJBWi2dZLDHMGdlTqys1X755ok0PM6x0HAGo=", - "x86_64-darwin": "sha256-FkLDqorfIfOw+tB7SW5vgyhOIoI0IV9lqPW1iEmvUiI=" + "x86_64-linux": "sha256-4I0lpBnbAi7IZMURTMLysjrqdsNvXJf8802NrJnpdks=", + "aarch64-linux": "sha256-WOGKsPlcQVSbL8TDr1JYO/2ucPTV2Hy0TXJKWv8EoVw=", + "aarch64-darwin": "sha256-LuvjwGm1QsHoLxuvSSp4VsDIv02Z/rTONsU32arQMuw=", + "x86_64-darwin": "sha256-AbglfgCWj/r+wHfle+e+D3b/xPcwwg4IK7j5iwn9nzw=" } } From c02dd067b2ae62553c63b087b7b48a0f46628747 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 2 Feb 2026 15:54:25 +0800 Subject: [PATCH 10/64] fix(desktop): keep mac titlebar stable under zoom (#11747) --- packages/app/src/components/titlebar.tsx | 5 +++- packages/app/src/context/platform.tsx | 4 +++ packages/desktop/src/index.tsx | 4 ++- packages/desktop/src/webview-zoom.ts | 38 ++++++++++++++---------- 4 files changed, 33 insertions(+), 18 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 001f7a5679..affb685411 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -24,6 +24,8 @@ export function Titlebar() { const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const web = createMemo(() => platform.platform === "web") + const zoom = () => platform.webviewZoom?.() ?? 1 + const minHeight = () => (mac() ? `${40 / zoom()}px` : undefined) const [history, setHistory] = createStore({ stack: [] as string[], @@ -134,6 +136,7 @@ export function Titlebar() { return (
-
+
+ + /** Webview zoom level (desktop only) */ + webviewZoom?: Accessor } export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({ diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 2e7ca136ac..505a00b16a 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -1,5 +1,5 @@ // @refresh reload -import "./webview-zoom" +import { webviewZoom } from "./webview-zoom" import { render } from "solid-js/web" import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app" import { open, save } from "@tauri-apps/plugin-dialog" @@ -346,6 +346,8 @@ const createPlatform = (password: Accessor): Platform => ({ parseMarkdown: async (markdown: string) => { return invoke("parse_markdown_command", { markdown }) }, + + webviewZoom, }) createMenu() diff --git a/packages/desktop/src/webview-zoom.ts b/packages/desktop/src/webview-zoom.ts index 9fa9bb9ed9..06f46a3afd 100644 --- a/packages/desktop/src/webview-zoom.ts +++ b/packages/desktop/src/webview-zoom.ts @@ -4,28 +4,34 @@ import { invoke } from "@tauri-apps/api/core" import { type as ostype } from "@tauri-apps/plugin-os" +import { createSignal } from "solid-js" const OS_NAME = ostype() -let zoomLevel = 1 +const [webviewZoom, setWebviewZoom] = createSignal(1) const MAX_ZOOM_LEVEL = 10 const MIN_ZOOM_LEVEL = 0.2 +const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) + +const applyZoom = (next: number) => { + setWebviewZoom(next) + invoke("plugin:webview|set_webview_zoom", { + value: next, + }) +} + window.addEventListener("keydown", (event) => { - if (OS_NAME === "macos" ? event.metaKey : event.ctrlKey) { - if (event.key === "-") { - zoomLevel -= 0.2 - } else if (event.key === "=" || event.key === "+") { - zoomLevel += 0.2 - } else if (event.key === "0") { - zoomLevel = 1 - } else { - return - } - zoomLevel = Math.min(Math.max(zoomLevel, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) - invoke("plugin:webview|set_webview_zoom", { - value: zoomLevel, - }) - } + if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return + + let newZoom = webviewZoom() + + if (event.key === "-") newZoom -= 0.2 + if (event.key === "=" || event.key === "+") newZoom += 0.2 + if (event.key === "0") newZoom = 1 + + applyZoom(clamp(newZoom)) }) + +export { webviewZoom } From 04aef44fc30d599f11ea2ada60ed63c4856a18ff Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 2 Feb 2026 15:58:08 +0800 Subject: [PATCH 11/64] chore(desktop): integrate tauri-specta (#11740) --- packages/desktop/src-tauri/Cargo.lock | 247 +++++++++++++++++---- packages/desktop/src-tauri/Cargo.toml | 14 +- packages/desktop/src-tauri/src/cli.rs | 1 + packages/desktop/src-tauri/src/lib.rs | 41 +++- packages/desktop/src-tauri/src/markdown.rs | 5 +- packages/desktop/src/bindings.ts | 20 ++ packages/desktop/src/cli.ts | 4 +- packages/desktop/src/index.tsx | 20 +- packages/desktop/src/menu.ts | 4 +- packages/desktop/src/updater.ts | 6 +- 10 files changed, 289 insertions(+), 73 deletions(-) create mode 100644 packages/desktop/src/bindings.ts diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 51e5e00490..14030218ed 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" + [[package]] name = "adler2" version = "2.0.1" @@ -1994,9 +2000,9 @@ dependencies = [ [[package]] name = "ico" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", "png 0.17.16", @@ -3065,12 +3071,14 @@ dependencies = [ "listeners", "objc2 0.6.3", "objc2-web-kit", - "reqwest", + "reqwest 0.12.24", "semver", "serde", "serde_json", + "specta", + "specta-typescript", "tauri", - "tauri-build", + "tauri-build 2.5.2", "tauri-plugin-clipboard-manager", "tauri-plugin-decorum", "tauri-plugin-deep-link", @@ -3085,6 +3093,7 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-updater", "tauri-plugin-window-state", + "tauri-specta", "tokio", "uuid", "webkit2gtk", @@ -3221,6 +3230,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" @@ -3947,6 +3962,40 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "reqwest" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + [[package]] name = "rfd" version = "0.15.4" @@ -4497,6 +4546,44 @@ dependencies = [ "system-deps", ] +[[package]] +name = "specta" +version = "2.0.0-rc.22" +source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb" +dependencies = [ + "paste", + "rustc_version", + "specta-macros", +] + +[[package]] +name = "specta-macros" +version = "2.0.0-rc.18" +source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb" +dependencies = [ + "Inflector", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "specta-serde" +version = "0.0.9" +source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb" +dependencies = [ + "specta", +] + +[[package]] +name = "specta-typescript" +version = "0.0.9" +source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb" +dependencies = [ + "specta", + "specta-serde", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4712,9 +4799,8 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e492485dd390b35f7497401f67694f46161a2a00ffd800938d5dd3c898fb9d8" +version = "2.9.5" +source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee" dependencies = [ "anyhow", "bytes", @@ -4740,17 +4826,18 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.1", "serde", "serde_json", "serde_repr", "serialize-to-javascript", + "specta", "swift-rs", - "tauri-build", + "tauri-build 2.5.3", "tauri-macros", "tauri-runtime", "tauri-runtime-wry", - "tauri-utils", + "tauri-utils 2.8.1", "thiserror 2.0.17", "tokio", "tray-icon", @@ -4777,7 +4864,28 @@ dependencies = [ "semver", "serde", "serde_json", - "tauri-utils", + "tauri-utils 2.8.0", + "tauri-winres", + "toml 0.9.8", + "walkdir", +] + +[[package]] +name = "tauri-build" +version = "2.5.3" +source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils 2.8.1", "tauri-winres", "toml 0.9.8", "walkdir", @@ -4785,9 +4893,8 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ef707148f0755110ca54377560ab891d722de4d53297595380a748026f139f" +version = "2.5.2" +source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee" dependencies = [ "base64 0.22.1", "brotli", @@ -4802,7 +4909,7 @@ dependencies = [ "serde_json", "sha2", "syn 2.0.110", - "tauri-utils", + "tauri-utils 2.8.1", "thiserror 2.0.17", "time", "url", @@ -4812,16 +4919,15 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71664fd715ee6e382c05345ad258d6d1d50f90cf1b58c0aa726638b33c2a075d" +version = "2.5.2" +source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.110", "tauri-codegen", - "tauri-utils", + "tauri-utils 2.8.1", ] [[package]] @@ -4836,7 +4942,7 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", - "tauri-utils", + "tauri-utils 2.8.0", "toml 0.9.8", "walkdir", ] @@ -4886,7 +4992,7 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", - "tauri-utils", + "tauri-utils 2.8.0", "thiserror 2.0.17", "tracing", "url", @@ -4928,7 +5034,7 @@ dependencies = [ "serde_repr", "tauri", "tauri-plugin", - "tauri-utils", + "tauri-utils 2.8.0", "thiserror 2.0.17", "toml 0.9.8", "url", @@ -4945,7 +5051,7 @@ dependencies = [ "data-url", "http", "regex", - "reqwest", + "reqwest 0.12.24", "schemars 0.8.22", "serde", "serde_json", @@ -5096,7 +5202,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest", + "reqwest 0.12.24", "semver", "serde", "serde_json", @@ -5129,9 +5235,8 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9368f09358496f2229313fccb37682ad116b7f46fa76981efe116994a0628926" +version = "2.9.2" +source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee" dependencies = [ "cookie", "dpi", @@ -5144,7 +5249,7 @@ dependencies = [ "raw-window-handle", "serde", "serde_json", - "tauri-utils", + "tauri-utils 2.8.1", "thiserror 2.0.17", "url", "webkit2gtk", @@ -5154,9 +5259,8 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "929f5df216f5c02a9e894554401bcdab6eec3e39ec6a4a7731c7067fc8688a93" +version = "2.9.3" +source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee" dependencies = [ "gtk", "http", @@ -5171,7 +5275,7 @@ dependencies = [ "softbuffer", "tao", "tauri-runtime", - "tauri-utils", + "tauri-utils 2.8.1", "url", "webkit2gtk", "webview2-com", @@ -5179,11 +5283,74 @@ dependencies = [ "wry", ] +[[package]] +name = "tauri-specta" +version = "2.0.0-rc.21" +source = "git+https://github.com/specta-rs/tauri-specta?rev=6720b2848eff9a3e40af54c48d65f6d56b640c0b#6720b2848eff9a3e40af54c48d65f6d56b640c0b" +dependencies = [ + "heck 0.5.0", + "serde", + "serde_json", + "specta", + "specta-typescript", + "tauri", + "tauri-specta-macros", + "thiserror 2.0.17", +] + +[[package]] +name = "tauri-specta-macros" +version = "2.0.0-rc.16" +source = "git+https://github.com/specta-rs/tauri-specta?rev=6720b2848eff9a3e40af54c48d65f6d56b640c0b#6720b2848eff9a3e40af54c48d65f6d56b640c0b" +dependencies = [ + "darling", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "tauri-utils" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b8bbe426abdbf52d050e52ed693130dbd68375b9ad82a3fb17efb4c8d85673" +dependencies = [ + "anyhow", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.17", + "toml 0.9.8", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-utils" +version = "2.8.1" +source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee" dependencies = [ "anyhow", "brotli", @@ -5547,9 +5714,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.10.0", "bytes", @@ -6034,9 +6201,9 @@ dependencies = [ [[package]] name = "webkit2gtk" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" dependencies = [ "bitflags 1.3.2", "cairo-rs", @@ -6058,9 +6225,9 @@ dependencies = [ [[package]] name = "webkit2gtk-sys" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" dependencies = [ "bitflags 1.3.2", "cairo-sys-rs", @@ -6719,9 +6886,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wry" -version = "0.53.5" +version = "0.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" +checksum = "5ed1a195b0375491dd15a7066a10251be217ce743cf4bbbbdcf5391d6473bee0" dependencies = [ "base64 0.22.1", "block2 0.6.2", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index e87bf77c2e..2efa484e82 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["macos-private-api", "devtools"] } +tauri = { version = "2.9.5", features = ["macos-private-api", "devtools"] } tauri-plugin-opener = "2" tauri-plugin-deep-link = "2.4.6" tauri-plugin-shell = "2" @@ -43,10 +43,13 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" uuid = { version = "1.19.0", features = ["v4"] } tauri-plugin-decorum = "1.1.1" comrak = { version = "0.50", default-features = false } +specta = "=2.0.0-rc.22" +specta-typescript = "0.0.9" +tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18.2" -webkit2gtk = "=2.0.1" +webkit2gtk = "=2.0.2" [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" @@ -59,3 +62,10 @@ windows = { version = "0.61", features = [ "Win32_System_Threading", "Win32_Security" ] } + +[patch.crates-io] +specta = { git = "https://github.com/specta-rs/specta", rev = "106425eac4964d8ff34d3a02f1612e33117b08bb" } +specta-typescript = { git = "https://github.com/specta-rs/specta", rev = "106425eac4964d8ff34d3a02f1612e33117b08bb" } +tauri-specta = { git = "https://github.com/specta-rs/tauri-specta", rev = "6720b2848eff9a3e40af54c48d65f6d56b640c0b" } +# TODO: https://github.com/tauri-apps/tauri/pull/14812 +tauri = { git = "https://github.com/tauri-apps/tauri", rev = "4d5d78daf636feaac20c5bc48a6071491c4291ee" } diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index f64beed6a1..16e4bfec99 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/packages/desktop/src-tauri/src/cli.rs @@ -51,6 +51,7 @@ fn is_cli_installed() -> bool { const INSTALL_SCRIPT: &str = include_str!("../../../../install"); #[tauri::command] +#[specta::specta] pub fn install_cli(app: tauri::AppHandle) -> Result { if cfg!(not(unix)) { return Err("CLI installation is only supported on macOS & Linux".to_string()); diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 0958481ad2..2fe7c4aa17 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -16,10 +16,10 @@ use std::{ time::{Duration, Instant}, }; use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewWindowBuilder}; -#[cfg(any(target_os = "linux", all(debug_assertions, windows)))] -use tauri_plugin_deep_link::DeepLinkExt; #[cfg(windows)] use tauri_plugin_decorum::WebviewWindowExt; +#[cfg(any(target_os = "linux", all(debug_assertions, windows)))] +use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_store::StoreExt; @@ -30,7 +30,7 @@ use crate::window_customizer::PinchZoomDisablePlugin; const SETTINGS_STORE: &str = "opencode.settings.dat"; const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl"; -#[derive(Clone, serde::Serialize)] +#[derive(Clone, serde::Serialize, specta::Type)] struct ServerReadyData { url: String, password: Option, @@ -64,6 +64,7 @@ struct LogState(Arc>>); const MAX_LOG_ENTRIES: usize = 200; #[tauri::command] +#[specta::specta] fn kill_sidecar(app: AppHandle) { let Some(server_state) = app.try_state::() else { println!("Server not running"); @@ -97,6 +98,7 @@ async fn get_logs(app: AppHandle) -> Result { } #[tauri::command] +#[specta::specta] async fn ensure_server_ready(state: State<'_, ServerState>) -> Result { state .status @@ -106,6 +108,7 @@ async fn ensure_server_ready(state: State<'_, ServerState>) -> Result Result, String> { let store = app .store(SETTINGS_STORE) @@ -119,6 +122,7 @@ fn get_default_server_url(app: AppHandle) -> Result, String> { } #[tauri::command] +#[specta::specta] async fn set_default_server_url(app: AppHandle, url: Option) -> Result<(), String> { let store = app .store(SETTINGS_STORE) @@ -252,6 +256,26 @@ async fn check_server_health(url: &str, password: Option<&str>) -> bool { pub fn run() { let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some(); + let builder = tauri_specta::Builder::::new() + // Then register them (separated by a comma) + .commands(tauri_specta::collect_commands![ + kill_sidecar, + install_cli, + ensure_server_ready, + get_default_server_url, + set_default_server_url, + markdown::parse_markdown_command + ]) + .error_handling(tauri_specta::ErrorHandlingMode::Throw); + + #[cfg(debug_assertions)] // <- Only export on non-release builds + builder + .export( + specta_typescript::Typescript::default(), + "../src/bindings.ts", + ) + .expect("Failed to export typescript bindings"); + #[cfg(all(target_os = "macos", not(debug_assertions)))] let _ = std::process::Command::new("killall") .arg("opencode-cli") @@ -285,15 +309,10 @@ pub fn run() { .plugin(tauri_plugin_notification::init()) .plugin(PinchZoomDisablePlugin) .plugin(tauri_plugin_decorum::init()) - .invoke_handler(tauri::generate_handler![ - kill_sidecar, - install_cli, - ensure_server_ready, - get_default_server_url, - set_default_server_url, - markdown::parse_markdown_command - ]) + .invoke_handler(builder.invoke_handler()) .setup(move |app| { + builder.mount_events(app); + #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] app.deep_link().register_all().ok(); diff --git a/packages/desktop/src-tauri/src/markdown.rs b/packages/desktop/src-tauri/src/markdown.rs index c3ca73857e..39a64a4318 100644 --- a/packages/desktop/src-tauri/src/markdown.rs +++ b/packages/desktop/src-tauri/src/markdown.rs @@ -1,4 +1,6 @@ -use comrak::{create_formatter, parse_document, Arena, Options, html::ChildRendering, nodes::NodeValue}; +use comrak::{ + Arena, Options, create_formatter, html::ChildRendering, nodes::NodeValue, parse_document, +}; use std::fmt::Write; create_formatter!(ExternalLinkFormatter, { @@ -55,6 +57,7 @@ pub fn parse_markdown(input: &str) -> String { } #[tauri::command] +#[specta::specta] pub async fn parse_markdown_command(markdown: String) -> Result { Ok(parse_markdown(&markdown)) } diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts new file mode 100644 index 0000000000..eb5498fa68 --- /dev/null +++ b/packages/desktop/src/bindings.ts @@ -0,0 +1,20 @@ +// This file has been generated by Tauri Specta. Do not edit this file manually. + +import { invoke as __TAURI_INVOKE, Channel } from '@tauri-apps/api/core'; + +/** Commands */ +export const commands = { + killSidecar: () => __TAURI_INVOKE("kill_sidecar"), + installCli: () => __TAURI_INVOKE("install_cli"), + ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), + getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), + setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), + parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), +}; + +/* Types */ +export type ServerReadyData = { + url: string, + password: string | null, + }; + diff --git a/packages/desktop/src/cli.ts b/packages/desktop/src/cli.ts index 5a8875cf89..28623bdf78 100644 --- a/packages/desktop/src/cli.ts +++ b/packages/desktop/src/cli.ts @@ -1,13 +1,13 @@ -import { invoke } from "@tauri-apps/api/core" import { message } from "@tauri-apps/plugin-dialog" import { initI18n, t } from "./i18n" +import { commands } from "./bindings" export async function installCli(): Promise { await initI18n() try { - const path = await invoke("install_cli") + const path = await commands.installCli() await message(t("desktop.cli.installed.message", { path }), { title: t("desktop.cli.installed.title") }) } catch (e) { await message(t("desktop.cli.failed.message", { error: String(e) }), { title: t("desktop.cli.failed.title") }) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 505a00b16a..2b44061626 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -7,7 +7,6 @@ import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link" import { open as shellOpen } from "@tauri-apps/plugin-shell" import { type as ostype } from "@tauri-apps/plugin-os" import { check, Update } from "@tauri-apps/plugin-updater" -import { invoke } from "@tauri-apps/api/core" import { getCurrentWindow } from "@tauri-apps/api/window" import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification" import { relaunch } from "@tauri-apps/plugin-process" @@ -22,6 +21,7 @@ import { createMenu } from "./menu" import { initI18n, t } from "./i18n" import pkg from "../package.json" import "./styles.css" +import { commands } from "./bindings" const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { @@ -274,12 +274,12 @@ const createPlatform = (password: Accessor): Platform => ({ update: async () => { if (!UPDATER_ENABLED || !update) return - if (ostype() === "windows") await invoke("kill_sidecar").catch(() => undefined) + if (ostype() === "windows") await commands.killSidecar().catch(() => undefined) await update.install().catch(() => undefined) }, restart: async () => { - await invoke("kill_sidecar").catch(() => undefined) + await commands.killSidecar().catch(() => undefined) await relaunch() }, @@ -335,17 +335,13 @@ const createPlatform = (password: Accessor): Platform => ({ }, getDefaultServerUrl: async () => { - const result = await invoke("get_default_server_url").catch(() => null) + const result = await commands.getDefaultServerUrl().catch(() => null) return result }, - setDefaultServerUrl: async (url: string | null) => { - await invoke("set_default_server_url", { url }) - }, + setDefaultServerUrl: async (url: string | null) => { await commands.setDefaultServerUrl(url) }, - parseMarkdown: async (markdown: string) => { - return invoke("parse_markdown_command", { markdown }) - }, + parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown), webviewZoom, }) @@ -394,7 +390,7 @@ type ServerReadyData = { url: string; password: string | null } // Gate component that waits for the server to be ready function ServerGate(props: { children: (data: Accessor) => JSX.Element }) { const [serverData] = createResource(() => - invoke("ensure_server_ready").then((v) => { + commands.ensureServerReady().then((v) => { return new Promise((res) => setTimeout(() => res(v as ServerReadyData), 2000)) }), ) @@ -408,7 +404,7 @@ function ServerGate(props: { children: (data: Accessor) => JSX. } const restartApp = async () => { - await invoke("kill_sidecar").catch(() => undefined) + await commands.killSidecar().catch(() => undefined) await relaunch().catch(() => undefined) } diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index 2edeff42b2..d410844042 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -1,11 +1,11 @@ import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu" import { type as ostype } from "@tauri-apps/plugin-os" -import { invoke } from "@tauri-apps/api/core" import { relaunch } from "@tauri-apps/plugin-process" import { runUpdater, UPDATER_ENABLED } from "./updater" import { installCli } from "./cli" import { initI18n, t } from "./i18n" +import { commands } from "./bindings" export async function createMenu() { if (ostype() !== "macos") return @@ -35,7 +35,7 @@ export async function createMenu() { }), await MenuItem.new({ action: async () => { - await invoke("kill_sidecar").catch(() => undefined) + await commands.killSidecar().catch(() => undefined) await relaunch().catch(() => undefined) }, text: t("desktop.menu.restart"), diff --git a/packages/desktop/src/updater.ts b/packages/desktop/src/updater.ts index b48bb6be02..7326696338 100644 --- a/packages/desktop/src/updater.ts +++ b/packages/desktop/src/updater.ts @@ -1,10 +1,10 @@ import { check } from "@tauri-apps/plugin-updater" import { relaunch } from "@tauri-apps/plugin-process" import { ask, message } from "@tauri-apps/plugin-dialog" -import { invoke } from "@tauri-apps/api/core" import { type as ostype } from "@tauri-apps/plugin-os" import { initI18n, t } from "./i18n" +import { commands } from "./bindings" export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false @@ -39,13 +39,13 @@ export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) { if (!shouldUpdate) return try { - if (ostype() === "windows") await invoke("kill_sidecar") + if (ostype() === "windows") await commands.killSidecar() await update.install() } catch { await message(t("desktop.updater.installFailed.message"), { title: t("desktop.updater.installFailed.title") }) return } - await invoke("kill_sidecar") + await commands.killSidecar() await relaunch() } From 784a17f7b3026be2a8abebd59ee4132270bdf6a0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 07:58:52 +0000 Subject: [PATCH 12/64] chore: generate --- packages/desktop/src/bindings.ts | 23 +++++++++++------------ packages/desktop/src/index.tsx | 4 +++- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index eb5498fa68..440e138b4f 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -1,20 +1,19 @@ // This file has been generated by Tauri Specta. Do not edit this file manually. -import { invoke as __TAURI_INVOKE, Channel } from '@tauri-apps/api/core'; +import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core" /** Commands */ export const commands = { - killSidecar: () => __TAURI_INVOKE("kill_sidecar"), - installCli: () => __TAURI_INVOKE("install_cli"), - ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), - getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), - setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), - parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), -}; + killSidecar: () => __TAURI_INVOKE("kill_sidecar"), + installCli: () => __TAURI_INVOKE("install_cli"), + ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), + getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), + setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), + parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), +} /* Types */ export type ServerReadyData = { - url: string, - password: string | null, - }; - + url: string + password: string | null +} diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 2b44061626..bb3265db88 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -339,7 +339,9 @@ const createPlatform = (password: Accessor): Platform => ({ return result }, - setDefaultServerUrl: async (url: string | null) => { await commands.setDefaultServerUrl(url) }, + setDefaultServerUrl: async (url: string | null) => { + await commands.setDefaultServerUrl(url) + }, parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown), From e6d8315e29a8b7a34fd6639393e4dcc12bf9fa6c Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 2 Feb 2026 17:54:57 +0800 Subject: [PATCH 13/64] fix(desktop): throttle window state persistence (#11746) --- packages/desktop/src-tauri/src/lib.rs | 45 ++++++++++++++++--- .../src-tauri/src/window_customizer.rs | 4 +- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 2fe7c4aa17..a177e6ceed 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -23,13 +23,18 @@ use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_store::StoreExt; -use tokio::sync::oneshot; +use tauri_plugin_window_state::{AppHandleExt, StateFlags}; +use tokio::sync::{mpsc, oneshot}; use crate::window_customizer::PinchZoomDisablePlugin; const SETTINGS_STORE: &str = "opencode.settings.dat"; const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl"; +fn window_state_flags() -> StateFlags { + StateFlags::all() - StateFlags::DECORATIONS +} + #[derive(Clone, serde::Serialize, specta::Type)] struct ServerReadyData { url: String, @@ -293,10 +298,7 @@ pub fn run() { .plugin(tauri_plugin_os::init()) .plugin( tauri_plugin_window_state::Builder::new() - .with_state_flags( - tauri_plugin_window_state::StateFlags::all() - - tauri_plugin_window_state::StateFlags::DECORATIONS, - ) + .with_state_flags(window_state_flags()) .build(), ) .plugin(tauri_plugin_store::Builder::new().build()) @@ -365,6 +367,8 @@ pub fn run() { let window = window_builder.build().expect("Failed to create window"); + setup_window_state_listener(&app, &window); + #[cfg(windows)] let _ = window.create_overlay_titlebar(); @@ -560,3 +564,34 @@ async fn spawn_local_server( } } } + +fn setup_window_state_listener(app: &tauri::AppHandle, window: &tauri::WebviewWindow) { + let (tx, mut rx) = mpsc::channel::<()>(1); + + window.on_window_event(move |event| { + if !matches!(event, WindowEvent::Moved(_) | WindowEvent::Resized(_)) { + return; + } + let _ = tx.try_send(()); + }); + + tauri::async_runtime::spawn({ + let app = app.clone(); + + async move { + let save = || { + let handle = app.clone(); + let app = app.clone(); + let _ = handle.run_on_main_thread(move || { + let _ = app.save_window_state(window_state_flags()); + }); + }; + + while rx.recv().await.is_some() { + tokio::time::sleep(Duration::from_millis(200)).await; + + save(); + } + } + }); +} diff --git a/packages/desktop/src-tauri/src/window_customizer.rs b/packages/desktop/src-tauri/src/window_customizer.rs index 682f57f247..d73662120a 100644 --- a/packages/desktop/src-tauri/src/window_customizer.rs +++ b/packages/desktop/src-tauri/src/window_customizer.rs @@ -1,4 +1,4 @@ -use tauri::{plugin::Plugin, Manager, Runtime, Window}; +use tauri::{Manager, Runtime, Window, plugin::Plugin}; pub struct PinchZoomDisablePlugin; @@ -21,8 +21,8 @@ impl Plugin for PinchZoomDisablePlugin { let _ = webview_window.with_webview(|_webview| { #[cfg(target_os = "linux")] unsafe { - use gtk::glib::ObjectExt; use gtk::GestureZoom; + use gtk::glib::ObjectExt; use webkit2gtk::glib::gobject_ffi; if let Some(data) = _webview.inner().data::("wk-view-zoom-gesture") { From 1832eeffc97430edea9ab62818153bfedc6aea17 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 2 Feb 2026 18:19:49 +0800 Subject: [PATCH 14/64] fix(desktop): remove unnecessary setTimeout --- packages/desktop/src/index.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index bb3265db88..9ef680ed86 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -391,11 +391,7 @@ type ServerReadyData = { url: string; password: string | null } // Gate component that waits for the server to be ready function ServerGate(props: { children: (data: Accessor) => JSX.Element }) { - const [serverData] = createResource(() => - commands.ensureServerReady().then((v) => { - return new Promise((res) => setTimeout(() => res(v as ServerReadyData), 2000)) - }), - ) + const [serverData] = createResource(() => commands.ensureServerReady()) const errorMessage = () => { const error = serverData.error From 9564c1d6be6a7d83abb6dd665b34a6572518fbab Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 2 Feb 2026 18:52:57 +0800 Subject: [PATCH 15/64] desktop: fix rust build + bindings formatting --- .prettierignore | 3 ++- packages/desktop/src-tauri/src/lib.rs | 1 + packages/desktop/src/bindings.ts | 23 ++++++++++++----------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.prettierignore b/.prettierignore index aa3a7ce238..5f86f710fb 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ -sst-env.d.ts \ No newline at end of file +sst-env.d.ts +desktop/src/bindings.ts diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index a177e6ceed..d309b918b6 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -569,6 +569,7 @@ fn setup_window_state_listener(app: &tauri::AppHandle, window: &tauri::WebviewWi let (tx, mut rx) = mpsc::channel::<()>(1); window.on_window_event(move |event| { + use tauri::WindowEvent; if !matches!(event, WindowEvent::Moved(_) | WindowEvent::Resized(_)) { return; } diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index 440e138b4f..eb5498fa68 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -1,19 +1,20 @@ // This file has been generated by Tauri Specta. Do not edit this file manually. -import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core" +import { invoke as __TAURI_INVOKE, Channel } from '@tauri-apps/api/core'; /** Commands */ export const commands = { - killSidecar: () => __TAURI_INVOKE("kill_sidecar"), - installCli: () => __TAURI_INVOKE("install_cli"), - ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), - getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), - setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), - parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), -} + killSidecar: () => __TAURI_INVOKE("kill_sidecar"), + installCli: () => __TAURI_INVOKE("install_cli"), + ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), + getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), + setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), + parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), +}; /* Types */ export type ServerReadyData = { - url: string - password: string | null -} + url: string, + password: string | null, + }; + From 1cabeb00d0a391cf83495bf4e3544aa53f155ef4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 10:53:46 +0000 Subject: [PATCH 16/64] chore: generate --- packages/desktop/src/bindings.ts | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index eb5498fa68..440e138b4f 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -1,20 +1,19 @@ // This file has been generated by Tauri Specta. Do not edit this file manually. -import { invoke as __TAURI_INVOKE, Channel } from '@tauri-apps/api/core'; +import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core" /** Commands */ export const commands = { - killSidecar: () => __TAURI_INVOKE("kill_sidecar"), - installCli: () => __TAURI_INVOKE("install_cli"), - ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), - getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), - setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), - parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), -}; + killSidecar: () => __TAURI_INVOKE("kill_sidecar"), + installCli: () => __TAURI_INVOKE("install_cli"), + ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), + getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), + setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), + parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), +} /* Types */ export type ServerReadyData = { - url: string, - password: string | null, - }; - + url: string + password: string | null +} From 52eb8a7a8c2ceefa0de8a1a37d5f8754f08cfcff Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 2 Feb 2026 19:05:47 +0800 Subject: [PATCH 17/64] feat(app): unread session navigation keybinds (#11750) --- packages/app/src/i18n/ar.ts | 2 ++ packages/app/src/i18n/br.ts | 2 ++ packages/app/src/i18n/da.ts | 2 ++ packages/app/src/i18n/de.ts | 2 ++ packages/app/src/i18n/en.ts | 2 ++ packages/app/src/i18n/es.ts | 2 ++ packages/app/src/i18n/fr.ts | 2 ++ packages/app/src/i18n/ja.ts | 2 ++ packages/app/src/i18n/ko.ts | 2 ++ packages/app/src/i18n/no.ts | 2 ++ packages/app/src/i18n/pl.ts | 2 ++ packages/app/src/i18n/ru.ts | 2 ++ packages/app/src/i18n/th.ts | 2 ++ packages/app/src/i18n/zh.ts | 2 ++ packages/app/src/i18n/zht.ts | 2 ++ packages/app/src/pages/layout.tsx | 60 +++++++++++++++++++++++++++++++ 16 files changed, 90 insertions(+) diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index e3831e23c4..3718303e5a 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "فتح الإعدادات", "command.session.previous": "الجلسة السابقة", "command.session.next": "الجلسة التالية", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "أرشفة الجلسة", "command.palette": "لوحة الأوامر", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index f930a66aff..43336f8d6f 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Abrir configurações", "command.session.previous": "Sessão anterior", "command.session.next": "Próxima sessão", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Arquivar sessão", "command.palette": "Paleta de comandos", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 2b7d77456d..69e8e8114f 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Åbn indstillinger", "command.session.previous": "Forrige session", "command.session.next": "Næste session", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Arkivér session", "command.palette": "Kommandopalette", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 4648ad9c41..1c28e4a16e 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -32,6 +32,8 @@ export const dict = { "command.settings.open": "Einstellungen öffnen", "command.session.previous": "Vorherige Sitzung", "command.session.next": "Nächste Sitzung", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Sitzung archivieren", "command.palette": "Befehlspalette", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 12ddcb4cd8..5589337e56 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Open settings", "command.session.previous": "Previous session", "command.session.next": "Next session", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Archive session", "command.palette": "Command palette", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 5d396f0b4f..6e3eac0dd3 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Abrir ajustes", "command.session.previous": "Sesión anterior", "command.session.next": "Siguiente sesión", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Archivar sesión", "command.palette": "Paleta de comandos", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 4226d0c7e2..fa3dccd9af 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Ouvrir les paramètres", "command.session.previous": "Session précédente", "command.session.next": "Session suivante", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Archiver la session", "command.palette": "Palette de commandes", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 28a925a0d3..4fccbd77e7 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "設定を開く", "command.session.previous": "前のセッション", "command.session.next": "次のセッション", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "セッションをアーカイブ", "command.palette": "コマンドパレット", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 1be4e1eb4b..5b5d29c0e0 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -32,6 +32,8 @@ export const dict = { "command.settings.open": "설정 열기", "command.session.previous": "이전 세션", "command.session.next": "다음 세션", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "세션 보관", "command.palette": "명령 팔레트", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 0a3b398856..89614ce853 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -31,6 +31,8 @@ export const dict = { "command.settings.open": "Åpne innstillinger", "command.session.previous": "Forrige sesjon", "command.session.next": "Neste sesjon", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Arkiver sesjon", "command.palette": "Kommandopalett", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index f4457c6acf..b89921a9bc 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Otwórz ustawienia", "command.session.previous": "Poprzednia sesja", "command.session.next": "Następna sesja", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Zarchiwizuj sesję", "command.palette": "Paleta poleceń", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index d5a4014d36..e99abbd081 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Открыть настройки", "command.session.previous": "Предыдущая сессия", "command.session.next": "Следующая сессия", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Архивировать сессию", "command.palette": "Палитра команд", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 1914b8e5bd..0da6f9acc7 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "เปิดการตั้งค่า", "command.session.previous": "เซสชันก่อนหน้า", "command.session.next": "เซสชันถัดไป", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "จัดเก็บเซสชัน", "command.palette": "คำสั่งค้นหา", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index b9d5395730..a7e1659ec3 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -32,6 +32,8 @@ export const dict = { "command.settings.open": "打开设置", "command.session.previous": "上一个会话", "command.session.next": "下一个会话", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "归档会话", "command.palette": "命令面板", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 23d3d80e13..7b8849b9a0 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -32,6 +32,8 @@ export const dict = { "command.settings.open": "開啟設定", "command.session.previous": "上一個工作階段", "command.session.next": "下一個工作階段", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "封存工作階段", "command.palette": "命令面板", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 845a4fc834..a970bf667e 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -886,6 +886,52 @@ export default function Layout(props: ParentProps) { queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`)) } + function navigateSessionByUnseen(offset: number) { + const sessions = currentSessions() + if (sessions.length === 0) return + + const hasUnseen = sessions.some((session) => notification.session.unseen(session.id).length > 0) + if (!hasUnseen) return + + const activeIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1 + const start = activeIndex === -1 ? (offset > 0 ? -1 : 0) : activeIndex + + for (let i = 1; i <= sessions.length; i++) { + const index = offset > 0 ? (start + i) % sessions.length : (start - i + sessions.length) % sessions.length + const session = sessions[index] + if (!session) continue + if (notification.session.unseen(session.id).length === 0) continue + + prefetchSession(session, "high") + + const next = sessions[(index + 1) % sessions.length] + const prev = sessions[(index - 1 + sessions.length) % sessions.length] + + if (offset > 0) { + if (next) prefetchSession(next, "high") + if (prev) prefetchSession(prev) + } + + if (offset < 0) { + if (prev) prefetchSession(prev, "high") + if (next) prefetchSession(next) + } + + if (import.meta.env.DEV) { + navStart({ + dir: base64Encode(session.directory), + from: params.id, + to: session.id, + trigger: offset > 0 ? "shift+alt+arrowdown" : "shift+alt+arrowup", + }) + } + + navigateToSession(session) + queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`)) + return + } + } + async function archiveSession(session: Session) { const [store, setStore] = globalSync.child(session.directory) const sessions = store.session ?? [] @@ -1024,6 +1070,20 @@ export default function Layout(props: ParentProps) { keybind: "alt+arrowdown", onSelect: () => navigateSessionByOffset(1), }, + { + id: "session.previous.unseen", + title: language.t("command.session.previous.unseen"), + category: language.t("command.category.session"), + keybind: "shift+alt+arrowup", + onSelect: () => navigateSessionByUnseen(-1), + }, + { + id: "session.next.unseen", + title: language.t("command.session.next.unseen"), + category: language.t("command.category.session"), + keybind: "shift+alt+arrowdown", + onSelect: () => navigateSessionByUnseen(1), + }, { id: "session.archive", title: language.t("command.session.archive"), From 985090ef3cf5b5cbda97c7d1f280371a28e50b3c Mon Sep 17 00:00:00 2001 From: Lucio Delelis Date: Mon, 2 Feb 2026 08:20:30 -0300 Subject: [PATCH 18/64] fix(ui): adjusts alignment of elements to prevent incomplete scroll (#11649) --- packages/ui/src/components/message-nav.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/message-nav.css b/packages/ui/src/components/message-nav.css index b1454ad425..79bfdc0b34 100644 --- a/packages/ui/src/components/message-nav.css +++ b/packages/ui/src/components/message-nav.css @@ -103,7 +103,7 @@ display: flex; padding: 4px 4px 6px 4px; justify-content: center; - align-items: center; + align-items: start; border-radius: var(--radius-md); background: var(--surface-raised-stronger-non-alpha); max-height: calc(100vh - 6rem); From 43bb389e354fe5b631036f658c30421d4a5f1f5a Mon Sep 17 00:00:00 2001 From: Sam Huckaby Date: Mon, 2 Feb 2026 06:30:44 -0500 Subject: [PATCH 19/64] Fix(app): the Vesper theme's light mode (#9892) --- packages/ui/src/theme/themes/vesper.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/theme/themes/vesper.json b/packages/ui/src/theme/themes/vesper.json index 3c5e44cbd5..040bdc049b 100644 --- a/packages/ui/src/theme/themes/vesper.json +++ b/packages/ui/src/theme/themes/vesper.json @@ -18,8 +18,7 @@ "background-base": "#FFF", "background-weak": "#F8F8F8", "background-strong": "#F0F0F0", - "background-stronger": "#E8E8E8", - "border-weak-base": "#E8E8E8", + "background-stronger": "#FBFBFB", "border-weak-hover": "#E0E0E0", "border-weak-active": "#D8D8D8", "border-weak-selected": "#D0D0D0", @@ -41,14 +40,15 @@ "surface-diff-delete-base": "#f5e8e8", "surface-diff-hidden-base": "#F0F0F0", "text-base": "#101010", - "text-weak": "#A0A0A0", + "text-invert-strong": "var(--smoke-dark-alpha-12)", + "text-weak": "#606060", "text-strong": "#000000", - "syntax-string": "#99FFE4", - "syntax-primitive": "#FF8080", - "syntax-property": "#FFC799", - "syntax-type": "#FFC799", - "syntax-constant": "#A0A0A0", - "syntax-info": "#A0A0A0", + "syntax-string": "#0D5C4F", + "syntax-primitive": "#B30000", + "syntax-property": "#C66C00", + "syntax-type": "#9C5C12", + "syntax-constant": "#404040", + "syntax-info": "#606060", "markdown-heading": "#FFC799", "markdown-text": "#101010", "markdown-link": "#FFC799", From 26197ec95bac8560a637fb496ce34c14bde7bca5 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 05:42:55 -0600 Subject: [PATCH 20/64] chore: update website stats --- packages/console/app/src/config.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts index 1d99def1b9..be53ad909b 100644 --- a/packages/console/app/src/config.ts +++ b/packages/console/app/src/config.ts @@ -9,8 +9,8 @@ export const config = { github: { repoUrl: "https://github.com/anomalyco/opencode", starsFormatted: { - compact: "80K", - full: "80,000", + compact: "95K", + full: "95,000", }, }, @@ -22,8 +22,8 @@ export const config = { // Static stats (used on landing page) stats: { - contributors: "600", - commits: "7,500", - monthlyUsers: "1.5M", + contributors: "650", + commits: "8,500", + monthlyUsers: "2.5M", }, } as const From 52006c2fd93b00c216b4fa9f47f0e85ab8a43753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20S=C3=BAkup?= Date: Mon, 2 Feb 2026 13:01:49 +0100 Subject: [PATCH 21/64] feat(opencode): ormolu code formatter for haskell (#10274) --- packages/opencode/src/format/formatter.ts | 9 +++++++++ packages/web/src/content/docs/formatters.mdx | 1 + packages/web/src/content/docs/lsp.mdx | 1 + 3 files changed, 11 insertions(+) diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 5f0624d6c9..9e97fae9df 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -355,3 +355,12 @@ export const pint: Info = { return false }, } + +export const ormolu: Info = { + name: "ormolu", + command: ["ormolu", "-i", "$FILE"], + extensions: [".hs"], + async enabled() { + return Bun.which("ormolu") !== null + }, +} diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/formatters.mdx index 225875f625..54f36e0cd0 100644 --- a/packages/web/src/content/docs/formatters.mdx +++ b/packages/web/src/content/docs/formatters.mdx @@ -36,6 +36,7 @@ OpenCode comes with several built-in formatters for popular languages and framew | shfmt | .sh, .bash | `shfmt` command available | | pint | .php | `laravel/pint` dependency in `composer.json` | | oxfmt (Experimental) | .js, .jsx, .ts, .tsx | `oxfmt` dependency in `package.json` and an [experimental env variable flag](/docs/cli/#experimental) | +| ormolu | .hs | `ormolu` command available | So if your project has `prettier` in your `package.json`, OpenCode will automatically use it. diff --git a/packages/web/src/content/docs/lsp.mdx b/packages/web/src/content/docs/lsp.mdx index 707af84a01..ac788fc600 100644 --- a/packages/web/src/content/docs/lsp.mdx +++ b/packages/web/src/content/docs/lsp.mdx @@ -25,6 +25,7 @@ OpenCode comes with several built-in LSP servers for popular languages: | fsharp | .fs, .fsi, .fsx, .fsscript | `.NET SDK` installed | | gleam | .gleam | `gleam` command available | | gopls | .go | `go` command available | +| hls | .hs, .lhs | `haskell-language-server-wrapper` command available | jdtls | .java | `Java SDK (version 21+)` installed | | kotlin-ls | .kt, .kts | Auto-installs for Kotlin projects | | lua-ls | .lua | Auto-installs for Lua projects | From 6b17645f2eadb3d66d9ecd94e04d0ba85ff5d335 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 12:02:35 +0000 Subject: [PATCH 22/64] chore: generate --- packages/web/src/content/docs/lsp.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/lsp.mdx b/packages/web/src/content/docs/lsp.mdx index ac788fc600..95c306fcc0 100644 --- a/packages/web/src/content/docs/lsp.mdx +++ b/packages/web/src/content/docs/lsp.mdx @@ -25,7 +25,7 @@ OpenCode comes with several built-in LSP servers for popular languages: | fsharp | .fs, .fsi, .fsx, .fsscript | `.NET SDK` installed | | gleam | .gleam | `gleam` command available | | gopls | .go | `go` command available | -| hls | .hs, .lhs | `haskell-language-server-wrapper` command available +| hls | .hs, .lhs | `haskell-language-server-wrapper` command available | | jdtls | .java | `Java SDK (version 21+)` installed | | kotlin-ls | .kt, .kts | Auto-installs for Kotlin projects | | lua-ls | .lua | Auto-installs for Lua projects | From 50b5168c16c44093d176cebb342c86d005ec14f5 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:03:55 +0100 Subject: [PATCH 23/64] fix(desktop): added inverted svg for steps expanded for nice UX (#10462) Co-authored-by: opencode Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: opencode-agent[bot] --- packages/ui/src/components/session-turn.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 48d6337edb..29c5566a65 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -610,7 +610,7 @@ export function SessionTurn( - + + + + + + From 37979ea44fd3afb99f6c110aed55e93ffb877b59 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:06:45 +0100 Subject: [PATCH 24/64] feat(app): enhance responsive design with additional breakpoints for larger screen layout adjustments (#10459) Co-authored-by: opencode Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: opencode-agent[bot] --- packages/app/src/pages/session.tsx | 11 +++++++---- packages/ui/src/styles/tailwind/index.css | 3 +++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index d3e74072a8..d03c4186c5 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1940,7 +1940,8 @@ export default function Page() { "sticky top-0 z-30 bg-background-stronger": true, "w-full": true, "px-4 md:px-6": true, - "md:max-w-200 md:mx-auto": centered(), + "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto": + centered(), }} >
@@ -1968,7 +1969,8 @@ export default function Page() { class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]" classList={{ "w-full": true, - "md:max-w-200 md:mx-auto": centered(), + "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto": + centered(), "mt-0.5": centered(), "mt-0": !centered(), }} @@ -2021,7 +2023,8 @@ export default function Page() { data-message-id={message.id} classList={{ "min-w-0 w-full max-w-full": true, - "md:max-w-200": centered(), + "md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": + centered(), }} > diff --git a/packages/ui/src/styles/tailwind/index.css b/packages/ui/src/styles/tailwind/index.css index f7ce21ad96..d8b0b2a1a0 100644 --- a/packages/ui/src/styles/tailwind/index.css +++ b/packages/ui/src/styles/tailwind/index.css @@ -17,6 +17,9 @@ --breakpoint-lg: 64rem; --breakpoint-xl: 80rem; --breakpoint-2xl: 96rem; + --breakpoint-3xl: 112rem; + --breakpoint-4xl: 128rem; + --breakpoint-5xl: 144rem; --container-3xs: 16rem; --container-2xs: 18rem; From 34c58af796befb22cd557012ec70d3e520b393b9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 12:07:56 +0000 Subject: [PATCH 25/64] chore: generate --- packages/app/src/pages/session.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index d03c4186c5..da12be8f56 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1941,7 +1941,7 @@ export default function Page() { "w-full": true, "px-4 md:px-6": true, "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto": - centered(), + centered(), }} >
@@ -1970,7 +1970,7 @@ export default function Page() { classList={{ "w-full": true, "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto": - centered(), + centered(), "mt-0.5": centered(), "mt-0": !centered(), }} @@ -2023,8 +2023,7 @@ export default function Page() { data-message-id={message.id} classList={{ "min-w-0 w-full max-w-full": true, - "md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": - centered(), + "md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": centered(), }} > Date: Mon, 2 Feb 2026 18:20:06 +0530 Subject: [PATCH 26/64] feat(app): add tab close keybind (#11780) --- .../src/components/session/session-sortable-tab.tsx | 12 +++++++++--- packages/app/src/i18n/en.ts | 1 + packages/app/src/pages/session.tsx | 12 ++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index 06609fcfb8..516f3c8ede 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -3,11 +3,12 @@ import type { JSX } from "solid-js" import { createSortable } from "@thisbeyond/solid-dnd" import { FileIcon } from "@opencode-ai/ui/file-icon" import { IconButton } from "@opencode-ai/ui/icon-button" -import { Tooltip } from "@opencode-ai/ui/tooltip" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tabs } from "@opencode-ai/ui/tabs" import { getFilename } from "@opencode-ai/util/path" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" +import { useCommand } from "@/context/command" export function FileVisual(props: { path: string; active?: boolean }): JSX.Element { return ( @@ -27,6 +28,7 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element { const file = useFile() const language = useLanguage() + const command = useCommand() const sortable = createSortable(props.tab) const path = createMemo(() => file.pathFromTab(props.tab)) return ( @@ -36,7 +38,11 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v + v onClick={() => props.onTabClose(props.tab)} aria-label={language.t("common.closeTab")} /> - + } hideCloseButton onMiddleClick={() => props.onTabClose(props.tab)} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 5589337e56..169d09cd38 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -45,6 +45,7 @@ export const dict = { "command.session.new": "New session", "command.file.open": "Open file", "command.file.open.description": "Search files and commands", + "command.tab.close": "Close tab", "command.context.addSelection": "Add selection to context", "command.context.addSelection.description": "Add selected lines from the current file", "command.terminal.toggle": "Toggle terminal", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index da12be8f56..772ad063ba 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -689,6 +689,18 @@ export default function Page() { slash: "open", onSelect: () => dialog.show(() => showAllFiles()} />), }, + { + id: "tab.close", + title: language.t("command.tab.close"), + category: language.t("command.category.file"), + keybind: "mod+w", + disabled: !tabs().active(), + onSelect: () => { + const active = tabs().active() + if (!active) return + tabs().close(active) + }, + }, { id: "context.addSelection", title: language.t("command.context.addSelection"), From 4369d796368b0681f93c0da28725e147a263f56b Mon Sep 17 00:00:00 2001 From: Dax Date: Mon, 2 Feb 2026 09:30:10 -0500 Subject: [PATCH 27/64] tui: truncate session title in exit banner (#11797) --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f7d83b0555..9a000f953c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -225,10 +225,11 @@ export function Session() { const exit = useExit() createEffect(() => { + const title = Locale.truncate(session()?.title ?? "", 50) return exit.message.set( [ ``, - ` █▀▀█ ${UI.Style.TEXT_DIM}${session()?.title}${UI.Style.TEXT_NORMAL}`, + ` █▀▀█ ${UI.Style.TEXT_DIM}${title}${UI.Style.TEXT_NORMAL}`, ` █ █ ${UI.Style.TEXT_DIM}opencode -s ${session()?.id}${UI.Style.TEXT_NORMAL}`, ` ▀▀▀▀ `, ].join("\n"), From d63ed3bbe3d45842c7dcac623b6fda9d1b8d7630 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 2 Feb 2026 09:37:34 -0500 Subject: [PATCH 28/64] ci --- .github/workflows/publish.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a1b492258b..d7cb86cdf7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -148,6 +148,12 @@ jobs: sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + - name: install cross toolchain (aarch64) + if: matrix.settings.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-dev-arm64-cross + - name: install Rust stable uses: dtolnay/rust-toolchain@stable with: @@ -192,6 +198,8 @@ jobs: releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext] env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: ${{ matrix.settings.target == 'aarch64-unknown-linux-gnu' && 'aarch64-linux-gnu-gcc' || '' }} + PKG_CONFIG_ALLOW_CROSS: ${{ matrix.settings.target == 'aarch64-unknown-linux-gnu' && '1' || '' }} TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} From 423778c93a9a976f3755c31a0398766b2d0c1e3f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 2 Feb 2026 09:44:19 -0500 Subject: [PATCH 30/64] ci: reduce aarch64 build runner to 4 vcpu to lower infrastructure costs --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d7cb86cdf7..c610e3957d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -103,7 +103,7 @@ jobs: target: x86_64-pc-windows-msvc - host: blacksmith-4vcpu-ubuntu-2404 target: x86_64-unknown-linux-gnu - - host: blacksmith-8vcpu-ubuntu-2404-arm + - host: blacksmith-4vcpu-ubuntu-2404 target: aarch64-unknown-linux-gnu runs-on: ${{ matrix.settings.host }} steps: From 06d63ca54cacfce5af7fdab216ffe7f35d778642 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 2 Feb 2026 10:06:21 -0500 Subject: [PATCH 31/64] ci: use native ARM runner for faster Linux ARM builds Switch from cross-compilation on x86_64 to native ARM runner, which improves build speed and reliability for Linux ARM binary distribution. --- .github/workflows/publish.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c610e3957d..a1b492258b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -103,7 +103,7 @@ jobs: target: x86_64-pc-windows-msvc - host: blacksmith-4vcpu-ubuntu-2404 target: x86_64-unknown-linux-gnu - - host: blacksmith-4vcpu-ubuntu-2404 + - host: blacksmith-8vcpu-ubuntu-2404-arm target: aarch64-unknown-linux-gnu runs-on: ${{ matrix.settings.host }} steps: @@ -148,12 +148,6 @@ jobs: sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - - name: install cross toolchain (aarch64) - if: matrix.settings.target == 'aarch64-unknown-linux-gnu' - run: | - sudo apt-get update - sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-dev-arm64-cross - - name: install Rust stable uses: dtolnay/rust-toolchain@stable with: @@ -198,8 +192,6 @@ jobs: releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext] env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: ${{ matrix.settings.target == 'aarch64-unknown-linux-gnu' && 'aarch64-linux-gnu-gcc' || '' }} - PKG_CONFIG_ALLOW_CROSS: ${{ matrix.settings.target == 'aarch64-unknown-linux-gnu' && '1' || '' }} TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} From 1bd5dc5382cfa8b57dc470970bcdfa6a3dbd8dfb Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Mon, 2 Feb 2026 18:13:48 +0200 Subject: [PATCH 32/64] ci: add ratelimits handling for close-stale-prs.yml (#11578) --- .github/workflows/close-stale-prs.yml | 132 ++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 19 deletions(-) diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml index e1ff4241c9..e0e571b469 100644 --- a/.github/workflows/close-stale-prs.yml +++ b/.github/workflows/close-stale-prs.yml @@ -18,6 +18,7 @@ permissions: jobs: close-stale-prs: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Close inactive PRs uses: actions/github-script@v8 @@ -25,6 +26,15 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const DAYS_INACTIVE = 60 + const MAX_RETRIES = 3 + + // Adaptive delay: fast for small batches, slower for large to respect + // GitHub's 80 content-generating requests/minute limit + const SMALL_BATCH_THRESHOLD = 10 + const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs) + const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit + + const startTime = Date.now() const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000) const { owner, repo } = context.repo const dryRun = context.payload.inputs?.dryRun === "true" @@ -32,6 +42,42 @@ jobs: core.info(`Dry run mode: ${dryRun}`) core.info(`Cutoff date: ${cutoff.toISOString()}`) + function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) + } + + async function withRetry(fn, description = 'API call') { + let lastError + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + const result = await fn() + return result + } catch (error) { + lastError = error + const isRateLimited = error.status === 403 && + (error.message?.includes('rate limit') || error.message?.includes('secondary')) + + if (!isRateLimited) { + throw error + } + + // Parse retry-after header, default to 60 seconds + const retryAfter = error.response?.headers?.['retry-after'] + ? parseInt(error.response.headers['retry-after']) + : 60 + + // Exponential backoff: retryAfter * 2^attempt + const backoffMs = retryAfter * 1000 * Math.pow(2, attempt) + + core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`) + + await sleep(backoffMs) + } + } + core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`) + throw lastError + } + const query = ` query($owner: String!, $repo: String!, $cursor: String) { repository(owner: $owner, name: $repo) { @@ -73,17 +119,27 @@ jobs: const allPrs = [] let cursor = null let hasNextPage = true + let pageCount = 0 while (hasNextPage) { - const result = await github.graphql(query, { - owner, - repo, - cursor, - }) + pageCount++ + core.info(`Fetching page ${pageCount} of open PRs...`) + + const result = await withRetry( + () => github.graphql(query, { owner, repo, cursor }), + `GraphQL page ${pageCount}` + ) allPrs.push(...result.repository.pullRequests.nodes) hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage cursor = result.repository.pullRequests.pageInfo.endCursor + + core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`) + + // Delay between pagination requests (use small batch delay for reads) + if (hasNextPage) { + await sleep(SMALL_BATCH_DELAY_MS) + } } core.info(`Found ${allPrs.length} open pull requests`) @@ -114,28 +170,66 @@ jobs: core.info(`Found ${stalePrs.length} stale pull requests`) + // ============================================ + // Close stale PRs + // ============================================ + const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD + ? LARGE_BATCH_DELAY_MS + : SMALL_BATCH_DELAY_MS + + core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`) + + let closedCount = 0 + let skippedCount = 0 + for (const pr of stalePrs) { const issue_number = pr.number const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.` if (dryRun) { - core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author.login}: ${pr.title}`) + core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`) continue } - await github.rest.issues.createComment({ - owner, - repo, - issue_number, - body: closeComment, - }) + try { + // Add comment + await withRetry( + () => github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: closeComment, + }), + `Comment on PR #${issue_number}` + ) - await github.rest.pulls.update({ - owner, - repo, - pull_number: issue_number, - state: "closed", - }) + // Close PR + await withRetry( + () => github.rest.pulls.update({ + owner, + repo, + pull_number: issue_number, + state: "closed", + }), + `Close PR #${issue_number}` + ) - core.info(`Closed PR #${issue_number} from ${pr.author.login}: ${pr.title}`) + closedCount++ + core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`) + + // Delay before processing next PR + await sleep(requestDelayMs) + } catch (error) { + skippedCount++ + core.error(`Failed to close PR #${issue_number}: ${error.message}`) + } } + + const elapsed = Math.round((Date.now() - startTime) / 1000) + core.info(`\n========== Summary ==========`) + core.info(`Total open PRs found: ${allPrs.length}`) + core.info(`Stale PRs identified: ${stalePrs.length}`) + core.info(`PRs closed: ${closedCount}`) + core.info(`PRs skipped (errors): ${skippedCount}`) + core.info(`Elapsed time: ${elapsed}s`) + core.info(`=============================`) From cf8b033be1cbe9f20bc0921d9920a66c0d95c704 Mon Sep 17 00:00:00 2001 From: Vladimir Glafirov Date: Mon, 2 Feb 2026 17:41:02 +0100 Subject: [PATCH 33/64] feat(provider): add User-Agent header for GitLab AI Gateway requests (#11818) --- bun.lock | 4 ++-- packages/opencode/package.json | 2 +- packages/opencode/src/provider/provider.ts | 11 ++++++++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index c98144251c..b1a34e5fa4 100644 --- a/bun.lock +++ b/bun.lock @@ -286,7 +286,7 @@ "@ai-sdk/vercel": "1.0.33", "@ai-sdk/xai": "2.0.56", "@clack/prompts": "1.0.0-alpha.1", - "@gitlab/gitlab-ai-provider": "3.3.1", + "@gitlab/gitlab-ai-provider": "3.4.0", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", @@ -925,7 +925,7 @@ "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], - "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.3.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-J4/LfVcxOKbR2gfoBWRKp1BpWppprC2Cz/Ff5E0B/0lS341CDtZwzkgWvHfkM/XU6q83JRs059dS0cR8VOODOQ=="], + "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.4.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-1fEZgqjSZ0WLesftw/J5UtFuJCYFDvCZCHhTH5PZAmpDEmCwllJBoe84L3+vIk38V2FGDMTW128iKTB2mVzr3A=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c86aa734d8..cc0e84d9f2 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -70,7 +70,7 @@ "@ai-sdk/vercel": "1.0.33", "@ai-sdk/xai": "2.0.56", "@clack/prompts": "1.0.0-alpha.1", - "@gitlab/gitlab-ai-provider": "3.3.1", + "@gitlab/gitlab-ai-provider": "3.4.0", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index e01c583ff3..27a86a2fcc 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1,4 +1,5 @@ import z from "zod" +import os from "os" import fuzzysort from "fuzzysort" import { Config } from "../config/config" import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda" @@ -35,8 +36,9 @@ import { createGateway } from "@ai-sdk/gateway" import { createTogetherAI } from "@ai-sdk/togetherai" import { createPerplexity } from "@ai-sdk/perplexity" import { createVercel } from "@ai-sdk/vercel" -import { createGitLab } from "@gitlab/gitlab-ai-provider" +import { createGitLab, VERSION as GITLAB_PROVIDER_VERSION } from "@gitlab/gitlab-ai-provider" import { ProviderTransform } from "./transform" +import { Installation } from "../installation" export namespace Provider { const log = Log.create({ service: "provider" }) @@ -424,11 +426,17 @@ export namespace Provider { const config = await Config.get() const providerConfig = config.provider?.["gitlab"] + const aiGatewayHeaders = { + "User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`, + ...(providerConfig?.options?.aiGatewayHeaders || {}), + } + return { autoload: !!apiKey, options: { instanceUrl, apiKey, + aiGatewayHeaders, featureFlags: { duo_agent_platform_agentic_chat: true, duo_agent_platform: true, @@ -437,6 +445,7 @@ export namespace Provider { }, async getModel(sdk: ReturnType, modelID: string) { return sdk.agenticChat(modelID, { + aiGatewayHeaders, featureFlags: { duo_agent_platform_agentic_chat: true, duo_agent_platform: true, From cf828fff85b50baf8c57cc3811c8789d9adbcae2 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 16:56:31 +0000 Subject: [PATCH 34/64] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 5180219533..431148b1fd 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-4I0lpBnbAi7IZMURTMLysjrqdsNvXJf8802NrJnpdks=", - "aarch64-linux": "sha256-WOGKsPlcQVSbL8TDr1JYO/2ucPTV2Hy0TXJKWv8EoVw=", - "aarch64-darwin": "sha256-LuvjwGm1QsHoLxuvSSp4VsDIv02Z/rTONsU32arQMuw=", - "x86_64-darwin": "sha256-AbglfgCWj/r+wHfle+e+D3b/xPcwwg4IK7j5iwn9nzw=" + "x86_64-linux": "sha256-yIrljJgOR1GZCAXi5bx+YvrIAjSkTAMTSzlhLFY/ufE=", + "aarch64-linux": "sha256-Xa3BgqbuD5Cx5OpyVSN1v7Klge449hPqR1GY9E9cAX0=", + "aarch64-darwin": "sha256-Q3FKm7+4Jr3PL+TnQngrTtv/xdek2st5HmgeoEOHUis=", + "x86_64-darwin": "sha256-asJ8DBvIgkqh8HhrN48M/L4xj1kwv+uyQMy9bN2HxuM=" } } From 965f32ad634d208bbb34c5a9bb12e501a009378b Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Mon, 2 Feb 2026 18:36:47 +0100 Subject: [PATCH 35/64] fix(tui): respect terminal transparency in system theme (#8467) --- packages/opencode/src/cli/cmd/tui/context/theme.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 7cde1b9648..41c5a4a831 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -41,7 +41,6 @@ import { useRenderer } from "@opentui/solid" import { createStore, produce } from "solid-js/store" import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" -import { useSDK } from "./sdk" type ThemeColors = { primary: RGBA @@ -429,6 +428,7 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA { function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson { const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!) const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!) + const transparent = RGBA.fromInts(0, 0, 0, 0) const isDark = mode == "dark" const col = (i: number) => { @@ -479,8 +479,8 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs textMuted, selectedListItemText: bg, - // Background colors - background: bg, + // Background colors - use transparent to respect terminal transparency + background: transparent, backgroundPanel: grays[2], backgroundElement: grays[3], backgroundMenu: grays[3], From b9aad20be651050880bf2bc3b4c857f16a970402 Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Mon, 2 Feb 2026 19:15:53 +0100 Subject: [PATCH 36/64] fix(app): open project search (#11783) --- .../components/dialog-select-directory.tsx | 182 +++++++++++++++--- packages/ui/src/components/list.tsx | 45 +++-- 2 files changed, 179 insertions(+), 48 deletions(-) diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index b9a7d6ed9b..6e7af3d902 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -4,10 +4,11 @@ import { FileIcon } from "@opencode-ai/ui/file-icon" import { List } from "@opencode-ai/ui/list" import { getDirectory, getFilename } from "@opencode-ai/util/path" import fuzzysort from "fuzzysort" -import { createMemo } from "solid-js" +import { createMemo, createResource, createSignal } from "solid-js" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" +import type { ListRef } from "@opencode-ai/ui/list" interface DialogSelectDirectoryProps { title?: string @@ -15,18 +16,47 @@ interface DialogSelectDirectoryProps { onSelect: (result: string | string[] | null) => void } +type Row = { + absolute: string + search: string +} + export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const sync = useGlobalSync() const sdk = useGlobalSDK() const dialog = useDialog() const language = useLanguage() - const home = createMemo(() => sync.data.path.home) + const [filter, setFilter] = createSignal("") - const start = createMemo(() => sync.data.path.home || sync.data.path.directory) + let list: ListRef | undefined + + const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) + + const [fallbackPath] = createResource( + () => (missingBase() ? true : undefined), + async () => { + return sdk.client.path + .get() + .then((x) => x.data) + .catch(() => undefined) + }, + { initialValue: undefined }, + ) + + const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") + + const start = createMemo( + () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory, + ) const cache = new Map>>() + const clean = (value: string) => { + const first = (value ?? "").split(/\r?\n/)[0] ?? "" + return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() + } + function normalize(input: string) { const v = input.replaceAll("\\", "/") if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/") @@ -64,24 +94,67 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { return "" } - function display(path: string) { + function parentOf(input: string) { + const v = trimTrailing(input) + if (v === "/") return v + if (v === "//") return v + if (/^[A-Za-z]:\/$/.test(v)) return v + + const i = v.lastIndexOf("/") + if (i <= 0) return "/" + if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3) + return v.slice(0, i) + } + + function modeOf(input: string) { + const raw = normalizeDriveRoot(input.trim()) + if (!raw) return "relative" as const + if (raw.startsWith("~")) return "tilde" as const + if (rootOf(raw)) return "absolute" as const + return "relative" as const + } + + function display(path: string, input: string) { const full = trimTrailing(path) + if (modeOf(input) === "absolute") return full + + return tildeOf(full) || full + } + + function tildeOf(absolute: string) { + const full = trimTrailing(absolute) const h = home() - if (!h) return full + if (!h) return "" const hn = trimTrailing(h) const lc = full.toLowerCase() const hc = hn.toLowerCase() if (lc === hc) return "~" if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length) - return full + return "" } - function scoped(filter: string) { + function row(absolute: string): Row { + const full = trimTrailing(absolute) + const tilde = tildeOf(full) + + const withSlash = (value: string) => { + if (!value) return "" + if (value.endsWith("/")) return value + return value + "/" + } + + const search = Array.from( + new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)), + ).join("\n") + return { absolute: full, search } + } + + function scoped(value: string) { const base = start() if (!base) return - const raw = normalizeDriveRoot(filter.trim()) + const raw = normalizeDriveRoot(value) if (!raw) return { directory: trimTrailing(base), path: "" } const h = home() @@ -122,21 +195,25 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { } const directories = async (filter: string) => { - const input = scoped(filter) - if (!input) return [] as string[] + const value = clean(filter) + const scopedInput = scoped(value) + if (!scopedInput) return [] as string[] - const raw = normalizeDriveRoot(filter.trim()) + const raw = normalizeDriveRoot(value) const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/") - const query = normalizeDriveRoot(input.path) + const query = normalizeDriveRoot(scopedInput.path) - if (!isPath) { - const results = await sdk.client.find - .files({ directory: input.directory, query, type: "directory", limit: 50 }) + const find = () => + sdk.client.find + .files({ directory: scopedInput.directory, query, type: "directory", limit: 50 }) .then((x) => x.data ?? []) .catch(() => []) - return results.map((rel) => join(input.directory, rel)).slice(0, 50) + if (!isPath) { + const results = await find() + + return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50) } const segments = query.replace(/^\/+/, "").split("/") @@ -145,17 +222,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const cap = 12 const branch = 4 - let paths = [input.directory] + let paths = [scopedInput.directory] for (const part of head) { if (part === "..") { - paths = paths.map((p) => { - const v = trimTrailing(p) - if (v === "/") return v - if (/^[A-Za-z]:\/$/.test(v)) return v - const i = v.lastIndexOf("/") - if (i <= 0) return "/" - return v.slice(0, i) - }) + paths = paths.map(parentOf) continue } @@ -165,7 +235,27 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { } const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat() - return Array.from(new Set(out)).slice(0, 50) + const deduped = Array.from(new Set(out)) + const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : "" + const expand = !raw.endsWith("/") + if (!expand || !tail) { + const items = base ? Array.from(new Set([base, ...deduped])) : deduped + return items.slice(0, 50) + } + + const needle = tail.toLowerCase() + const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle) + const target = exact[0] + if (!target) return deduped.slice(0, 50) + + const children = await match(target, "", 30) + const items = Array.from(new Set([...deduped, ...children])) + return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50) + } + + const items = async (value: string) => { + const results = await directories(value) + return results.map(row) } function resolve(absolute: string) { @@ -179,24 +269,52 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }} emptyMessage={language.t("dialog.directory.empty")} loadingMessage={language.t("common.loading")} - items={directories} - key={(x) => x} + items={items} + key={(x) => x.absolute} + filterKeys={["search"]} + ref={(r) => (list = r)} + onFilter={(value) => setFilter(clean(value))} + onKeyEvent={(e, item) => { + if (e.key !== "Tab") return + if (e.shiftKey) return + if (!item) return + + e.preventDefault() + e.stopPropagation() + + const value = display(item.absolute, filter()) + list?.setFilter(value.endsWith("/") ? value : value + "/") + }} onSelect={(path) => { if (!path) return - resolve(path) + resolve(path.absolute) }} > - {(absolute) => { - const path = display(absolute) + {(item) => { + const path = display(item.absolute, filter()) + if (path === "~") { + return ( +
+
+ +
+ ~ + / +
+
+
+ ) + } return (
- +
{getDirectory(path)} {getFilename(path)} + /
diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 15854180e4..886ac5e6c8 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -51,6 +51,7 @@ export interface ListProps extends FilteredListProps { export interface ListRef { onKeyDown: (e: KeyboardEvent) => void setScrollRef: (el: HTMLDivElement | undefined) => void + setFilter: (value: string) => void } export function List(props: ListProps & { ref?: (ref: ListRef) => void }) { @@ -80,7 +81,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) container.scrollTop = Math.max(0, Math.min(target, max)) } - const { filter, grouped, flat, active, setActive, onKeyDown, onInput } = useFilteredList(props) + const { filter, grouped, flat, active, setActive, onKeyDown, onInput, refetch } = useFilteredList(props) const searchProps = () => (typeof props.search === "object" ? props.search : {}) const searchAction = () => searchProps().action @@ -89,21 +90,29 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) const moved = (event: MouseEvent) => event.movementX !== 0 || event.movementY !== 0 - createEffect(() => { - if (props.filter !== undefined) { - onInput(props.filter) - } - }) + const applyFilter = (value: string, options?: { ref?: boolean }) => { + const prev = filter() + setInternalFilter(value) + onInput(value) + props.onFilter?.(value) - createEffect((prev) => { - if (!props.search) return - const current = internalFilter() - if (prev !== current) { - onInput(current) - props.onFilter?.(current) + if (!options?.ref) return + + // Force a refetch even if the value is unchanged. + // This is important for programmatic changes like Tab completion. + if (prev === value) { + refetch() + return } - return current - }, "") + queueMicrotask(() => refetch()) + } + + createEffect(() => { + if (props.filter === undefined) return + if (props.filter === internalFilter()) return + setInternalFilter(props.filter) + onInput(props.filter) + }) createEffect( on( @@ -163,6 +172,8 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) const index = selected ? all.indexOf(selected) : -1 props.onKeyEvent?.(e, selected) + if (e.defaultPrevented) return + if (e.key === "Enter" && !e.isComposing) { e.preventDefault() if (selected) handleSelect(selected, index) @@ -174,6 +185,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) props.ref?.({ onKeyDown: handleKey, setScrollRef, + setFilter: (value) => applyFilter(value, { ref: true }), }) const renderAdd = () => { @@ -247,7 +259,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) data-slot="list-search-input" type="text" value={internalFilter()} - onChange={setInternalFilter} + onChange={(value) => applyFilter(value)} onKeyDown={handleKey} placeholder={searchProps().placeholder} spellcheck={false} @@ -260,7 +272,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) setInternalFilter("")} + onClick={() => applyFilter("")} aria-label={i18n.t("ui.list.clearFilter")} /> @@ -295,6 +307,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) data-active={props.key(item) === active()} data-selected={item === props.current} onClick={() => handleSelect(item, i())} + onKeyDown={handleKey} type="button" onMouseMove={(event) => { if (!moved(event)) return From ea1aba4192fd356603e807144edf202328008ee6 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 07:02:40 -0600 Subject: [PATCH 37/64] feat(app): project context menu on right-click --- packages/app/src/pages/layout.tsx | 107 +++++-- packages/ui/src/components/context-menu.css | 134 +++++++++ packages/ui/src/components/context-menu.tsx | 308 ++++++++++++++++++++ packages/ui/src/styles/index.css | 1 + 4 files changed, 521 insertions(+), 29 deletions(-) create mode 100644 packages/ui/src/components/context-menu.css create mode 100644 packages/ui/src/components/context-menu.tsx diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index a970bf667e..5a8dc0f2ea 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -31,6 +31,7 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { HoverCard } from "@opencode-ai/ui/hover-card" import { MessageNav } from "@opencode-ai/ui/message-nav" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { ContextMenu } from "@opencode-ai/ui/context-menu" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Spinner } from "@opencode-ai/ui/spinner" @@ -2310,10 +2311,13 @@ export default function Layout(props: ParentProps) { () => props.project.vcs === "git" && layout.sidebar.workspaces(props.project.worktree)(), ) const [open, setOpen] = createSignal(false) + const [menu, setMenu] = createSignal(false) const preview = createMemo(() => !props.mobile && layout.sidebar.opened()) const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened()) - const active = createMemo(() => (preview() ? open() : overlay() && state.hoverProject === props.project.worktree)) + const active = createMemo( + () => menu() || (preview() ? open() : overlay() && state.hoverProject === props.project.worktree), + ) createEffect(() => { if (preview()) return @@ -2352,35 +2356,79 @@ export default function Layout(props: ParentProps) { const projectName = () => props.project.name || getFilename(props.project.worktree) const trigger = ( - + { + if (!overlay()) return + globalSync.child(props.project.worktree) + setState("hoverProject", props.project.worktree) + setState("hoverSession", undefined) + }} + onFocus={() => { + if (!overlay()) return + globalSync.child(props.project.worktree) + setState("hoverProject", props.project.worktree) + setState("hoverSession", undefined) + }} + onClick={() => navigateToProject(props.project.worktree)} + onBlur={() => setOpen(false)} + > + + + + + dialog.show(() => )}> + {language.t("common.edit")} + + { + const enabled = layout.sidebar.workspaces(props.project.worktree)() + if (enabled) { + layout.sidebar.toggleWorkspaces(props.project.worktree) + return + } + if (props.project.vcs !== "git") return + layout.sidebar.toggleWorkspaces(props.project.worktree) + }} + > + + {layout.sidebar.workspaces(props.project.worktree)() + ? language.t("sidebar.workspaces.disable") + : language.t("sidebar.workspaces.enable")} + + + + closeProject(props.project.worktree)} + > + {language.t("common.close")} + + + + ) return ( @@ -2388,13 +2436,14 @@ export default function Layout(props: ParentProps) {
{ + if (menu()) return setOpen(value) if (value) setState("hoverSession", undefined) }} diff --git a/packages/ui/src/components/context-menu.css b/packages/ui/src/components/context-menu.css new file mode 100644 index 0000000000..1e366dccd4 --- /dev/null +++ b/packages/ui/src/components/context-menu.css @@ -0,0 +1,134 @@ +[data-component="context-menu-content"], +[data-component="context-menu-sub-content"] { + min-width: 8rem; + overflow: hidden; + border: none; + border-radius: var(--radius-md); + box-shadow: var(--shadow-xs-border); + background-clip: padding-box; + background-color: var(--surface-raised-stronger-non-alpha); + padding: 4px; + z-index: 100; + transform-origin: var(--kb-menu-content-transform-origin); + + &:focus-within, + &:focus { + outline: none; + } + + animation: contextMenuContentHide var(--transition-duration) var(--transition-easing) forwards; + + @starting-style { + animation: none; + } + + &[data-expanded] { + pointer-events: auto; + animation: contextMenuContentShow var(--transition-duration) var(--transition-easing) forwards; + } +} + +[data-component="context-menu-content"], +[data-component="context-menu-sub-content"] { + [data-slot="context-menu-item"], + [data-slot="context-menu-checkbox-item"], + [data-slot="context-menu-radio-item"], + [data-slot="context-menu-sub-trigger"] { + position: relative; + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border-radius: var(--radius-sm); + cursor: default; + outline: none; + + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-strong); + + transition-property: background-color, color; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); + user-select: none; + + &:hover { + background-color: var(--surface-raised-base-hover); + } + + &[data-disabled] { + color: var(--text-weak); + pointer-events: none; + } + } + + [data-slot="context-menu-sub-trigger"] { + &[data-expanded] { + background: var(--surface-raised-base-hover); + outline: none; + border: none; + } + } + + [data-slot="context-menu-item-indicator"] { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + } + + [data-slot="context-menu-item-label"] { + flex: 1; + } + + [data-slot="context-menu-item-description"] { + font-size: var(--font-size-x-small); + color: var(--text-weak); + } + + [data-slot="context-menu-separator"] { + height: 1px; + margin: 4px -4px; + border-top-color: var(--border-weak-base); + } + + [data-slot="context-menu-group-label"] { + padding: 4px 8px; + font-family: var(--font-family-sans); + font-size: var(--font-size-x-small); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-weak); + } + + [data-slot="context-menu-arrow"] { + fill: var(--surface-raised-stronger-non-alpha); + } +} + +@keyframes contextMenuContentShow { + from { + opacity: 0; + transform: scaleY(0.95); + } + to { + opacity: 1; + transform: scaleY(1); + } +} + +@keyframes contextMenuContentHide { + from { + opacity: 1; + transform: scaleY(1); + } + to { + opacity: 0; + transform: scaleY(0.95); + } +} diff --git a/packages/ui/src/components/context-menu.tsx b/packages/ui/src/components/context-menu.tsx new file mode 100644 index 0000000000..afdaff7b80 --- /dev/null +++ b/packages/ui/src/components/context-menu.tsx @@ -0,0 +1,308 @@ +import { ContextMenu as Kobalte } from "@kobalte/core/context-menu" +import { splitProps } from "solid-js" +import type { ComponentProps, ParentProps } from "solid-js" + +export interface ContextMenuProps extends ComponentProps {} +export interface ContextMenuTriggerProps extends ComponentProps {} +export interface ContextMenuIconProps extends ComponentProps {} +export interface ContextMenuPortalProps extends ComponentProps {} +export interface ContextMenuContentProps extends ComponentProps {} +export interface ContextMenuArrowProps extends ComponentProps {} +export interface ContextMenuSeparatorProps extends ComponentProps {} +export interface ContextMenuGroupProps extends ComponentProps {} +export interface ContextMenuGroupLabelProps extends ComponentProps {} +export interface ContextMenuItemProps extends ComponentProps {} +export interface ContextMenuItemLabelProps extends ComponentProps {} +export interface ContextMenuItemDescriptionProps extends ComponentProps {} +export interface ContextMenuItemIndicatorProps extends ComponentProps {} +export interface ContextMenuRadioGroupProps extends ComponentProps {} +export interface ContextMenuRadioItemProps extends ComponentProps {} +export interface ContextMenuCheckboxItemProps extends ComponentProps {} +export interface ContextMenuSubProps extends ComponentProps {} +export interface ContextMenuSubTriggerProps extends ComponentProps {} +export interface ContextMenuSubContentProps extends ComponentProps {} + +function ContextMenuRoot(props: ContextMenuProps) { + return +} + +function ContextMenuTrigger(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuIcon(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuPortal(props: ContextMenuPortalProps) { + return +} + +function ContextMenuContent(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuArrow(props: ContextMenuArrowProps) { + const [local, rest] = splitProps(props, ["class", "classList"]) + return ( + + ) +} + +function ContextMenuSeparator(props: ContextMenuSeparatorProps) { + const [local, rest] = splitProps(props, ["class", "classList"]) + return ( + + ) +} + +function ContextMenuGroup(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuGroupLabel(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuItem(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuItemLabel(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuItemDescription(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuItemIndicator(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuRadioGroup(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuRadioItem(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuCheckboxItem(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuSub(props: ContextMenuSubProps) { + return +} + +function ContextMenuSubTrigger(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuSubContent(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +export const ContextMenu = Object.assign(ContextMenuRoot, { + Trigger: ContextMenuTrigger, + Icon: ContextMenuIcon, + Portal: ContextMenuPortal, + Content: ContextMenuContent, + Arrow: ContextMenuArrow, + Separator: ContextMenuSeparator, + Group: ContextMenuGroup, + GroupLabel: ContextMenuGroupLabel, + Item: ContextMenuItem, + ItemLabel: ContextMenuItemLabel, + ItemDescription: ContextMenuItemDescription, + ItemIndicator: ContextMenuItemIndicator, + RadioGroup: ContextMenuRadioGroup, + RadioItem: ContextMenuRadioItem, + CheckboxItem: ContextMenuCheckboxItem, + Sub: ContextMenuSub, + SubTrigger: ContextMenuSubTrigger, + SubContent: ContextMenuSubContent, +}) diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 2a8171f98c..d5939b2b36 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -16,6 +16,7 @@ @import "../components/collapsible.css" layer(components); @import "../components/diff.css" layer(components); @import "../components/diff-changes.css" layer(components); +@import "../components/context-menu.css" layer(components); @import "../components/dropdown-menu.css" layer(components); @import "../components/dialog.css" layer(components); @import "../components/file-icon.css" layer(components); From 30a25e4edca0f3476ca63f83dbe95fcee75113e3 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:21:08 -0600 Subject: [PATCH 38/64] fix(app): user messages not rendering consistently --- packages/app/src/components/prompt-input.tsx | 4 +- packages/app/src/context/global-sync.tsx | 16 +++--- packages/app/src/context/sync.tsx | 10 ++-- packages/app/src/pages/layout.tsx | 52 +++++++++++++++----- packages/ui/src/components/session-turn.tsx | 8 +-- 5 files changed, 61 insertions(+), 29 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 2b63b6f5fd..1c84c36104 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1463,7 +1463,7 @@ export const PromptInput: Component = (props) => { draft.part[messageID] = optimisticParts .filter((p) => !!p?.id) .slice() - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }), ) return @@ -1481,7 +1481,7 @@ export const PromptInput: Component = (props) => { draft.part[messageID] = optimisticParts .filter((p) => !!p?.id) .slice() - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }), ) } diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index ad3d124b2c..0facbdfff4 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -119,6 +119,8 @@ type ChildOptions = { bootstrap?: boolean } +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + function normalizeProviderList(input: ProviderListResponse): ProviderListResponse { return { ...input, @@ -297,7 +299,7 @@ function createGlobalSync() { const aUpdated = sessionUpdatedAt(a) const bUpdated = sessionUpdatedAt(b) if (aUpdated !== bUpdated) return bUpdated - aUpdated - return a.id.localeCompare(b.id) + return cmp(a.id, b.id) } function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) { @@ -325,7 +327,7 @@ function createGlobalSync() { const all = input .filter((s) => !!s?.id) .filter((s) => !s.time?.archived) - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => cmp(a.id, b.id)) const roots = all.filter((s) => !s.parentID) const children = all.filter((s) => !!s.parentID) @@ -342,7 +344,7 @@ function createGlobalSync() { return sessionUpdatedAt(s) > cutoff }) - return [...keepRoots, ...keepChildren].sort((a, b) => a.id.localeCompare(b.id)) + return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id)) } function ensureChild(directory: string) { @@ -457,7 +459,7 @@ function createGlobalSync() { const nonArchived = (x.data ?? []) .filter((s) => !!s?.id) .filter((s) => !s.time?.archived) - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => cmp(a.id, b.id)) // Read the current limit at resolve-time so callers that bump the limit while // a request is in-flight still get the expanded result. @@ -559,7 +561,7 @@ function createGlobalSync() { "permission", sessionID, reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)), + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), { key: "id" }, ), ) @@ -588,7 +590,7 @@ function createGlobalSync() { "question", sessionID, reconcile( - questions.filter((q) => !!q?.id).sort((a, b) => a.id.localeCompare(b.id)), + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), { key: "id" }, ), ) @@ -986,7 +988,7 @@ function createGlobalSync() { .filter((p) => !!p?.id) .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) .slice() - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => cmp(a.id, b.id)) setGlobalStore("project", projects) }), ), diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 5c8e140c39..0c63652450 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -9,6 +9,8 @@ import type { Message, Part } from "@opencode-ai/sdk/v2/client" const keyFor = (directory: string, id: string) => `${directory}\n${id}` +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", init: () => { @@ -59,7 +61,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const next = items .map((x) => x.info) .filter((m) => !!m?.id) - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => cmp(a.id, b.id)) batch(() => { input.setStore("message", input.sessionID, reconcile(next, { key: "id" })) @@ -69,7 +71,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ "part", message.info.id, reconcile( - message.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)), + message.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), { key: "id" }, ), ) @@ -129,7 +131,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const result = Binary.search(messages, input.messageID, (m) => m.id) messages.splice(result.index, 0, message) } - draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)) + draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)) }), ) }, @@ -271,7 +273,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ await client.session.list().then((x) => { const sessions = (x.data ?? []) .filter((s) => !!s?.id) - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => cmp(a.id, b.id)) .slice(0, store.limit) setStore("session", reconcile(sessions, { key: "id" })) }) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 5a8dc0f2ea..202443ee70 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -499,7 +499,7 @@ export default function Layout(props: ParentProps) { const bUpdated = b.time.updated ?? b.time.created const aRecent = aUpdated > oneMinuteAgo const bRecent = bUpdated > oneMinuteAgo - if (aRecent && bRecent) return a.id.localeCompare(b.id) + if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 if (aRecent && !bRecent) return -1 if (!aRecent && bRecent) return 1 return bUpdated - aUpdated @@ -739,7 +739,7 @@ export default function Layout(props: ParentProps) { } async function prefetchMessages(directory: string, sessionID: string, token: number) { - const [, setStore] = globalSync.child(directory, { bootstrap: false }) + const [store, setStore] = globalSync.child(directory, { bootstrap: false }) return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) .then((messages) => { @@ -750,23 +750,49 @@ export default function Layout(props: ParentProps) { .map((x) => x.info) .filter((m) => !!m?.id) .slice() - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + + const current = store.message[sessionID] ?? [] + const merged = (() => { + if (current.length === 0) return next + + const map = new Map() + for (const item of current) { + if (!item?.id) continue + map.set(item.id, item) + } + for (const item of next) { + map.set(item.id, item) + } + return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + })() batch(() => { - setStore("message", sessionID, reconcile(next, { key: "id" })) + setStore("message", sessionID, reconcile(merged, { key: "id" })) for (const message of items) { - setStore( - "part", - message.info.id, - reconcile( - message.parts + const currentParts = store.part[message.info.id] ?? [] + const mergedParts = (() => { + if (currentParts.length === 0) { + return message.parts .filter((p) => !!p?.id) .slice() - .sort((a, b) => a.id.localeCompare(b.id)), - { key: "id" }, - ), - ) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + } + + const map = new Map() + for (const item of currentParts) { + if (!item?.id) continue + map.set(item.id, item) + } + for (const item of message.parts) { + if (!item?.id) continue + map.set(item.id, item) + } + return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + })() + + setStore("part", message.info.id, reconcile(mergedParts, { key: "id" })) } }) }) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 29c5566a65..d878bd2456 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -161,12 +161,14 @@ export function SessionTurn( const messageIndex = createMemo(() => { const messages = allMessages() ?? emptyMessages const result = Binary.search(messages, props.messageID, (m) => m.id) - if (!result.found) return -1 - const msg = messages[result.index] + const index = result.found ? result.index : messages.findIndex((m) => m.id === props.messageID) + if (index < 0) return -1 + + const msg = messages[index] if (!msg || msg.role !== "user") return -1 - return result.index + return index }) const message = createMemo(() => { From f1e0c31b8f7c299d2bdc5f69dc30ed55f86918bb Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:29:06 -0600 Subject: [PATCH 39/64] fix(app): button heights --- packages/ui/src/components/button.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index 3e5d21d1de..afff0c476b 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -114,6 +114,7 @@ } &[data-size="small"] { + height: 22px; padding: 4px 8px; &[data-icon] { padding: 4px 12px 4px 4px; @@ -130,6 +131,7 @@ } &[data-size="normal"] { + height: 24px; padding: 4px 6px; &[data-icon] { padding: 4px 12px 4px 4px; @@ -150,6 +152,7 @@ } &[data-size="large"] { + height: 32px; padding: 6px 12px; &[data-icon] { From 23631a93935a33fb8e44272ba1572e3475a223c2 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:53:41 -0600 Subject: [PATCH 40/64] fix(app): navigate to last project on open --- packages/app/src/pages/layout.tsx | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 202443ee70..5d285c5ecc 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -109,7 +109,7 @@ export default function Layout(props: ParentProps) { const command = useCommand() const theme = useTheme() const language = useLanguage() - const initialDir = params.dir + const initialDirectory = decode64(params.dir) const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeKey: Record = { @@ -120,7 +120,7 @@ export default function Layout(props: ParentProps) { const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme]) const [state, setState] = createStore({ - autoselect: !params.dir, + autoselect: !initialDirectory, busyWorkspaces: new Set(), hoverSession: undefined as string | undefined, hoverProject: undefined as string | undefined, @@ -180,13 +180,21 @@ export default function Layout(props: ParentProps) { const autoselecting = createMemo(() => { if (params.dir) return false - if (initialDir) return false if (!state.autoselect) return false if (!pageReady()) return true if (!layoutReady()) return true const list = layout.projects.list() - if (list.length === 0) return false - return true + if (list.length > 0) return true + return !!server.projects.last() + }) + + createEffect(() => { + if (!state.autoselect) return + const dir = params.dir + if (!dir) return + const directory = decode64(dir) + if (!directory) return + setState("autoselect", false) }) const editorOpen = (id: string) => editor.active === id @@ -566,11 +574,18 @@ export default function Layout(props: ParentProps) { if (!value.ready) return if (!value.layoutReady) return if (!state.autoselect) return - if (initialDir) return if (value.dir) return - if (value.list.length === 0) return const last = server.projects.last() + + if (value.list.length === 0) { + if (!last) return + setState("autoselect", false) + openProject(last, false) + navigateToProject(last) + return + } + const next = value.list.find((project) => project.worktree === last) ?? value.list[0] if (!next) return setState("autoselect", false) From 3b93e8d95cfc30d1a85fbb76694bdb7f49dff1e9 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:55:15 -0600 Subject: [PATCH 41/64] fix(app): added/deleted file status now correctly calculated --- packages/app/src/components/file-tree.tsx | 49 ++++++++++++++++++- packages/app/src/pages/session.tsx | 4 +- packages/opencode/src/snapshot/index.ts | 19 +++++++ .../opencode/test/snapshot/snapshot.test.ts | 46 +++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 1 + 5 files changed, 115 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index d43310b195..19f5e9a3b4 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -130,10 +130,57 @@ export default function FileTree(props: { const nodes = file.tree.children(props.path) const current = filter() if (!current) return nodes - return nodes.filter((node) => { + + const parent = (path: string) => { + const idx = path.lastIndexOf("/") + if (idx === -1) return "" + return path.slice(0, idx) + } + + const leaf = (path: string) => { + const idx = path.lastIndexOf("/") + return idx === -1 ? path : path.slice(idx + 1) + } + + const out = nodes.filter((node) => { if (node.type === "file") return current.files.has(node.path) return current.dirs.has(node.path) }) + + const seen = new Set(out.map((node) => node.path)) + + for (const dir of current.dirs) { + if (parent(dir) !== props.path) continue + if (seen.has(dir)) continue + out.push({ + name: leaf(dir), + path: dir, + absolute: dir, + type: "directory", + ignored: false, + }) + seen.add(dir) + } + + for (const item of current.files) { + if (parent(item) !== props.path) continue + if (seen.has(item)) continue + out.push({ + name: leaf(item), + path: item, + absolute: item, + type: "file", + ignored: false, + }) + seen.add(item) + } + + return out.toSorted((a, b) => { + if (a.type !== b.type) { + return a.type === "directory" ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) }) const Node = ( diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 772ad063ba..540046c09b 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -500,9 +500,7 @@ export default function Page() { const out = new Map() for (const diff of diffs()) { const file = normalize(diff.file) - const add = diff.additions > 0 - const del = diff.deletions > 0 - const kind = add && del ? "mix" : add ? "add" : del ? "del" : "mix" + const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" out.set(file, kind) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 1c15390905..b3c8a905c2 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -188,6 +188,7 @@ export namespace Snapshot { after: z.string(), additions: z.number(), deletions: z.number(), + status: z.enum(["added", "deleted", "modified"]).optional(), }) .meta({ ref: "FileDiff", @@ -196,6 +197,23 @@ export namespace Snapshot { export async function diffFull(from: string, to: string): Promise { const git = gitdir() const result: FileDiff[] = [] + const status = new Map() + + const statuses = + await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .` + .quiet() + .cwd(Instance.directory) + .nothrow() + .text() + + for (const line of statuses.trim().split("\n")) { + if (!line) continue + const [code, file] = line.split("\t") + if (!code || !file) continue + const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified" + status.set(file, kind) + } + for await (const line of $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .` .quiet() .cwd(Instance.directory) @@ -224,6 +242,7 @@ export namespace Snapshot { after, additions: Number.isFinite(added) ? added : 0, deletions: Number.isFinite(deleted) ? deleted : 0, + status: status.get(file) ?? "modified", }) } return result diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index ef6271ed5d..091469ec76 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -749,6 +749,52 @@ test("revert preserves file that existed in snapshot when deleted then recreated }) }) +test("diffFull sets status based on git change type", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Bun.write(`${tmp.path}/grow.txt`, "one\n") + await Bun.write(`${tmp.path}/trim.txt`, "line1\nline2\n") + await Bun.write(`${tmp.path}/delete.txt`, "gone") + + const before = await Snapshot.track() + expect(before).toBeTruthy() + + await Bun.write(`${tmp.path}/grow.txt`, "one\ntwo\n") + await Bun.write(`${tmp.path}/trim.txt`, "line1\n") + await $`rm ${tmp.path}/delete.txt`.quiet() + await Bun.write(`${tmp.path}/added.txt`, "new") + + const after = await Snapshot.track() + expect(after).toBeTruthy() + + const diffs = await Snapshot.diffFull(before!, after!) + expect(diffs.length).toBe(4) + + const added = diffs.find((d) => d.file === "added.txt") + expect(added).toBeDefined() + expect(added!.status).toBe("added") + + const deleted = diffs.find((d) => d.file === "delete.txt") + expect(deleted).toBeDefined() + expect(deleted!.status).toBe("deleted") + + const grow = diffs.find((d) => d.file === "grow.txt") + expect(grow).toBeDefined() + expect(grow!.status).toBe("modified") + expect(grow!.additions).toBeGreaterThan(0) + expect(grow!.deletions).toBe(0) + + const trim = diffs.find((d) => d.file === "trim.txt") + expect(trim).toBeDefined() + expect(trim!.status).toBe("modified") + expect(trim!.additions).toBe(0) + expect(trim!.deletions).toBeGreaterThan(0) + }, + }) +}) + test("diffFull with new file additions", async () => { await using tmp = await bootstrap() await Instance.provide({ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0556e1ad94..085c9d9c7e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -96,6 +96,7 @@ export type FileDiff = { after: string additions: number deletions: number + status?: "added" | "deleted" | "modified" } export type UserMessage = { From dfd5f38408aa0a905a9cda40f1ce077777dce5e0 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:22:00 -0600 Subject: [PATCH 42/64] fix(app): icon sizes --- packages/app/src/components/prompt-input.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 1c84c36104..d31d0b2a37 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1801,7 +1801,7 @@ export const PromptInput: Component = (props) => { }} >
- +
{getFilenameTruncated(item.path, 14)} @@ -1818,7 +1818,7 @@ export const PromptInput: Component = (props) => { type="button" icon="close-small" variant="ghost" - class="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-all" + class="ml-auto size-3.5 opacity-0 group-hover:opacity-100 transition-all" onClick={(e) => { e.stopPropagation() if (item.commentID) comments.remove(item.path, item.commentID) From 2f76b49df3cfd316069a2b5c292fed369acadbde Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:37:50 -0600 Subject: [PATCH 43/64] Revert "feat(ui): Smooth fading out on scroll, style fixes (#11683)" This reverts commit e445dc07464d75c893756f6e256c1755d9e2285e. --- .../app/src/components/settings-general.tsx | 10 +- .../app/src/components/settings-keybinds.tsx | 10 +- .../app/src/components/settings-models.tsx | 10 +- .../app/src/components/settings-providers.tsx | 10 +- packages/ui/src/components/list.css | 50 +++-- packages/ui/src/components/list.tsx | 5 +- packages/ui/src/components/scroll-fade.css | 82 ------- packages/ui/src/components/scroll-fade.tsx | 206 ------------------ packages/ui/src/components/scroll-reveal.tsx | 141 ------------ packages/ui/src/styles/index.css | 1 - 10 files changed, 43 insertions(+), 482 deletions(-) delete mode 100644 packages/ui/src/components/scroll-fade.css delete mode 100644 packages/ui/src/components/scroll-fade.tsx delete mode 100644 packages/ui/src/components/scroll-reveal.tsx diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index a0251ed41b..94813871e4 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -5,7 +5,6 @@ import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { showToast } from "@opencode-ai/ui/toast" -import { ScrollFade } from "@opencode-ai/ui/scroll-fade" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings, monoFontFamily } from "@/context/settings" @@ -131,12 +130,7 @@ export const SettingsGeneral: Component = () => { const soundOptions = [...SOUND_OPTIONS] return ( - +

{language.t("settings.tab.general")}

@@ -417,7 +411,7 @@ export const SettingsGeneral: Component = () => {
-
+
) } diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index 8655bca34b..a24db13f5c 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -5,7 +5,6 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" -import { ScrollFade } from "@opencode-ai/ui/scroll-fade" import fuzzysort from "fuzzysort" import { formatKeybind, parseKeybind, useCommand } from "@/context/command" import { useLanguage } from "@/context/language" @@ -353,12 +352,7 @@ export const SettingsKeybinds: Component = () => { }) return ( - +
@@ -436,6 +430,6 @@ export const SettingsKeybinds: Component = () => {
- +
) } diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index 0ee5caf73d..1807d561ea 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -9,7 +9,6 @@ import { type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" import { useModels } from "@/context/models" import { popularProviders } from "@/hooks/use-providers" -import { ScrollFade } from "@opencode-ai/ui/scroll-fade" type ModelItem = ReturnType["list"]>[number] @@ -40,12 +39,7 @@ export const SettingsModels: Component = () => { }) return ( - +

{language.t("settings.models.title")}

@@ -131,6 +125,6 @@ export const SettingsModels: Component = () => {
- +
) } diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 2460534c05..dcc597139e 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -12,7 +12,6 @@ import { useGlobalSync } from "@/context/global-sync" import { DialogConnectProvider } from "./dialog-connect-provider" import { DialogSelectProvider } from "./dialog-select-provider" import { DialogCustomProvider } from "./dialog-custom-provider" -import { ScrollFade } from "@opencode-ai/ui/scroll-fade" type ProviderSource = "env" | "api" | "config" | "custom" type ProviderMeta = { source?: ProviderSource } @@ -116,12 +115,7 @@ export const SettingsProviders: Component = () => { } return ( - +

{language.t("settings.providers.title")}

@@ -267,6 +261,6 @@ export const SettingsProviders: Component = () => {
- +
) } diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index 7b365c288a..b12d304151 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -1,7 +1,25 @@ +@property --bottom-fade { + syntax: ""; + inherits: false; + initial-value: 0px; +} + +@keyframes scroll { + 0% { + --bottom-fade: 20px; + } + 90% { + --bottom-fade: 20px; + } + 100% { + --bottom-fade: 0; + } +} + [data-component="list"] { display: flex; flex-direction: column; - gap: 8px; + gap: 12px; overflow: hidden; padding: 0 12px; @@ -19,9 +37,7 @@ flex-shrink: 0; background-color: transparent; opacity: 0.5; - transition-property: opacity; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); + transition: opacity 0.15s ease; &:hover:not(:disabled), &:focus-visible:not(:disabled), @@ -72,9 +88,7 @@ height: 20px; background-color: transparent; opacity: 0.5; - transition-property: opacity; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); + transition: opacity 0.15s ease; &:hover:not(:disabled), &:focus-visible:not(:disabled), @@ -117,6 +131,15 @@ gap: 12px; overflow-y: auto; overscroll-behavior: contain; + mask: linear-gradient(to bottom, #ffff calc(100% - var(--bottom-fade)), #0000); + animation: scroll; + animation-timeline: --scroll; + scroll-timeline: --scroll y; + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } [data-slot="list-empty-state"] { display: flex; @@ -192,9 +215,7 @@ background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent); pointer-events: none; opacity: 0; - transition-property: opacity; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); + transition: opacity 0.15s ease; } &[data-stuck="true"]::after { @@ -230,22 +251,17 @@ align-items: center; justify-content: center; flex-shrink: 0; - aspect-ratio: 1 / 1; + aspect-ratio: 1/1; [data-component="icon"] { color: var(--icon-strong-base); } } - - [name="check"] { - color: var(--icon-strong-base); - } - [data-slot="list-item-active-icon"] { display: none; align-items: center; justify-content: center; flex-shrink: 0; - aspect-ratio: 1 / 1; + aspect-ratio: 1/1; [data-component="icon"] { color: var(--icon-strong-base); } diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 886ac5e6c8..6c654cbb7d 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -5,7 +5,6 @@ import { useI18n } from "../context/i18n" import { Icon, type IconProps } from "./icon" import { IconButton } from "./icon-button" import { TextField } from "./text-field" -import { ScrollFade } from "./scroll-fade" function findByKey(container: HTMLElement, key: string) { const nodes = container.querySelectorAll('[data-slot="list-item"][data-key]') @@ -280,7 +279,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) {searchAction()}
- +
0 || showAdd()} fallback={ @@ -353,7 +352,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
-
+
) } diff --git a/packages/ui/src/components/scroll-fade.css b/packages/ui/src/components/scroll-fade.css deleted file mode 100644 index ede5fabec4..0000000000 --- a/packages/ui/src/components/scroll-fade.css +++ /dev/null @@ -1,82 +0,0 @@ -[data-component="scroll-fade"] { - overflow: auto; - overscroll-behavior: contain; - scrollbar-width: none; - box-sizing: border-box; - color: inherit; - font: inherit; - -ms-overflow-style: none; - - &::-webkit-scrollbar { - display: none; - } - - &[data-direction="horizontal"] { - overflow-x: auto; - overflow-y: hidden; - - /* Both fades */ - &[data-fade-start][data-fade-end] { - mask-image: linear-gradient( - to right, - transparent, - black var(--scroll-fade-start), - black calc(100% - var(--scroll-fade-end)), - transparent - ); - -webkit-mask-image: linear-gradient( - to right, - transparent, - black var(--scroll-fade-start), - black calc(100% - var(--scroll-fade-end)), - transparent - ); - } - - /* Only start fade */ - &[data-fade-start]:not([data-fade-end]) { - mask-image: linear-gradient(to right, transparent, black var(--scroll-fade-start), black 100%); - -webkit-mask-image: linear-gradient(to right, transparent, black var(--scroll-fade-start), black 100%); - } - - /* Only end fade */ - &:not([data-fade-start])[data-fade-end] { - mask-image: linear-gradient(to right, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); - -webkit-mask-image: linear-gradient(to right, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); - } - } - - &[data-direction="vertical"] { - overflow-y: auto; - overflow-x: hidden; - - &[data-fade-start][data-fade-end] { - mask-image: linear-gradient( - to bottom, - transparent, - black var(--scroll-fade-start), - black calc(100% - var(--scroll-fade-end)), - transparent - ); - -webkit-mask-image: linear-gradient( - to bottom, - transparent, - black var(--scroll-fade-start), - black calc(100% - var(--scroll-fade-end)), - transparent - ); - } - - /* Only start fade */ - &[data-fade-start]:not([data-fade-end]) { - mask-image: linear-gradient(to bottom, transparent, black var(--scroll-fade-start), black 100%); - -webkit-mask-image: linear-gradient(to bottom, transparent, black var(--scroll-fade-start), black 100%); - } - - /* Only end fade */ - &:not([data-fade-start])[data-fade-end] { - mask-image: linear-gradient(to bottom, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); - -webkit-mask-image: linear-gradient(to bottom, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); - } - } -} diff --git a/packages/ui/src/components/scroll-fade.tsx b/packages/ui/src/components/scroll-fade.tsx deleted file mode 100644 index 97f0339e82..0000000000 --- a/packages/ui/src/components/scroll-fade.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { type JSX, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" - -export interface ScrollFadeProps extends JSX.HTMLAttributes { - direction?: "horizontal" | "vertical" - fadeStartSize?: number - fadeEndSize?: number - trackTransformSelector?: string - ref?: (el: HTMLDivElement) => void -} - -export function ScrollFade(props: ScrollFadeProps) { - const [local, others] = splitProps(props, [ - "children", - "direction", - "fadeStartSize", - "fadeEndSize", - "trackTransformSelector", - "class", - "style", - "ref", - ]) - - const direction = () => local.direction ?? "vertical" - const fadeStartSize = () => local.fadeStartSize ?? 20 - const fadeEndSize = () => local.fadeEndSize ?? 20 - - const getTransformOffset = (element: Element): number => { - const style = getComputedStyle(element) - const transform = style.transform - if (!transform || transform === "none") return 0 - - const match = transform.match(/matrix(?:3d)?\(([^)]+)\)/) - if (!match) return 0 - - const values = match[1].split(",").map((v) => parseFloat(v.trim())) - const isHorizontal = direction() === "horizontal" - - if (transform.startsWith("matrix3d")) { - return isHorizontal ? -(values[12] || 0) : -(values[13] || 0) - } else { - return isHorizontal ? -(values[4] || 0) : -(values[5] || 0) - } - } - - let containerRef: HTMLDivElement | undefined - - const [fadeStart, setFadeStart] = createSignal(0) - const [fadeEnd, setFadeEnd] = createSignal(0) - const [isScrollable, setIsScrollable] = createSignal(false) - - let lastScrollPos = 0 - let lastTransformPos = 0 - let lastScrollSize = 0 - let lastClientSize = 0 - - const updateFade = () => { - if (!containerRef) return - - const isHorizontal = direction() === "horizontal" - const scrollPos = isHorizontal ? containerRef.scrollLeft : containerRef.scrollTop - const scrollSize = isHorizontal ? containerRef.scrollWidth : containerRef.scrollHeight - const clientSize = isHorizontal ? containerRef.clientWidth : containerRef.clientHeight - - let transformPos = 0 - if (local.trackTransformSelector) { - const transformElement = containerRef.querySelector(local.trackTransformSelector) - if (transformElement) { - transformPos = getTransformOffset(transformElement) - } - } - - const effectiveScrollPos = Math.max(scrollPos, transformPos) - - if ( - effectiveScrollPos === lastScrollPos && - transformPos === lastTransformPos && - scrollSize === lastScrollSize && - clientSize === lastClientSize - ) { - return - } - - lastScrollPos = effectiveScrollPos - lastTransformPos = transformPos - lastScrollSize = scrollSize - lastClientSize = clientSize - - const maxScroll = scrollSize - clientSize - const canScroll = maxScroll > 1 - - setIsScrollable(canScroll) - - if (!canScroll) { - setFadeStart(0) - setFadeEnd(0) - return - } - - const progress = maxScroll > 0 ? effectiveScrollPos / maxScroll : 0 - - const startProgress = Math.min(progress / 0.1, 1) - setFadeStart(startProgress * fadeStartSize()) - - const endProgress = progress > 0.9 ? (1 - progress) / 0.1 : 1 - setFadeEnd(Math.max(0, endProgress) * fadeEndSize()) - } - - onMount(() => { - if (!containerRef) return - - updateFade() - - let rafId: number | undefined - let isPolling = false - let pollTimeout: ReturnType | undefined - - const startPolling = () => { - if (isPolling) return - isPolling = true - - const pollScroll = () => { - updateFade() - rafId = requestAnimationFrame(pollScroll) - } - rafId = requestAnimationFrame(pollScroll) - } - - const stopPolling = () => { - if (!isPolling) return - isPolling = false - if (rafId !== undefined) { - cancelAnimationFrame(rafId) - rafId = undefined - } - } - - const schedulePollingStop = () => { - if (pollTimeout !== undefined) clearTimeout(pollTimeout) - pollTimeout = setTimeout(stopPolling, 1000) - } - - const onActivity = () => { - updateFade() - if (local.trackTransformSelector) { - startPolling() - schedulePollingStop() - } - } - - containerRef.addEventListener("scroll", onActivity, { passive: true }) - - const resizeObserver = new ResizeObserver(() => { - lastScrollSize = 0 - lastClientSize = 0 - onActivity() - }) - resizeObserver.observe(containerRef) - - const mutationObserver = new MutationObserver(() => { - lastScrollSize = 0 - lastClientSize = 0 - requestAnimationFrame(onActivity) - }) - mutationObserver.observe(containerRef, { - childList: true, - subtree: true, - characterData: true, - }) - - onCleanup(() => { - containerRef?.removeEventListener("scroll", onActivity) - resizeObserver.disconnect() - mutationObserver.disconnect() - stopPolling() - if (pollTimeout !== undefined) clearTimeout(pollTimeout) - }) - }) - - createEffect(() => { - local.children - requestAnimationFrame(updateFade) - }) - - return ( -
{ - containerRef = el - local.ref?.(el) - }} - data-component="scroll-fade" - data-direction={direction()} - data-scrollable={isScrollable() || undefined} - data-fade-start={fadeStart() > 0 || undefined} - data-fade-end={fadeEnd() > 0 || undefined} - class={local.class} - style={{ - ...(typeof local.style === "object" ? local.style : {}), - "--scroll-fade-start": `${fadeStart()}px`, - "--scroll-fade-end": `${fadeEnd()}px`, - }} - {...others} - > - {local.children} -
- ) -} diff --git a/packages/ui/src/components/scroll-reveal.tsx b/packages/ui/src/components/scroll-reveal.tsx deleted file mode 100644 index 6e5072dc81..0000000000 --- a/packages/ui/src/components/scroll-reveal.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { type JSX, onCleanup, splitProps } from "solid-js" -import { ScrollFade, type ScrollFadeProps } from "./scroll-fade" - -const SCROLL_SPEED = 60 -const PAUSE_DURATION = 800 - -type ScrollAnimationState = { - rafId: number | null - startTime: number - running: boolean -} - -const startScrollAnimation = (containerEl: HTMLElement): ScrollAnimationState | null => { - containerEl.offsetHeight - - const extraWidth = containerEl.scrollWidth - containerEl.clientWidth - - if (extraWidth <= 0) { - return null - } - - const scrollDuration = (extraWidth / SCROLL_SPEED) * 1000 - const totalDuration = PAUSE_DURATION + scrollDuration + PAUSE_DURATION + scrollDuration + PAUSE_DURATION - - const state: ScrollAnimationState = { - rafId: null, - startTime: performance.now(), - running: true, - } - - const animate = (currentTime: number) => { - if (!state.running) return - - const elapsed = currentTime - state.startTime - const progress = (elapsed % totalDuration) / totalDuration - - const pausePercent = PAUSE_DURATION / totalDuration - const scrollPercent = scrollDuration / totalDuration - - const pauseEnd1 = pausePercent - const scrollEnd1 = pauseEnd1 + scrollPercent - const pauseEnd2 = scrollEnd1 + pausePercent - const scrollEnd2 = pauseEnd2 + scrollPercent - - let scrollPos = 0 - - if (progress < pauseEnd1) { - scrollPos = 0 - } else if (progress < scrollEnd1) { - const scrollProgress = (progress - pauseEnd1) / scrollPercent - scrollPos = scrollProgress * extraWidth - } else if (progress < pauseEnd2) { - scrollPos = extraWidth - } else if (progress < scrollEnd2) { - const scrollProgress = (progress - pauseEnd2) / scrollPercent - scrollPos = extraWidth * (1 - scrollProgress) - } else { - scrollPos = 0 - } - - containerEl.scrollLeft = scrollPos - state.rafId = requestAnimationFrame(animate) - } - - state.rafId = requestAnimationFrame(animate) - return state -} - -const stopScrollAnimation = (state: ScrollAnimationState | null, containerEl?: HTMLElement) => { - if (state) { - state.running = false - if (state.rafId !== null) { - cancelAnimationFrame(state.rafId) - } - } - if (containerEl) { - containerEl.scrollLeft = 0 - } -} - -export interface ScrollRevealProps extends Omit { - hoverDelay?: number -} - -export function ScrollReveal(props: ScrollRevealProps) { - const [local, others] = splitProps(props, ["children", "hoverDelay", "ref"]) - - const hoverDelay = () => local.hoverDelay ?? 300 - - let containerRef: HTMLDivElement | undefined - let hoverTimeout: ReturnType | undefined - let scrollAnimationState: ScrollAnimationState | null = null - - const handleMouseEnter: JSX.EventHandler = () => { - hoverTimeout = setTimeout(() => { - if (!containerRef) return - - containerRef.offsetHeight - - const isScrollable = containerRef.scrollWidth > containerRef.clientWidth + 1 - - if (isScrollable) { - stopScrollAnimation(scrollAnimationState, containerRef) - scrollAnimationState = startScrollAnimation(containerRef) - } - }, hoverDelay()) - } - - const handleMouseLeave: JSX.EventHandler = () => { - if (hoverTimeout) { - clearTimeout(hoverTimeout) - hoverTimeout = undefined - } - stopScrollAnimation(scrollAnimationState, containerRef) - scrollAnimationState = null - } - - onCleanup(() => { - if (hoverTimeout) { - clearTimeout(hoverTimeout) - } - stopScrollAnimation(scrollAnimationState, containerRef) - }) - - return ( - { - containerRef = el - local.ref?.(el) - }} - fadeStartSize={8} - fadeEndSize={8} - direction="horizontal" - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} - {...others} - > - {local.children} - - ) -} diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index d5939b2b36..55e1a16d18 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -41,7 +41,6 @@ @import "../components/select.css" layer(components); @import "../components/spinner.css" layer(components); @import "../components/switch.css" layer(components); -@import "../components/scroll-fade.css" layer(components); @import "../components/session-review.css" layer(components); @import "../components/session-turn.css" layer(components); @import "../components/sticky-accordion-header.css" layer(components); From 70cf609ce90a7534349c8dd5ed8441cbd32ebba7 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:46:25 -0600 Subject: [PATCH 44/64] Revert "feat(ui): Select, dropdown, popover styles & transitions (#11675)" This reverts commit 377bf7ff21a4f05807c38675ac70cd08fe67b516. --- .../src/components/dialog-select-model.tsx | 7 +- packages/app/src/components/prompt-input.tsx | 125 ++++++---------- .../app/src/components/settings-general.tsx | 2 +- packages/ui/src/components/button.css | 26 ++-- packages/ui/src/components/button.tsx | 2 +- packages/ui/src/components/cycle-label.css | 49 ------- packages/ui/src/components/cycle-label.tsx | 135 ------------------ packages/ui/src/components/dropdown-menu.css | 45 +++--- packages/ui/src/components/icon.tsx | 7 +- packages/ui/src/components/message-part.tsx | 6 +- packages/ui/src/components/morph-chevron.css | 10 -- packages/ui/src/components/morph-chevron.tsx | 73 ---------- packages/ui/src/components/popover.css | 58 ++------ packages/ui/src/components/reasoning-icon.css | 9 -- packages/ui/src/components/reasoning-icon.tsx | 46 ------ packages/ui/src/components/select.css | 87 +++++------ packages/ui/src/components/select.tsx | 18 +-- packages/ui/src/styles/index.css | 2 - packages/ui/src/styles/utilities.css | 42 ------ 19 files changed, 129 insertions(+), 620 deletions(-) delete mode 100644 packages/ui/src/components/cycle-label.css delete mode 100644 packages/ui/src/components/cycle-label.tsx delete mode 100644 packages/ui/src/components/morph-chevron.css delete mode 100644 packages/ui/src/components/morph-chevron.tsx delete mode 100644 packages/ui/src/components/reasoning-icon.css delete mode 100644 packages/ui/src/components/reasoning-icon.tsx diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 2135b1edf4..4f0dcc3ee6 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -90,10 +90,9 @@ const ModelList: Component<{ export function ModelSelectorPopover(props: { provider?: string - children?: JSX.Element | ((open: boolean) => JSX.Element) + children?: JSX.Element triggerAs?: T triggerProps?: ComponentProps - gutter?: number }) { const [store, setStore] = createStore<{ open: boolean @@ -176,14 +175,14 @@ export function ModelSelectorPopover(props: { }} modal={false} placement="top-start" - gutter={props.gutter ?? 8} + gutter={8} > setStore("trigger", el)} as={props.triggerAs ?? "div"} {...(props.triggerProps as any)} > - {typeof props.children === "function" ? props.children(store.open) : props.children} + {props.children} = (props) => { clearInput() client.session .shell({ - sessionID: session?.id || "", + sessionID: session.id, agent, model, command: text, @@ -1280,7 +1277,7 @@ export const PromptInput: Component = (props) => { clearInput() client.session .command({ - sessionID: session?.id || "", + sessionID: session.id, command: commandName, arguments: args.join(" "), agent, @@ -1436,13 +1433,13 @@ export const PromptInput: Component = (props) => { const optimisticParts = requestParts.map((part) => ({ ...part, - sessionID: session?.id || "", + sessionID: session.id, messageID, })) as unknown as Part[] const optimisticMessage: Message = { id: messageID, - sessionID: session?.id || "", + sessionID: session.id, role: "user", time: { created: Date.now() }, agent, @@ -1453,9 +1450,9 @@ export const PromptInput: Component = (props) => { if (sessionDirectory === projectDirectory) { sync.set( produce((draft) => { - const messages = draft.message[session?.id || ""] + const messages = draft.message[session.id] if (!messages) { - draft.message[session?.id || ""] = [optimisticMessage] + draft.message[session.id] = [optimisticMessage] } else { const result = Binary.search(messages, messageID, (m) => m.id) messages.splice(result.index, 0, optimisticMessage) @@ -1471,9 +1468,9 @@ export const PromptInput: Component = (props) => { globalSync.child(sessionDirectory)[1]( produce((draft) => { - const messages = draft.message[session?.id || ""] + const messages = draft.message[session.id] if (!messages) { - draft.message[session?.id || ""] = [optimisticMessage] + draft.message[session.id] = [optimisticMessage] } else { const result = Binary.search(messages, messageID, (m) => m.id) messages.splice(result.index, 0, optimisticMessage) @@ -1490,7 +1487,7 @@ export const PromptInput: Component = (props) => { if (sessionDirectory === projectDirectory) { sync.set( produce((draft) => { - const messages = draft.message[session?.id || ""] + const messages = draft.message[session.id] if (messages) { const result = Binary.search(messages, messageID, (m) => m.id) if (result.found) messages.splice(result.index, 1) @@ -1503,7 +1500,7 @@ export const PromptInput: Component = (props) => { globalSync.child(sessionDirectory)[1]( produce((draft) => { - const messages = draft.message[session?.id || ""] + const messages = draft.message[session.id] if (messages) { const result = Binary.search(messages, messageID, (m) => m.id) if (result.found) messages.splice(result.index, 1) @@ -1524,15 +1521,15 @@ export const PromptInput: Component = (props) => { const worktree = WorktreeState.get(sessionDirectory) if (!worktree || worktree.status !== "pending") return true - if (sessionDirectory === projectDirectory && session?.id) { - sync.set("session_status", session?.id, { type: "busy" }) + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "busy" }) } const controller = new AbortController() const cleanup = () => { - if (sessionDirectory === projectDirectory && session?.id) { - sync.set("session_status", session?.id, { type: "idle" }) + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "idle" }) } removeOptimisticMessage() for (const item of commentItems) { @@ -1549,7 +1546,7 @@ export const PromptInput: Component = (props) => { restoreInput() } - pending.set(session?.id || "", { abort: controller, cleanup }) + pending.set(session.id, { abort: controller, cleanup }) const abort = new Promise>>((resolve) => { if (controller.signal.aborted) { @@ -1577,7 +1574,7 @@ export const PromptInput: Component = (props) => { if (timer.id === undefined) return clearTimeout(timer.id) }) - pending.delete(session?.id || "") + pending.delete(session.id) if (controller.signal.aborted) return false if (result.status === "failed") throw new Error(result.message) return true @@ -1587,7 +1584,7 @@ export const PromptInput: Component = (props) => { const ok = await waitForWorktree() if (!ok) return await client.session.prompt({ - sessionID: session?.id || "", + sessionID: session.id, agent, model, messageID, @@ -1597,9 +1594,9 @@ export const PromptInput: Component = (props) => { } void send().catch((err) => { - pending.delete(session?.id || "") - if (sessionDirectory === projectDirectory && session?.id) { - sync.set("session_status", session?.id, { type: "idle" }) + pending.delete(session.id) + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "idle" }) } showToast({ title: language.t("prompt.toast.promptSendFailed.title"), @@ -1621,28 +1618,6 @@ export const PromptInput: Component = (props) => { }) } - const currrentModelVariant = createMemo(() => { - const modelVariant = local.model.variant.current() ?? "" - return modelVariant === "xhigh" - ? "xHigh" - : modelVariant.length > 0 - ? modelVariant[0].toUpperCase() + modelVariant.slice(1) - : "Default" - }) - - const reasoningPercentage = createMemo(() => { - const variants = local.model.variant.list() - const current = local.model.variant.current() - const totalEntries = variants.length + 1 - - if (totalEntries <= 2 || current === "Default") { - return 0 - } - - const currentIndex = current ? variants.indexOf(current) + 1 : 0 - return ((currentIndex + 1) / totalEntries) * 100 - }, [local.model.variant]) - return (
@@ -1695,7 +1670,7 @@ export const PromptInput: Component = (props) => { } > - + @{(item as { type: "agent"; name: string }).name} @@ -1760,9 +1735,9 @@ export const PromptInput: Component = (props) => { }} > -
+
- + {language.t("prompt.dropzone.label")}
@@ -1848,7 +1823,7 @@ export const PromptInput: Component = (props) => { when={attachment.mime.startsWith("image/")} fallback={
- +
} > @@ -1922,7 +1897,7 @@ export const PromptInput: Component = (props) => {
-
+
@@ -1943,7 +1918,6 @@ export const PromptInput: Component = (props) => { onSelect={local.agent.set} class="capitalize" variant="ghost" - gutter={12} /> = (props) => { title={language.t("command.model.choose")} keybind={command.keybind("model.choose")} > - } @@ -1976,16 +1943,12 @@ export const PromptInput: Component = (props) => { title={language.t("command.model.choose")} keybind={command.keybind("model.choose")} > - - {(open) => ( - <> - - - - {local.model.current()?.name ?? language.t("dialog.model.select.title")} - - - )} + + + + + {local.model.current()?.name ?? language.t("dialog.model.select.title")} + @@ -1998,13 +1961,10 @@ export const PromptInput: Component = (props) => { @@ -2018,7 +1978,7 @@ export const PromptInput: Component = (props) => { variant="ghost" onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)} classList={{ - "_hidden group-hover/prompt-input:flex items-center justify-center": true, + "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true, "text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory), "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory), }} @@ -2040,7 +2000,7 @@ export const PromptInput: Component = (props) => {
-
+
= (props) => { e.currentTarget.value = "" }} /> -
+
@@ -2083,7 +2042,7 @@ export const PromptInput: Component = (props) => {
{language.t("prompt.action.send")} - +
@@ -2094,7 +2053,7 @@ export const PromptInput: Component = (props) => { disabled={!prompt.dirty() && !working()} icon={working() ? "stop" : "arrow-up"} variant="primary" - class="h-6 w-5.5" + class="h-6 w-4.5" aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} /> diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 94813871e4..b31cfb6cc7 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -226,7 +226,7 @@ export const SettingsGeneral: Component = () => { variant="secondary" size="small" triggerVariant="settings" - triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "field-sizing": "content" }} + triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }} > {(option) => ( diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index afff0c476b..d9b3459230 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -9,13 +9,7 @@ user-select: none; cursor: default; outline: none; - padding: 4px 8px; white-space: nowrap; - transition-property: background-color, border-color, color, box-shadow, opacity; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); - outline: none; - line-height: 20px; &[data-variant="primary"] { background-color: var(--button-primary-base); @@ -100,6 +94,7 @@ &:active:not(:disabled) { background-color: var(--button-secondary-base); scale: 0.99; + transition: all 150ms ease-out; } &:disabled { border-color: var(--border-disabled); @@ -115,32 +110,33 @@ &[data-size="small"] { height: 22px; - padding: 4px 8px; + padding: 0 8px; &[data-icon] { - padding: 4px 12px 4px 4px; + padding: 0 12px 0 4px; } + font-size: var(--font-size-small); + line-height: var(--line-height-large); gap: 4px; /* text-12-medium */ font-family: var(--font-family-sans); - font-size: var(--font-size-base); + font-size: var(--font-size-small); font-style: normal; font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); } &[data-size="normal"] { height: 24px; - padding: 4px 6px; + line-height: 24px; + padding: 0 6px; &[data-icon] { - padding: 4px 12px 4px 4px; - } - - &[aria-haspopup] { - padding: 4px 6px 4px 8px; + padding: 0 12px 0 4px; } + font-size: var(--font-size-small); gap: 6px; /* text-12-medium */ diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index b2d2004d3c..7f974b2f76 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -4,7 +4,7 @@ import { Icon, IconProps } from "./icon" export interface ButtonProps extends ComponentProps, - Pick, "class" | "classList" | "children" | "style"> { + Pick, "class" | "classList" | "children"> { size?: "small" | "normal" | "large" variant?: "primary" | "secondary" | "ghost" icon?: IconProps["name"] diff --git a/packages/ui/src/components/cycle-label.css b/packages/ui/src/components/cycle-label.css deleted file mode 100644 index 3c98fcd261..0000000000 --- a/packages/ui/src/components/cycle-label.css +++ /dev/null @@ -1,49 +0,0 @@ -.cycle-label { - --c-duration: 200ms; - --c-stagger: 30ms; - --c-opacity-start: 0; - --c-opacity-end: 1; - --c-blur-start: 0px; - --c-blur-end: 0px; - --c-skew: 10deg; - - display: inline-flex; - position: relative; - - transform-style: preserve-3d; - perspective: 500px; - transition: width var(--transition-duration) var(--transition-easing); - will-change: width; - overflow: hidden; - - .cycle-char { - display: inline-block; - transform-style: preserve-3d; - min-width: 0.25em; - backface-visibility: hidden; - - transition-property: transform, opacity, filter; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); - transition-delay: calc(var(--i, 0) * var(--c-stagger)); - - &.enter { - opacity: var(--c-opacity-end); - filter: blur(var(--c-blur-end)); - transform: translateY(0) rotateX(0) skewX(0); - } - - &.exit { - opacity: var(--c-opacity-start); - filter: blur(var(--c-blur-start)); - transform: translateY(50%) rotateX(90deg) skewX(var(--c-skew)); - } - - &.pre { - opacity: var(--c-opacity-start); - filter: blur(var(--c-blur-start)); - transition: none; - transform: translateY(-50%) rotateX(-90deg) skewX(calc(var(--c-skew) * -1)); - } - } -} diff --git a/packages/ui/src/components/cycle-label.tsx b/packages/ui/src/components/cycle-label.tsx deleted file mode 100644 index dc12bd75c8..0000000000 --- a/packages/ui/src/components/cycle-label.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import "./cycle-label.css" -import { createEffect, createSignal, JSX, on } from "solid-js" - -export interface CycleLabelProps extends JSX.HTMLAttributes { - value: string - onValueChange?: (value: string) => void - duration?: number | ((value: string) => number) - stagger?: number - opacity?: [number, number] - blur?: [number, number] - skewX?: number - onAnimationStart?: () => void - onAnimationEnd?: () => void -} - -const segmenter = - typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter("en", { granularity: "grapheme" }) : null - -const getChars = (text: string): string[] => - segmenter ? Array.from(segmenter.segment(text), (s) => s.segment) : text.split("") - -const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - -export function CycleLabel(props: CycleLabelProps) { - const getDuration = (text: string) => { - const d = - props.duration ?? - Number(getComputedStyle(document.documentElement).getPropertyValue("--transition-duration")) ?? - 200 - return typeof d === "function" ? d(text) : d - } - const stagger = () => props?.stagger ?? 30 - const opacity = () => props?.opacity ?? [0, 1] - const blur = () => props?.blur ?? [0, 0] - const skewX = () => props?.skewX ?? 10 - - let containerRef: HTMLSpanElement | undefined - let isAnimating = false - const [currentText, setCurrentText] = createSignal(props.value) - - const setChars = (el: HTMLElement, text: string, state: "enter" | "exit" | "pre" = "enter") => { - el.innerHTML = "" - const chars = getChars(text) - chars.forEach((char, i) => { - const span = document.createElement("span") - span.textContent = char === " " ? "\u00A0" : char - span.className = `cycle-char ${state}` - span.style.setProperty("--i", String(i)) - el.appendChild(span) - }) - } - - const animateToText = async (newText: string) => { - if (!containerRef || isAnimating) return - if (newText === currentText()) return - - isAnimating = true - props.onAnimationStart?.() - - const dur = getDuration(newText) - const stag = stagger() - - containerRef.style.width = containerRef.offsetWidth + "px" - - const oldChars = containerRef.querySelectorAll(".cycle-char") - oldChars.forEach((c) => c.classList.replace("enter", "exit")) - - const clone = containerRef.cloneNode(false) as HTMLElement - Object.assign(clone.style, { - position: "absolute", - visibility: "hidden", - width: "auto", - transition: "none", - }) - setChars(clone, newText) - document.body.appendChild(clone) - const nextWidth = clone.offsetWidth - clone.remove() - - const exitTime = oldChars.length * stag + dur - await wait(exitTime * 0.3) - - containerRef.style.width = nextWidth + "px" - - const widthDur = 200 - await wait(widthDur * 0.3) - - setChars(containerRef, newText, "pre") - containerRef.offsetWidth - - Array.from(containerRef.children).forEach((c) => (c.className = "cycle-char enter")) - setCurrentText(newText) - props.onValueChange?.(newText) - - const enterTime = getChars(newText).length * stag + dur - await wait(enterTime) - - containerRef.style.width = "" - isAnimating = false - props.onAnimationEnd?.() - } - - createEffect( - on( - () => props.value, - (newValue) => { - if (newValue !== currentText()) { - animateToText(newValue) - } - }, - ), - ) - - const initRef = (el: HTMLSpanElement) => { - containerRef = el - setChars(el, props.value) - } - - return ( - - ) -} diff --git a/packages/ui/src/components/dropdown-menu.css b/packages/ui/src/components/dropdown-menu.css index 18266ac1a1..cba041613e 100644 --- a/packages/ui/src/components/dropdown-menu.css +++ b/packages/ui/src/components/dropdown-menu.css @@ -2,29 +2,26 @@ [data-component="dropdown-menu-sub-content"] { min-width: 8rem; overflow: hidden; - border: none; border-radius: var(--radius-md); - box-shadow: var(--shadow-xs-border); + border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent); background-clip: padding-box; background-color: var(--surface-raised-stronger-non-alpha); padding: 4px; - z-index: 100; + box-shadow: var(--shadow-md); + z-index: 50; transform-origin: var(--kb-menu-content-transform-origin); - &:focus-within, - &:focus { + &:focus, + &:focus-visible { outline: none; } - animation: dropdownMenuContentHide var(--transition-duration) var(--transition-easing) forwards; - - @starting-style { - animation: none; + &[data-closed] { + animation: dropdown-menu-close 0.15s ease-out; } &[data-expanded] { - pointer-events: auto; - animation: dropdownMenuContentShow var(--transition-duration) var(--transition-easing) forwards; + animation: dropdown-menu-open 0.15s ease-out; } } @@ -41,22 +38,18 @@ padding: 4px 8px; border-radius: var(--radius-sm); cursor: default; + user-select: none; outline: none; font-family: var(--font-family-sans); - font-size: var(--font-size-base); + font-size: var(--font-size-small); font-weight: var(--font-weight-medium); line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); color: var(--text-strong); - transition-property: background-color, color; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); - user-select: none; - - &:hover { - background-color: var(--surface-raised-base-hover); + &[data-highlighted] { + background: var(--surface-raised-base-hover); } &[data-disabled] { @@ -68,8 +61,6 @@ [data-slot="dropdown-menu-sub-trigger"] { &[data-expanded] { background: var(--surface-raised-base-hover); - outline: none; - border: none; } } @@ -111,24 +102,24 @@ } } -@keyframes dropdownMenuContentShow { +@keyframes dropdown-menu-open { from { opacity: 0; - transform: scaleY(0.95); + transform: scale(0.96); } to { opacity: 1; - transform: scaleY(1); + transform: scale(1); } } -@keyframes dropdownMenuContentHide { +@keyframes dropdown-menu-close { from { opacity: 1; - transform: scaleY(1); + transform: scale(1); } to { opacity: 0; - transform: scaleY(0.95); + transform: scale(0.96); } } diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 97488a42f0..544c6abdd2 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -80,16 +80,13 @@ const icons = { export interface IconProps extends ComponentProps<"svg"> { name: keyof typeof icons - size?: "small" | "normal" | "medium" | "large" | number + size?: "small" | "normal" | "medium" | "large" } export function Icon(props: IconProps) { const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) return ( -
+
- +
m.id === perm.tool!.messageID) + const message = findLast(messages, (m) => m.id === perm.tool!.messageID) if (!message) return undefined const parts = data.store.part[message.id] ?? [] for (const part of parts) { diff --git a/packages/ui/src/components/morph-chevron.css b/packages/ui/src/components/morph-chevron.css deleted file mode 100644 index f6edb3f649..0000000000 --- a/packages/ui/src/components/morph-chevron.css +++ /dev/null @@ -1,10 +0,0 @@ -[data-slot="morph-chevron-svg"] { - width: 16px; - height: 16px; - display: block; - fill: none; - stroke-width: 1.5; - stroke: currentcolor; - stroke-linecap: round; - stroke-linejoin: round; -} diff --git a/packages/ui/src/components/morph-chevron.tsx b/packages/ui/src/components/morph-chevron.tsx deleted file mode 100644 index 280aeb7e34..0000000000 --- a/packages/ui/src/components/morph-chevron.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { createEffect, createUniqueId, on } from "solid-js" - -export interface MorphChevronProps { - expanded: boolean - class?: string -} - -const COLLAPSED = "M4 6L8 10L12 6" -const EXPANDED = "M4 10L8 6L12 10" - -export function MorphChevron(props: MorphChevronProps) { - const id = createUniqueId() - let path: SVGPathElement | undefined - let expandAnim: SVGAnimateElement | undefined - let collapseAnim: SVGAnimateElement | undefined - - createEffect( - on( - () => props.expanded, - (expanded, prev) => { - if (prev === undefined) { - // Set initial state without animation - path?.setAttribute("d", expanded ? EXPANDED : COLLAPSED) - return - } - if (expanded) { - expandAnim?.beginElement() - } else { - collapseAnim?.beginElement() - } - }, - ), - ) - - return ( - - ) -} diff --git a/packages/ui/src/components/popover.css b/packages/ui/src/components/popover.css index d200fe8b24..b49542afd9 100644 --- a/packages/ui/src/components/popover.css +++ b/packages/ui/src/components/popover.css @@ -15,35 +15,16 @@ transform-origin: var(--kb-popover-content-transform-origin); - animation: popoverContentHide var(--transition-duration) var(--transition-easing) forwards; + &:focus-within { + outline: none; + } - @starting-style { - animation: none; + &[data-closed] { + animation: popover-close 0.15s ease-out; } &[data-expanded] { - pointer-events: auto; - animation: popoverContentShow var(--transition-duration) var(--transition-easing) forwards; - } - - [data-origin-top-right] { - transform-origin: top right; - } - - [data-origin-top-left] { - transform-origin: top left; - } - - [data-origin-bottom-right] { - transform-origin: bottom right; - } - - [data-origin-bottom-left] { - transform-origin: bottom left; - } - - &:focus-within { - outline: none; + animation: popover-open 0.15s ease-out; } [data-slot="popover-header"] { @@ -94,39 +75,24 @@ } } -@keyframes popoverContentShow { +@keyframes popover-open { from { opacity: 0; - transform: scaleY(0.95); + transform: scale(0.96); } to { opacity: 1; - transform: scaleY(1); + transform: scale(1); } } -@keyframes popoverContentHide { +@keyframes popover-close { from { opacity: 1; - transform: scaleY(1); + transform: scale(1); } to { opacity: 0; - transform: scaleY(0.95); - } -} - -[data-component="model-popover-content"] { - transform-origin: var(--kb-popper-content-transform-origin); - pointer-events: none; - animation: popoverContentHide var(--transition-duration) var(--transition-easing) forwards; - - @starting-style { - animation: none; - } - - &[data-expanded] { - pointer-events: auto; - animation: popoverContentShow var(--transition-duration) var(--transition-easing) forwards; + transform: scale(0.96); } } diff --git a/packages/ui/src/components/reasoning-icon.css b/packages/ui/src/components/reasoning-icon.css deleted file mode 100644 index 26fbc01448..0000000000 --- a/packages/ui/src/components/reasoning-icon.css +++ /dev/null @@ -1,9 +0,0 @@ -[data-component="reasoning-icon"] { - color: var(--icon-strong-base); - - [data-slot="reasoning-icon-percentage"] { - transition: clip-path 200ms cubic-bezier(0.25, 0, 0.5, 1); - clip-path: inset(calc(100% - var(--reasoning-icon-percentage) * 100%) 0 0 0); - opacity: calc(var(--reasoning-icon-percentage) * 0.75); - } -} diff --git a/packages/ui/src/components/reasoning-icon.tsx b/packages/ui/src/components/reasoning-icon.tsx deleted file mode 100644 index 7bac49ffd2..0000000000 --- a/packages/ui/src/components/reasoning-icon.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { type ComponentProps, splitProps } from "solid-js" - -export interface ReasoningIconProps extends Pick, "class" | "classList"> { - percentage: number - size?: number - strokeWidth?: number -} - -export function ReasoningIcon(props: ReasoningIconProps) { - const [split, rest] = splitProps(props, ["percentage", "size", "strokeWidth", "class", "classList"]) - - const size = () => split.size || 16 - const strokeWidth = () => split.strokeWidth || 1.25 - - return ( - - - - - ) -} diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css index eaba6fd6d2..25dd2eb40b 100644 --- a/packages/ui/src/components/select.css +++ b/packages/ui/src/components/select.css @@ -1,13 +1,7 @@ [data-component="select"] { [data-slot="select-select-trigger"] { - display: flex; - padding: 4px 8px !important; - align-items: center; - justify-content: space-between; + padding: 0 4px 0 8px; box-shadow: none; - transition-property: background-color; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); [data-slot="select-select-trigger-value"] { overflow: hidden; @@ -21,10 +15,10 @@ align-items: center; justify-content: center; flex-shrink: 0; - color: var(--icon-base); + color: var(--text-weak); + transition: transform 0.1s ease-in-out; } - &:hover, &[data-expanded] { &[data-variant="secondary"] { background-color: var(--button-secondary-hover); @@ -36,13 +30,13 @@ background-color: var(--icon-strong-active); } } - &:not([data-expanded]):focus, + &:not([data-expanded]):focus-visible { &[data-variant="secondary"] { background-color: var(--button-secondary-base); } &[data-variant="ghost"] { - background-color: transparent; + background-color: var(--surface-raised-base-hover); } &[data-variant="primary"] { background-color: var(--icon-strong-base); @@ -52,10 +46,10 @@ &[data-trigger-style="settings"] { [data-slot="select-select-trigger"] { - padding: 6px 6px 6px 10px; + padding: 6px 6px 6px 12px; box-shadow: none; border-radius: 6px; - field-sizing: content; + min-width: 160px; height: 32px; justify-content: flex-end; gap: 12px; @@ -67,7 +61,6 @@ white-space: nowrap; font-size: var(--font-size-base); font-weight: var(--font-weight-regular); - padding: 4px 8px 4px 4px; } [data-slot="select-select-trigger-icon"] { width: 16px; @@ -98,26 +91,17 @@ } [data-component="select-content"] { - min-width: 8rem; + min-width: 104px; max-width: 23rem; overflow: hidden; border-radius: var(--radius-md); background-color: var(--surface-raised-stronger-non-alpha); padding: 4px; box-shadow: var(--shadow-xs-border); - z-index: 50; - transform-origin: var(--kb-popper-content-transform-origin); - pointer-events: none; - - animation: selectContentHide var(--transition-duration) var(--transition-easing) forwards; - - @starting-style { - animation: none; - } + z-index: 60; &[data-expanded] { - pointer-events: auto; - animation: selectContentShow var(--transition-duration) var(--transition-easing) forwards; + animation: select-open 0.15s ease-out; } [data-slot="select-select-content-list"] { @@ -127,38 +111,43 @@ overflow-x: hidden; display: flex; flex-direction: column; + &:focus { outline: none; } + > *:not([role="presentation"]) + *:not([role="presentation"]) { margin-top: 2px; } } + [data-slot="select-select-item"] { position: relative; display: flex; align-items: center; - padding: 4px 8px; + padding: 2px 8px; gap: 12px; - border-radius: var(--radius-sm); + border-radius: 4px; + cursor: default; /* text-12-medium */ font-family: var(--font-family-sans); - font-size: var(--font-size-base); + font-size: var(--font-size-small); font-style: normal; font-weight: var(--font-weight-medium); line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); + color: var(--text-strong); - transition-property: background-color, color; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); + transition: + background-color 0.2s ease-in-out, + color 0.2s ease-in-out; outline: none; user-select: none; - &:hover { - background-color: var(--surface-raised-base-hover); + &[data-highlighted] { + background: var(--surface-raised-base-hover); } &[data-disabled] { background-color: var(--surface-raised-base); @@ -171,11 +160,6 @@ margin-left: auto; width: 16px; height: 16px; - color: var(--icon-strong-base); - - svg { - color: var(--icon-strong-base); - } } &:focus { outline: none; @@ -187,9 +171,13 @@ } [data-component="select-content"][data-trigger-style="settings"] { - field-sizing: content; + min-width: 160px; border-radius: 8px; - padding: 0 0 0 4px; + padding: 0; + + [data-slot="select-select-content-list"] { + padding: 4px; + } [data-slot="select-select-item"] { /* text-14-regular */ @@ -202,24 +190,13 @@ } } -@keyframes selectContentShow { +@keyframes select-open { from { opacity: 0; - transform: scaleY(0.95); + transform: scale(0.95); } to { opacity: 1; - transform: scaleY(1); - } -} - -@keyframes selectContentHide { - from { - opacity: 1; - transform: scaleY(1); - } - to { - opacity: 0; - transform: scaleY(0.95); + transform: scale(1); } } diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index fef00500a7..0386c329ec 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -1,10 +1,8 @@ import { Select as Kobalte } from "@kobalte/core/select" -import { createMemo, createSignal, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" +import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" import { pipe, groupBy, entries, map } from "remeda" -import { Show } from "solid-js" import { Button, ButtonProps } from "./button" import { Icon } from "./icon" -import { MorphChevron } from "./morph-chevron" export type SelectProps = Omit>, "value" | "onSelect" | "children"> & { placeholder?: string @@ -40,8 +38,6 @@ export function Select(props: SelectProps & Omit) "triggerVariant", ]) - const [isOpen, setIsOpen] = createSignal(false) - const state = { key: undefined as string | undefined, cleanup: undefined as (() => void) | void, @@ -89,7 +85,7 @@ export function Select(props: SelectProps & Omit) data-component="select" data-trigger-style={local.triggerVariant} placement={local.triggerVariant === "settings" ? "bottom-end" : "bottom-start"} - gutter={8} + gutter={4} value={local.current} options={grouped()} optionValue={(x) => (local.value ? local.value(x) : (x as string))} @@ -119,7 +115,7 @@ export function Select(props: SelectProps & Omit) : (itemProps.item.rawValue as string)} - + )} @@ -128,7 +124,6 @@ export function Select(props: SelectProps & Omit) stop() }} onOpenChange={(open) => { - setIsOpen(open) local.onOpenChange?.(open) if (!open) stop() }} @@ -154,12 +149,7 @@ export function Select(props: SelectProps & Omit) }} - - - - - - + diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 55e1a16d18..c038f69f67 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -49,8 +49,6 @@ @import "../components/toast.css" layer(components); @import "../components/tooltip.css" layer(components); @import "../components/typewriter.css" layer(components); -@import "../components/morph-chevron.css" layer(components); -@import "../components/reasoning-icon.css" layer(components); @import "./utilities.css" layer(utilities); @import "./animations.css" layer(utilities); diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css index 82a913c883..8c954f1fe4 100644 --- a/packages/ui/src/styles/utilities.css +++ b/packages/ui/src/styles/utilities.css @@ -1,17 +1,6 @@ :root { interpolate-size: allow-keywords; - /* Transition tokens */ - --transition-duration: 200ms; - --transition-easing: cubic-bezier(0.25, 0, 0.5, 1); - --transition-fast: 150ms; - --transition-slow: 300ms; - - /* Allow height transitions from 0 to auto */ - @supports (interpolate-size: allow-keywords) { - interpolate-size: allow-keywords; - } - [data-popper-positioner] { pointer-events: none; } @@ -140,34 +129,3 @@ line-height: var(--line-height-x-large); /* 120% */ letter-spacing: var(--letter-spacing-tightest); } - -/* Transition utility classes */ -.transition-colors { - transition-property: background-color, border-color, color, fill, stroke; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); -} - -.transition-opacity { - transition-property: opacity; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); -} - -.transition-transform { - transition-property: transform; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); -} - -.transition-shadow { - transition-property: box-shadow; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); -} - -.transition-interactive { - transition-property: background-color, border-color, color, box-shadow, opacity; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); -} From 0405b425f528ce9042ff0eeb511512e239cb1b5f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:40:18 -0600 Subject: [PATCH 45/64] feat(app): file search --- packages/ui/src/components/code.tsx | 462 +++++++++++++++++++++++++++- packages/ui/src/pierre/index.ts | 8 + 2 files changed, 467 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index dbf942dbb6..38dfcd8380 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -1,7 +1,8 @@ import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs" -import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js" +import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js" import { createDefaultOptions, styleVariables } from "../pierre" import { getWorkerPool } from "../pierre/worker" +import { Icon } from "./icon" type SelectionSide = "additions" | "deletions" @@ -46,8 +47,88 @@ function findSide(node: Node | null): SelectionSide | undefined { return "additions" } +type FindHost = { + element: () => HTMLElement | undefined + open: () => void + close: () => void + next: (dir: 1 | -1) => void + isOpen: () => boolean +} + +const findHosts = new Set() +let findTarget: FindHost | undefined +let findCurrent: FindHost | undefined +let findInstalled = false + +function isEditable(node: unknown): boolean { + if (!(node instanceof HTMLElement)) return false + if (node.closest("[data-prevent-autofocus]")) return true + if (node.isContentEditable) return true + return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName) +} + +function hostForNode(node: unknown): FindHost | undefined { + if (!(node instanceof Node)) return + for (const host of findHosts) { + const el = host.element() + if (el && el.isConnected && el.contains(node)) return host + } +} + +function installFindShortcuts() { + if (findInstalled) return + if (typeof window === "undefined") return + findInstalled = true + + window.addEventListener( + "keydown", + (event) => { + if (event.defaultPrevented) return + + const mod = event.metaKey || event.ctrlKey + if (!mod) return + + const key = event.key.toLowerCase() + + if (key === "g") { + const host = findCurrent + if (!host || !host.isOpen()) return + event.preventDefault() + event.stopPropagation() + host.next(event.shiftKey ? -1 : 1) + return + } + + if (key !== "f") return + + const current = findCurrent + if (current && current.isOpen()) { + event.preventDefault() + event.stopPropagation() + current.open() + return + } + + if (isEditable(event.target)) return + + const host = hostForNode(document.activeElement) ?? findTarget ?? Array.from(findHosts)[0] + if (!host) return + + event.preventDefault() + event.stopPropagation() + host.open() + }, + { capture: true }, + ) +} + export function Code(props: CodeProps) { + let wrapper!: HTMLDivElement let container!: HTMLDivElement + let findInput: HTMLInputElement | undefined + let findOverlay!: HTMLDivElement + let findOverlayFrame: number | undefined + let findOverlayScroll: HTMLElement[] = [] let observer: MutationObserver | undefined let renderToken = 0 let selectionFrame: number | undefined @@ -70,6 +151,13 @@ export function Code(props: CodeProps) { const [rendered, setRendered] = createSignal(0) + const [findOpen, setFindOpen] = createSignal(false) + const [findQuery, setFindQuery] = createSignal("") + const [findIndex, setFindIndex] = createSignal(0) + const [findCount, setFindCount] = createSignal(0) + let findMode: "highlights" | "overlay" = "overlay" + let findHits: Range[] = [] + const file = createMemo( () => new File( @@ -104,6 +192,296 @@ export function Code(props: CodeProps) { host.removeAttribute("data-color-scheme") } + const supportsHighlights = () => { + const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown } + return typeof g.Highlight === "function" && g.CSS?.highlights != null + } + + const clearHighlightFind = () => { + const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights + if (!api) return + api.delete("opencode-find") + api.delete("opencode-find-current") + } + + const clearOverlayScroll = () => { + for (const el of findOverlayScroll) el.removeEventListener("scroll", scheduleOverlay) + findOverlayScroll = [] + } + + const clearOverlay = () => { + if (findOverlayFrame !== undefined) { + cancelAnimationFrame(findOverlayFrame) + findOverlayFrame = undefined + } + findOverlay.innerHTML = "" + } + + const renderOverlay = () => { + if (findMode !== "overlay") { + clearOverlay() + return + } + + clearOverlay() + if (findHits.length === 0) return + + const base = wrapper.getBoundingClientRect() + const current = findIndex() + + const frag = document.createDocumentFragment() + for (let i = 0; i < findHits.length; i++) { + const range = findHits[i] + const active = i === current + + for (const rect of Array.from(range.getClientRects())) { + if (!rect.width || !rect.height) continue + + const el = document.createElement("div") + el.style.position = "absolute" + el.style.left = `${Math.round(rect.left - base.left)}px` + el.style.top = `${Math.round(rect.top - base.top)}px` + el.style.width = `${Math.round(rect.width)}px` + el.style.height = `${Math.round(rect.height)}px` + el.style.borderRadius = "2px" + el.style.backgroundColor = active ? "var(--surface-warning-strong)" : "var(--surface-warning-base)" + el.style.opacity = active ? "0.55" : "0.35" + if (active) el.style.boxShadow = "inset 0 0 0 1px var(--border-warning-base)" + frag.appendChild(el) + } + } + + findOverlay.appendChild(frag) + } + + function scheduleOverlay() { + if (findMode !== "overlay") return + if (!findOpen()) return + if (findOverlayFrame !== undefined) return + + findOverlayFrame = requestAnimationFrame(() => { + findOverlayFrame = undefined + renderOverlay() + }) + } + + const syncOverlayScroll = () => { + if (findMode !== "overlay") return + const root = getRoot() + + const next = root + ? Array.from(root.querySelectorAll("[data-code]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + : [] + if (next.length === findOverlayScroll.length && next.every((el, i) => el === findOverlayScroll[i])) return + + clearOverlayScroll() + findOverlayScroll = next + for (const el of findOverlayScroll) el.addEventListener("scroll", scheduleOverlay, { passive: true }) + } + + const clearFind = () => { + clearHighlightFind() + clearOverlay() + clearOverlayScroll() + findHits = [] + setFindCount(0) + setFindIndex(0) + } + + const scanFind = (root: ShadowRoot, query: string) => { + const needle = query.toLowerCase() + const out: Range[] = [] + + const cols = Array.from(root.querySelectorAll("[data-column-content]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + + for (const col of cols) { + const text = col.textContent + if (!text) continue + + const hay = text.toLowerCase() + let idx = hay.indexOf(needle) + if (idx === -1) continue + + const nodes: Text[] = [] + const ends: number[] = [] + const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT) + let node = walker.nextNode() + let pos = 0 + + while (node) { + if (node instanceof Text) { + pos += node.data.length + nodes.push(node) + ends.push(pos) + } + node = walker.nextNode() + } + + if (nodes.length === 0) continue + + const locate = (at: number) => { + let lo = 0 + let hi = ends.length - 1 + while (lo < hi) { + const mid = (lo + hi) >> 1 + if (ends[mid] >= at) hi = mid + else lo = mid + 1 + } + const prev = lo === 0 ? 0 : ends[lo - 1] + return { node: nodes[lo], offset: at - prev } + } + + while (idx !== -1) { + const start = locate(idx) + const end = locate(idx + query.length) + const range = document.createRange() + range.setStart(start.node, start.offset) + range.setEnd(end.node, end.offset) + out.push(range) + idx = hay.indexOf(needle, idx + query.length) + } + } + + return out + } + + const scrollToRange = (range: Range) => { + const start = range.startContainer + const el = start instanceof Element ? start : start.parentElement + el?.scrollIntoView({ block: "center", inline: "center" }) + } + + const setHighlights = (ranges: Range[], index: number) => { + const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights + const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight + if (!api || typeof Highlight !== "function") return false + + api.delete("opencode-find") + api.delete("opencode-find-current") + + const active = ranges[index] + if (active) api.set("opencode-find-current", new Highlight(active)) + + const rest = ranges.filter((_, i) => i !== index) + if (rest.length > 0) api.set("opencode-find", new Highlight(...rest)) + return true + } + + const applyFind = (opts?: { reset?: boolean; scroll?: boolean }) => { + if (!findOpen()) return + + const query = findQuery().trim() + if (!query) { + clearFind() + return + } + + const root = getRoot() + if (!root) return + + findMode = supportsHighlights() ? "highlights" : "overlay" + + const ranges = scanFind(root, query) + const total = ranges.length + const desired = opts?.reset ? 0 : findIndex() + const index = total ? Math.min(desired, total - 1) : 0 + + findHits = ranges + setFindCount(total) + setFindIndex(index) + + const active = ranges[index] + if (findMode === "highlights") { + clearOverlay() + clearOverlayScroll() + if (!setHighlights(ranges, index)) { + findMode = "overlay" + clearHighlightFind() + syncOverlayScroll() + scheduleOverlay() + } + if (opts?.scroll && active) scrollToRange(active) + return + } + + clearHighlightFind() + syncOverlayScroll() + if (opts?.scroll && active) scrollToRange(active) + scheduleOverlay() + } + + const closeFind = () => { + setFindOpen(false) + clearFind() + if (findCurrent === host) findCurrent = undefined + } + + const stepFind = (dir: 1 | -1) => { + if (!findOpen()) return + const total = findCount() + if (total <= 0) return + + const index = (findIndex() + dir + total) % total + setFindIndex(index) + + const active = findHits[index] + if (!active) return + + if (findMode === "highlights") { + if (!setHighlights(findHits, index)) { + findMode = "overlay" + applyFind({ reset: true, scroll: true }) + return + } + scrollToRange(active) + return + } + + clearHighlightFind() + syncOverlayScroll() + scrollToRange(active) + scheduleOverlay() + } + + const host: FindHost = { + element: () => wrapper, + isOpen: () => findOpen(), + next: stepFind, + open: () => { + if (findCurrent && findCurrent !== host) findCurrent.close() + findCurrent = host + findTarget = host + + if (!findOpen()) setFindOpen(true) + requestAnimationFrame(() => { + findInput?.focus() + findInput?.select() + }) + applyFind({ scroll: true }) + }, + close: closeFind, + } + + onMount(() => { + findMode = supportsHighlights() ? "highlights" : "overlay" + installFindShortcuts() + findHosts.add(host) + if (!findTarget) findTarget = host + + onCleanup(() => { + findHosts.delete(host) + if (findCurrent === host) { + findCurrent = undefined + clearHighlightFind() + } + if (findTarget === host) findTarget = undefined + }) + }) + const applyCommentedLines = (ranges: SelectedLineRange[]) => { const root = getRoot() if (!root) return @@ -189,6 +567,7 @@ export function Code(props: CodeProps) { requestAnimationFrame(() => { if (token !== renderToken) return applySelection(lastSelection) + applyFind({ reset: true }) local.onRendered?.() }) } @@ -466,6 +845,13 @@ export function Code(props: CodeProps) { onCleanup(() => { observer?.disconnect() + clearOverlayScroll() + clearOverlay() + if (findCurrent === host) { + findCurrent = undefined + clearHighlightFind() + } + if (selectionFrame !== undefined) { cancelAnimationFrame(selectionFrame) selectionFrame = undefined @@ -487,11 +873,81 @@ export function Code(props: CodeProps) {
+ ref={wrapper} + tabIndex={0} + onPointerDown={() => { + findTarget = host + wrapper.focus({ preventScroll: true }) + }} + onFocus={() => { + findTarget = host + }} + > +
+
+ +
e.stopPropagation()} + > + + { + setFindQuery(e.currentTarget.value) + setFindIndex(0) + applyFind({ reset: true, scroll: true }) + }} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault() + closeFind() + return + } + if (e.key !== "Enter") return + e.preventDefault() + stepFind(e.shiftKey ? -1 : 1) + }} + /> +
+ {findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"} +
+ + + +
+
+
) } diff --git a/packages/ui/src/pierre/index.ts b/packages/ui/src/pierre/index.ts index f0da519793..f6446f3cc8 100644 --- a/packages/ui/src/pierre/index.ts +++ b/packages/ui/src/pierre/index.ts @@ -57,6 +57,14 @@ const unsafeCSS = ` background-color: var(--diffs-bg-selection-text); } +::highlight(opencode-find) { + background-color: rgb(from var(--surface-warning-base) r g b / 0.35); +} + +::highlight(opencode-find-current) { + background-color: rgb(from var(--surface-warning-strong) r g b / 0.55); +} + [data-diffs] [data-comment-selected]:not([data-selected-line]) [data-column-content] { box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection); } From 69f5f657f2b3b98d213a7bedd46624cda0e78bcd Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:42:05 -0600 Subject: [PATCH 46/64] chore: cleanup --- packages/ui/src/components/code.tsx | 118 +++++++++++++++++++++------- 1 file changed, 91 insertions(+), 27 deletions(-) diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index 38dfcd8380..0b0646f0ee 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -109,9 +109,8 @@ function installFindShortcuts() { return } - if (isEditable(event.target)) return - - const host = hostForNode(document.activeElement) ?? findTarget ?? Array.from(findHosts)[0] + const host = + hostForNode(document.activeElement) ?? hostForNode(event.target) ?? findTarget ?? Array.from(findHosts)[0] if (!host) return event.preventDefault() @@ -126,9 +125,11 @@ export function Code(props: CodeProps) { let wrapper!: HTMLDivElement let container!: HTMLDivElement let findInput: HTMLInputElement | undefined + let findBar: HTMLDivElement | undefined let findOverlay!: HTMLDivElement let findOverlayFrame: number | undefined let findOverlayScroll: HTMLElement[] = [] + let findPositionFrame: number | undefined let observer: MutationObserver | undefined let renderToken = 0 let selectionFrame: number | undefined @@ -290,6 +291,41 @@ export function Code(props: CodeProps) { setFindIndex(0) } + const getScrollParent = (el: HTMLElement): HTMLElement | null => { + let parent = el.parentElement + while (parent) { + const style = getComputedStyle(parent) + if (style.overflowY === "auto" || style.overflowY === "scroll") return parent + parent = parent.parentElement + } + return null + } + + const positionFindBar = () => { + if (!findBar || !wrapper) return + const scrollParent = getScrollParent(wrapper) + if (!scrollParent) { + findBar.style.position = "absolute" + findBar.style.top = "8px" + findBar.style.right = "8px" + findBar.style.left = "" + return + } + const scrollTop = scrollParent.scrollTop + findBar.style.position = "absolute" + findBar.style.top = `${scrollTop + 8}px` + findBar.style.right = "8px" + findBar.style.left = "" + } + + const scheduleFindPosition = () => { + if (findPositionFrame !== undefined) return + findPositionFrame = requestAnimationFrame(() => { + findPositionFrame = undefined + positionFindBar() + }) + } + const scanFind = (root: ShadowRoot, query: string) => { const needle = query.toLowerCase() const out: Range[] = [] @@ -458,6 +494,7 @@ export function Code(props: CodeProps) { if (!findOpen()) setFindOpen(true) requestAnimationFrame(() => { + positionFindBar() findInput?.focus() findInput?.select() }) @@ -482,6 +519,25 @@ export function Code(props: CodeProps) { }) }) + createEffect(() => { + if (!findOpen()) return + const scrollParent = getScrollParent(wrapper) + const target = scrollParent ?? window + + const handler = () => scheduleFindPosition() + target.addEventListener("scroll", handler, { passive: true }) + window.addEventListener("resize", handler, { passive: true }) + + onCleanup(() => { + target.removeEventListener("scroll", handler) + window.removeEventListener("resize", handler) + if (findPositionFrame !== undefined) { + cancelAnimationFrame(findPositionFrame) + findPositionFrame = undefined + } + }) + }) + const applyCommentedLines = (ranges: SelectedLineRange[]) => { const root = getRoot() if (!root) return @@ -862,6 +918,11 @@ export function Code(props: CodeProps) { dragFrame = undefined } + if (findPositionFrame !== undefined) { + cancelAnimationFrame(findPositionFrame) + findPositionFrame = undefined + } + dragStart = undefined dragEnd = undefined dragMoved = false @@ -888,19 +949,18 @@ export function Code(props: CodeProps) { findTarget = host }} > -
-
e.stopPropagation()} > - + { setFindQuery(e.currentTarget.value) setFindIndex(0) @@ -917,30 +977,32 @@ export function Code(props: CodeProps) { stepFind(e.shiftKey ? -1 : 1) }} /> -
+
{findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"}
+
+ + +
- -
+
+
) } From befb5d54fbfd8df9706c49159095b1ef7f2ec23d Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:24:27 -0600 Subject: [PATCH 47/64] chore: cleanup --- packages/ui/src/components/code.tsx | 54 +++++++++++------------------ 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index 0b0646f0ee..e3e1e56520 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -129,7 +129,7 @@ export function Code(props: CodeProps) { let findOverlay!: HTMLDivElement let findOverlayFrame: number | undefined let findOverlayScroll: HTMLElement[] = [] - let findPositionFrame: number | undefined + let findScroll: HTMLElement | undefined let observer: MutationObserver | undefined let renderToken = 0 let selectionFrame: number | undefined @@ -303,29 +303,13 @@ export function Code(props: CodeProps) { const positionFindBar = () => { if (!findBar || !wrapper) return - const scrollParent = getScrollParent(wrapper) - if (!scrollParent) { - findBar.style.position = "absolute" - findBar.style.top = "8px" - findBar.style.right = "8px" - findBar.style.left = "" - return - } - const scrollTop = scrollParent.scrollTop + const scrollTop = findScroll ? findScroll.scrollTop : window.scrollY findBar.style.position = "absolute" findBar.style.top = `${scrollTop + 8}px` findBar.style.right = "8px" findBar.style.left = "" } - const scheduleFindPosition = () => { - if (findPositionFrame !== undefined) return - findPositionFrame = requestAnimationFrame(() => { - findPositionFrame = undefined - positionFindBar() - }) - } - const scanFind = (root: ShadowRoot, query: string) => { const needle = query.toLowerCase() const out: Range[] = [] @@ -440,13 +424,19 @@ export function Code(props: CodeProps) { syncOverlayScroll() scheduleOverlay() } - if (opts?.scroll && active) scrollToRange(active) + if (opts?.scroll && active) { + scrollToRange(active) + positionFindBar() + } return } clearHighlightFind() syncOverlayScroll() - if (opts?.scroll && active) scrollToRange(active) + if (opts?.scroll && active) { + scrollToRange(active) + positionFindBar() + } scheduleOverlay() } @@ -474,12 +464,14 @@ export function Code(props: CodeProps) { return } scrollToRange(active) + positionFindBar() return } clearHighlightFind() syncOverlayScroll() scrollToRange(active) + positionFindBar() scheduleOverlay() } @@ -492,13 +484,14 @@ export function Code(props: CodeProps) { findCurrent = host findTarget = host + findScroll = getScrollParent(wrapper) ?? undefined if (!findOpen()) setFindOpen(true) requestAnimationFrame(() => { + applyFind({ scroll: true }) positionFindBar() findInput?.focus() findInput?.select() }) - applyFind({ scroll: true }) }, close: closeFind, } @@ -521,20 +514,18 @@ export function Code(props: CodeProps) { createEffect(() => { if (!findOpen()) return - const scrollParent = getScrollParent(wrapper) - const target = scrollParent ?? window + findScroll = getScrollParent(wrapper) ?? undefined + const target = findScroll ?? window - const handler = () => scheduleFindPosition() + const handler = () => positionFindBar() target.addEventListener("scroll", handler, { passive: true }) window.addEventListener("resize", handler, { passive: true }) + handler() onCleanup(() => { target.removeEventListener("scroll", handler) window.removeEventListener("resize", handler) - if (findPositionFrame !== undefined) { - cancelAnimationFrame(findPositionFrame) - findPositionFrame = undefined - } + findScroll = undefined }) }) @@ -918,11 +909,6 @@ export function Code(props: CodeProps) { dragFrame = undefined } - if (findPositionFrame !== undefined) { - cancelAnimationFrame(findPositionFrame) - findPositionFrame = undefined - } - dragStart = undefined dragEnd = undefined dragMoved = false @@ -977,7 +963,7 @@ export function Code(props: CodeProps) { stepFind(e.shiftKey ? -1 : 1) }} /> -
+
{findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"}
From c002ca03ba7a617090ab104c5d2a07f1c8be2958 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:22:10 -0600 Subject: [PATCH 48/64] feat(app): search through sessions --- packages/app/src/pages/layout.tsx | 410 +++++++++++++++++++++------- packages/ui/src/components/list.tsx | 33 ++- 2 files changed, 343 insertions(+), 100 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 5d285c5ecc..fe8618b739 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -27,6 +27,7 @@ import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { InlineInput } from "@opencode-ai/ui/inline-input" +import { List, type ListRef } from "@opencode-ai/ui/list" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { HoverCard } from "@opencode-ai/ui/hover-card" import { MessageNav } from "@opencode-ai/ui/message-nav" @@ -2682,6 +2683,14 @@ export default function Layout(props: ParentProps) { } const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => { + type SearchItem = { + id: string + title: string + directory: string + label: string + archived?: number + } + const projectName = createMemo(() => { const project = panelProps.project if (!project) return "" @@ -2697,6 +2706,107 @@ export default function Layout(props: ParentProps) { }) const homedir = createMemo(() => globalSync.data.path.home) + const [search, setSearch] = createStore({ + value: "", + }) + const searching = createMemo(() => search.value.trim().length > 0) + let searchRef: HTMLInputElement | undefined + let listRef: ListRef | undefined + + const token = { value: 0 } + let inflight: Promise | undefined + let all: SearchItem[] | undefined + + const reset = () => { + token.value += 1 + inflight = undefined + all = undefined + setSearch({ value: "" }) + listRef = undefined + } + + const open = (item: SearchItem | undefined) => { + if (!item) return + + const href = `/${base64Encode(item.directory)}/session/${item.id}` + if (!layout.sidebar.opened()) { + setState("hoverSession", undefined) + setState("hoverProject", undefined) + } + reset() + navigate(href) + layout.mobileSidebar.hide() + } + + const items = (filter: string) => { + const query = filter.trim() + if (!query) { + token.value += 1 + inflight = undefined + all = undefined + return [] as SearchItem[] + } + + const project = panelProps.project + if (!project) return [] as SearchItem[] + if (all) return all + if (inflight) return inflight + + const current = token.value + const dirs = workspaceIds(project) + inflight = Promise.all( + dirs.map((input) => { + const directory = workspaceKey(input) + const [workspaceStore] = globalSync.child(directory, { bootstrap: false }) + const kind = + directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") + const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id) + const label = `${kind} : ${name}` + return globalSDK.client.session + .list({ directory, roots: true }) + .then((x) => + (x.data ?? []) + .filter((s) => !!s?.id) + .map((s) => ({ + id: s.id, + title: s.title ?? language.t("command.session.new"), + directory, + label, + archived: s.time?.archived, + })), + ) + .catch(() => [] as SearchItem[]) + }), + ) + .then((results) => { + if (token.value !== current) return [] as SearchItem[] + + const seen = new Set() + const next = results.flat().filter((item) => { + const key = `${item.directory}:${item.id}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + all = next + return next + }) + .catch(() => [] as SearchItem[]) + .finally(() => { + inflight = undefined + }) + + return inflight + } + + createEffect( + on( + () => panelProps.project?.worktree, + () => reset(), + { defer: true }, + ), + ) + return (
- + {(p) => ( <>
@@ -2714,7 +2824,7 @@ export default function Layout(props: ParentProps) { renameProject(p, next)} + onSave={(next) => renameProject(p(), next)} class="text-16-medium text-text-strong truncate" displayClass="text-16-medium text-text-strong truncate" stopPropagation @@ -2723,7 +2833,7 @@ export default function Layout(props: ParentProps) { - {p.worktree.replace(homedir(), "~")} + {p().worktree.replace(homedir(), "~")}
@@ -2742,31 +2852,31 @@ export default function Layout(props: ParentProps) { icon="dot-grid" variant="ghost" data-action="project-menu" - data-project={base64Encode(p.worktree)} + data-project={base64Encode(p().worktree)} class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active" aria-label={language.t("common.moreOptions")} /> - dialog.show(() => )}> + dialog.show(() => )}> {language.t("common.edit")} { - const enabled = layout.sidebar.workspaces(p.worktree)() + const enabled = layout.sidebar.workspaces(p().worktree)() if (enabled) { - layout.sidebar.toggleWorkspaces(p.worktree) + layout.sidebar.toggleWorkspaces(p().worktree) return } - if (p.vcs !== "git") return - layout.sidebar.toggleWorkspaces(p.worktree) + if (p().vcs !== "git") return + layout.sidebar.toggleWorkspaces(p().worktree) }} > - {layout.sidebar.workspaces(p.worktree)() + {layout.sidebar.workspaces(p().worktree)() ? language.t("sidebar.workspaces.disable") : language.t("sidebar.workspaces.enable")} @@ -2774,8 +2884,8 @@ export default function Layout(props: ParentProps) { closeProject(p.worktree)} + data-project={base64Encode(p().worktree)} + onSelect={() => closeProject(p().worktree)} > {language.t("common.close")} @@ -2785,103 +2895,207 @@ export default function Layout(props: ParentProps) {
- +
{ + const target = event.target + if (!(target instanceof Element)) return + if (target.closest("input, textarea, [contenteditable='true']")) return + searchRef?.focus() + }} + > + + { + searchRef = el + }} + class="flex-1 min-w-0 text-14-regular text-text-strong placeholder:text-text-weak" + style={{ "box-shadow": "none" }} + value={search.value} + onInput={(event) => setSearch("value", event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault() + setSearch("value", "") + queueMicrotask(() => searchRef?.focus()) + return + } + + if (!searching()) return + + if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter") { + const ref = listRef + if (!ref) return + event.stopPropagation() + ref.onKeyDown(event) + return + } + + if (event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { + if (event.key === "n" || event.key === "p") { + const ref = listRef + if (!ref) return + event.stopPropagation() + ref.onKeyDown(event) + } + } + }} + placeholder={language.t("session.header.search.placeholder", { project: projectName() })} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + /> + + { + setSearch("value", "") + queueMicrotask(() => searchRef?.focus()) + }} + /> + +
+
+ + + `${item.directory}:${item.id}`} + onSelect={open} + ref={(ref) => { + listRef = ref + }} + > + {(item) => ( +
+ + {item.title} + + + {item.label} + +
+ )} +
+
+ +
+ +
+ + + +
+
+ +
+ + } + > <> -
+
-
-
- +
+ + + +
{ + if (!panelProps.mobile) scrollContainerRef = el + }} + class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" + > + + + {(directory) => ( + + )} + + +
+ + + +
- } - > - <> -
- - - -
-
- - - -
{ - if (!panelProps.mobile) scrollContainerRef = el - }} - class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" - > - - - {(directory) => ( - - )} - - -
- - - -
-
- - + +
)} - 0 && providers.paid().length === 0}> -
-
-
-
{language.t("sidebar.gettingStarted.title")}
-
{language.t("sidebar.gettingStarted.line1")}
-
{language.t("sidebar.gettingStarted.line2")}
-
- + +
0 && providers.paid().length === 0), + }} + > +
+
+
{language.t("sidebar.gettingStarted.title")}
+
{language.t("sidebar.gettingStarted.line1")}
+
{language.t("sidebar.gettingStarted.line2")}
+
- +
) } diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 6c654cbb7d..abd5572207 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -57,6 +57,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) const i18n = useI18n() const [scrollRef, setScrollRef] = createSignal(undefined) const [internalFilter, setInternalFilter] = createSignal("") + let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined const [store, setStore] = createStore({ mouseActive: false, }) @@ -176,6 +177,14 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) if (e.key === "Enter" && !e.isComposing) { e.preventDefault() if (selected) handleSelect(selected, index) + } else if (props.search) { + if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "p")) { + onKeyDown(e) + return + } + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + onKeyDown(e) + } } else { onKeyDown(e) } @@ -247,7 +256,21 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
-
+
{ + const container = event.currentTarget + if (!(container instanceof HTMLElement)) return + + const node = container.querySelector("input, textarea") + const input = node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement ? node : inputRef + input?.focus() + + // Prevent global listeners (e.g. dnd sensors) from cancelling focus. + event.stopPropagation() + }} + >
@@ -257,6 +280,9 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) variant="ghost" data-slot="list-search-input" type="text" + ref={(el: HTMLInputElement | HTMLTextAreaElement) => { + inputRef = el + }} value={internalFilter()} onChange={(value) => applyFilter(value)} onKeyDown={handleKey} @@ -271,7 +297,10 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) applyFilter("")} + onClick={() => { + setInternalFilter("") + queueMicrotask(() => inputRef?.focus()) + }} aria-label={i18n.t("ui.list.clearFilter")} /> From 562c9d76d9becbd485af589ab7ddd64f9c9fd31d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 20:27:15 +0000 Subject: [PATCH 49/64] chore: generate --- packages/sdk/openapi.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 95bca32303..72327a8d72 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -6087,6 +6087,10 @@ }, "deletions": { "type": "number" + }, + "status": { + "type": "string", + "enum": ["added", "deleted", "modified"] } }, "required": ["file", "before", "after", "additions", "deletions"] From 824165eb792edfd8600d44aac83e1f6bba2a9e62 Mon Sep 17 00:00:00 2001 From: Alex Yaroshuk <34632190+alexyaroshuk@users.noreply.github.com> Date: Tue, 3 Feb 2026 04:36:00 +0800 Subject: [PATCH 50/64] feat(app): add workspace toggle command to command palette and prompt input (#11810) --- packages/app/src/i18n/ar.ts | 6 ++++++ packages/app/src/i18n/br.ts | 6 ++++++ packages/app/src/i18n/da.ts | 6 ++++++ packages/app/src/i18n/de.ts | 1 + packages/app/src/i18n/en.ts | 7 +++++++ packages/app/src/i18n/es.ts | 6 ++++++ packages/app/src/i18n/fr.ts | 6 ++++++ packages/app/src/i18n/ja.ts | 1 + packages/app/src/i18n/ko.ts | 6 ++++++ packages/app/src/i18n/no.ts | 6 ++++++ packages/app/src/i18n/pl.ts | 6 ++++++ packages/app/src/i18n/ru.ts | 6 ++++++ packages/app/src/i18n/th.ts | 8 +++++++- packages/app/src/i18n/zh.ts | 8 +++++++- packages/app/src/i18n/zht.ts | 8 +++++++- packages/app/src/pages/layout.tsx | 23 +++++++++++++++++++++++ 16 files changed, 107 insertions(+), 3 deletions(-) diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 3718303e5a..f816c9aca0 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي", "command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا", "command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا", + "command.workspace.toggle": "تبديل مساحات العمل", "command.session.undo": "تراجع", "command.session.undo.description": "تراجع عن الرسالة الأخيرة", "command.session.redo": "إعادة", @@ -348,6 +349,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "توقف قبول التعديلات تلقائيًا", "toast.permissions.autoaccept.off.description": "ستتطلب أذونات التحرير والكتابة موافقة", + "toast.workspace.enabled.title": "تم تمكين مساحات العمل", + "toast.workspace.enabled.description": "الآن يتم عرض عدة worktrees في الشريط الجانبي", + "toast.workspace.disabled.title": "تم تعطيل مساحات العمل", + "toast.workspace.disabled.description": "يتم عرض worktree الرئيسي فقط في الشريط الجانبي", + "toast.model.none.title": "لم يتم تحديد نموذج", "toast.model.none.description": "قم بتوصيل موفر لتلخيص هذه الجلسة", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 43336f8d6f..4bb66e11c9 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Mudar para o próximo nível de esforço", "command.permissions.autoaccept.enable": "Aceitar edições automaticamente", "command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente", + "command.workspace.toggle": "Alternar espaços de trabalho", "command.session.undo": "Desfazer", "command.session.undo.description": "Desfazer a última mensagem", "command.session.redo": "Refazer", @@ -347,6 +348,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Parou de aceitar edições automaticamente", "toast.permissions.autoaccept.off.description": "Permissões de edição e escrita exigirão aprovação", + "toast.workspace.enabled.title": "Espaços de trabalho ativados", + "toast.workspace.enabled.description": "Várias worktrees agora são exibidas na barra lateral", + "toast.workspace.disabled.title": "Espaços de trabalho desativados", + "toast.workspace.disabled.description": "Apenas a worktree principal é exibida na barra lateral", + "toast.model.none.title": "Nenhum modelo selecionado", "toast.model.none.description": "Conecte um provedor para resumir esta sessão", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 69e8e8114f..95d9f4a0fc 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Skift til næste indsatsniveau", "command.permissions.autoaccept.enable": "Accepter ændringer automatisk", "command.permissions.autoaccept.disable": "Stop automatisk accept af ændringer", + "command.workspace.toggle": "Skift arbejdsområder", "command.session.undo": "Fortryd", "command.session.undo.description": "Fortryd den sidste besked", "command.session.redo": "Omgør", @@ -349,6 +350,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Stoppede automatisk accept af ændringer", "toast.permissions.autoaccept.off.description": "Redigerings- og skrivetilladelser vil kræve godkendelse", + "toast.workspace.enabled.title": "Arbejdsområder aktiveret", + "toast.workspace.enabled.description": "Flere worktrees vises nu i sidepanelet", + "toast.workspace.disabled.title": "Arbejdsområder deaktiveret", + "toast.workspace.disabled.description": "Kun hoved-worktree vises i sidepanelet", + "toast.model.none.title": "Ingen model valgt", "toast.model.none.description": "Forbind en udbyder for at opsummere denne session", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 1c28e4a16e..3ead99427d 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -74,6 +74,7 @@ export const dict = { "command.model.variant.cycle.description": "Zum nächsten Aufwandslevel wechseln", "command.permissions.autoaccept.enable": "Änderungen automatisch akzeptieren", "command.permissions.autoaccept.disable": "Automatische Annahme von Änderungen stoppen", + "command.workspace.toggle": "Arbeitsbereiche umschalten", "command.session.undo": "Rückgängig", "command.session.undo.description": "Letzte Nachricht rückgängig machen", "command.session.redo": "Wiederherstellen", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 169d09cd38..780c19e21c 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -71,6 +71,8 @@ export const dict = { "command.model.variant.cycle.description": "Switch to the next effort level", "command.permissions.autoaccept.enable": "Auto-accept edits", "command.permissions.autoaccept.disable": "Stop auto-accepting edits", + "command.workspace.toggle": "Toggle workspaces", + "command.workspace.toggle.description": "Enable or disable multiple workspaces in the sidebar", "command.session.undo": "Undo", "command.session.undo.description": "Undo the last message", "command.session.redo": "Redo", @@ -350,6 +352,11 @@ export const dict = { "toast.theme.title": "Theme switched", "toast.scheme.title": "Color scheme", + "toast.workspace.enabled.title": "Workspaces enabled", + "toast.workspace.enabled.description": "Multiple worktrees are now shown in the sidebar", + "toast.workspace.disabled.title": "Workspaces disabled", + "toast.workspace.disabled.description": "Only the main worktree is shown in the sidebar", + "toast.permissions.autoaccept.on.title": "Auto-accepting edits", "toast.permissions.autoaccept.on.description": "Edit and write permissions will be automatically approved", "toast.permissions.autoaccept.off.title": "Stopped auto-accepting edits", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 6e3eac0dd3..4c5fe30040 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Cambiar al siguiente nivel de esfuerzo", "command.permissions.autoaccept.enable": "Aceptar ediciones automáticamente", "command.permissions.autoaccept.disable": "Dejar de aceptar ediciones automáticamente", + "command.workspace.toggle": "Alternar espacios de trabajo", "command.session.undo": "Deshacer", "command.session.undo.description": "Deshacer el último mensaje", "command.session.redo": "Rehacer", @@ -350,6 +351,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Se dejó de aceptar ediciones automáticamente", "toast.permissions.autoaccept.off.description": "Los permisos de edición y escritura requerirán aprobación", + "toast.workspace.enabled.title": "Espacios de trabajo habilitados", + "toast.workspace.enabled.description": "Ahora se muestran varios worktrees en la barra lateral", + "toast.workspace.disabled.title": "Espacios de trabajo deshabilitados", + "toast.workspace.disabled.description": "Solo se muestra el worktree principal en la barra lateral", + "toast.model.none.title": "Ningún modelo seleccionado", "toast.model.none.description": "Conecta un proveedor para resumir esta sesión", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index fa3dccd9af..41c8b45547 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Passer au niveau d'effort suivant", "command.permissions.autoaccept.enable": "Accepter automatiquement les modifications", "command.permissions.autoaccept.disable": "Arrêter l'acceptation automatique des modifications", + "command.workspace.toggle": "Basculer les espaces de travail", "command.session.undo": "Annuler", "command.session.undo.description": "Annuler le dernier message", "command.session.redo": "Rétablir", @@ -352,6 +353,11 @@ export const dict = { "toast.permissions.autoaccept.off.description": "Les permissions de modification et d'écriture nécessiteront une approbation", + "toast.workspace.enabled.title": "Espaces de travail activés", + "toast.workspace.enabled.description": "Plusieurs worktrees sont désormais affichés dans la barre latérale", + "toast.workspace.disabled.title": "Espaces de travail désactivés", + "toast.workspace.disabled.description": "Seul le worktree principal est affiché dans la barre latérale", + "toast.model.none.title": "Aucun modèle sélectionné", "toast.model.none.description": "Connectez un fournisseur pour résumer cette session", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 4fccbd77e7..d2530f5e51 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "次の思考レベルに切り替え", "command.permissions.autoaccept.enable": "編集を自動承認", "command.permissions.autoaccept.disable": "編集の自動承認を停止", + "command.workspace.toggle": "ワークスペースを切り替え", "command.session.undo": "元に戻す", "command.session.undo.description": "最後のメッセージを元に戻す", "command.session.redo": "やり直す", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 5b5d29c0e0..f81164ce3b 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -74,6 +74,7 @@ export const dict = { "command.model.variant.cycle.description": "다음 생각 수준으로 전환", "command.permissions.autoaccept.enable": "편집 자동 수락", "command.permissions.autoaccept.disable": "편집 자동 수락 중지", + "command.workspace.toggle": "작업 공간 전환", "command.session.undo": "실행 취소", "command.session.undo.description": "마지막 메시지 실행 취소", "command.session.redo": "다시 실행", @@ -351,6 +352,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "편집 자동 수락 중지됨", "toast.permissions.autoaccept.off.description": "편집 및 쓰기 권한 승인이 필요합니다", + "toast.workspace.enabled.title": "작업 공간 활성화됨", + "toast.workspace.enabled.description": "이제 사이드바에 여러 작업 트리가 표시됩니다", + "toast.workspace.disabled.title": "작업 공간 비활성화됨", + "toast.workspace.disabled.description": "사이드바에 메인 작업 트리만 표시됩니다", + "toast.model.none.title": "선택된 모델 없음", "toast.model.none.description": "이 세션을 요약하려면 공급자를 연결하세요", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 89614ce853..d1f2bc7fdc 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -73,6 +73,7 @@ export const dict = { "command.model.variant.cycle.description": "Bytt til neste innsatsnivå", "command.permissions.autoaccept.enable": "Godta endringer automatisk", "command.permissions.autoaccept.disable": "Slutt å godta endringer automatisk", + "command.workspace.toggle": "Veksle arbeidsområder", "command.session.undo": "Angre", "command.session.undo.description": "Angre siste melding", "command.session.redo": "Gjør om", @@ -351,6 +352,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Sluttet å godta endringer automatisk", "toast.permissions.autoaccept.off.description": "Redigerings- og skrivetillatelser vil kreve godkjenning", + "toast.workspace.enabled.title": "Arbeidsområder aktivert", + "toast.workspace.enabled.description": "Flere worktrees vises nå i sidefeltet", + "toast.workspace.disabled.title": "Arbeidsområder deaktivert", + "toast.workspace.disabled.description": "Kun hoved-worktree vises i sidefeltet", + "toast.model.none.title": "Ingen modell valgt", "toast.model.none.description": "Koble til en leverandør for å oppsummere denne sesjonen", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index b89921a9bc..f1211c4599 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Przełącz na następny poziom wysiłku", "command.permissions.autoaccept.enable": "Automatyczne akceptowanie edycji", "command.permissions.autoaccept.disable": "Zatrzymaj automatyczne akceptowanie edycji", + "command.workspace.toggle": "Przełącz przestrzenie robocze", "command.session.undo": "Cofnij", "command.session.undo.description": "Cofnij ostatnią wiadomość", "command.session.redo": "Ponów", @@ -349,6 +350,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Zatrzymano automatyczne akceptowanie edycji", "toast.permissions.autoaccept.off.description": "Uprawnienia do edycji i zapisu będą wymagały zatwierdzenia", + "toast.workspace.enabled.title": "Przestrzenie robocze włączone", + "toast.workspace.enabled.description": "Kilka worktree jest teraz wyświetlanych na pasku bocznym", + "toast.workspace.disabled.title": "Przestrzenie robocze wyłączone", + "toast.workspace.disabled.description": "Tylko główny worktree jest wyświetlany na pasku bocznym", + "toast.model.none.title": "Nie wybrano modelu", "toast.model.none.description": "Połącz dostawcę, aby podsumować tę sesję", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index e99abbd081..e0efffa41b 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Переключиться к следующему уровню усилий", "command.permissions.autoaccept.enable": "Авто-принятие изменений", "command.permissions.autoaccept.disable": "Прекратить авто-принятие изменений", + "command.workspace.toggle": "Переключить рабочие пространства", "command.session.undo": "Отменить", "command.session.undo.description": "Отменить последнее сообщение", "command.session.redo": "Повторить", @@ -350,6 +351,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Авто-принятие остановлено", "toast.permissions.autoaccept.off.description": "Редактирование и запись потребуют подтверждения", + "toast.workspace.enabled.title": "Рабочие пространства включены", + "toast.workspace.enabled.description": "В боковой панели теперь отображаются несколько рабочих деревьев", + "toast.workspace.disabled.title": "Рабочие пространства отключены", + "toast.workspace.disabled.description": "В боковой панели отображается только главное рабочее дерево", + "toast.model.none.title": "Модель не выбрана", "toast.model.none.description": "Подключите провайдера для суммаризации сессии", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 0da6f9acc7..cfe439d510 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "สลับไปยังระดับความพยายามถัดไป", "command.permissions.autoaccept.enable": "ยอมรับการแก้ไขโดยอัตโนมัติ", "command.permissions.autoaccept.disable": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ", + "command.workspace.toggle": "สลับพื้นที่ทำงาน", "command.session.undo": "ยกเลิก", "command.session.undo.description": "ยกเลิกข้อความล่าสุด", "command.session.redo": "ทำซ้ำ", @@ -349,10 +350,15 @@ export const dict = { "toast.scheme.title": "โทนสี", "toast.permissions.autoaccept.on.title": "กำลังยอมรับการแก้ไขโดยอัตโนมัติ", - "toast.permissions.autoaccept.on.description": "สิทธิ์การแก้ไขและเขียนจะได้รับการอนุมัติโดยอัตโนมัติ", + "toast.permissions.autoaccept.on.description": "สิทธิ์การแก้ไขและจะได้รับเขียนการอนุมัติโดยอัตโนมัติ", "toast.permissions.autoaccept.off.title": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ", "toast.permissions.autoaccept.off.description": "สิทธิ์การแก้ไขและเขียนจะต้องได้รับการอนุมัติ", + "toast.workspace.enabled.title": "เปิดใช้งานพื้นที่ทำงานแล้ว", + "toast.workspace.enabled.description": "ตอนนี้จะแสดง worktree หลายรายการในแถบด้านข้าง", + "toast.workspace.disabled.title": "ปิดใช้งานพื้นที่ทำงานแล้ว", + "toast.workspace.disabled.description": "จะแสดงเฉพาะ worktree หลักในแถบด้านข้าง", + "toast.model.none.title": "ไม่ได้เลือกโมเดล", "toast.model.none.description": "เชื่อมต่อผู้ให้บริการเพื่อสรุปเซสชันนี้", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index a7e1659ec3..81bb23db9d 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -74,6 +74,7 @@ export const dict = { "command.model.variant.cycle.description": "切换到下一个强度等级", "command.permissions.autoaccept.enable": "自动接受编辑", "command.permissions.autoaccept.disable": "停止自动接受编辑", + "command.workspace.toggle": "切换工作区", "command.session.undo": "撤销", "command.session.undo.description": "撤销上一条消息", "command.session.redo": "重做", @@ -344,7 +345,12 @@ export const dict = { "toast.language.description": "已切换到{{language}}", "toast.theme.title": "主题已切换", - "toast.scheme.title": "配色方案", + "toast.scheme.title": "颜色方案", + + "toast.workspace.enabled.title": "工作区已启用", + "toast.workspace.enabled.description": "侧边栏现在显示多个工作树", + "toast.workspace.disabled.title": "工作区已禁用", + "toast.workspace.disabled.description": "侧边栏只显示主工作树", "toast.permissions.autoaccept.on.title": "自动接受编辑", "toast.permissions.autoaccept.on.description": "编辑和写入权限将自动获批", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 7b8849b9a0..f01c1ce0b1 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -74,6 +74,7 @@ export const dict = { "command.model.variant.cycle.description": "切換到下一個強度等級", "command.permissions.autoaccept.enable": "自動接受編輯", "command.permissions.autoaccept.disable": "停止自動接受編輯", + "command.workspace.toggle": "切換工作區", "command.session.undo": "復原", "command.session.undo.description": "復原上一則訊息", "command.session.redo": "重做", @@ -341,7 +342,12 @@ export const dict = { "toast.language.description": "已切換到 {{language}}", "toast.theme.title": "主題已切換", - "toast.scheme.title": "配色方案", + "toast.scheme.title": "顏色方案", + + "toast.workspace.enabled.title": "工作區已啟用", + "toast.workspace.enabled.description": "側邊欄現在顯示多個工作樹", + "toast.workspace.disabled.title": "工作區已停用", + "toast.workspace.disabled.description": "側邊欄只顯示主工作樹", "toast.permissions.autoaccept.on.title": "自動接受編輯", "toast.permissions.autoaccept.on.description": "編輯和寫入權限將自動獲准", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index fe8618b739..ba888a2805 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1138,6 +1138,29 @@ export default function Layout(props: ParentProps) { if (session) archiveSession(session) }, }, + { + id: "workspace.toggle", + title: language.t("command.workspace.toggle"), + description: language.t("command.workspace.toggle.description"), + category: language.t("command.category.workspace"), + slash: "workspace", + disabled: !currentProject() || currentProject()?.vcs !== "git", + onSelect: () => { + const project = currentProject() + if (!project) return + if (project.vcs !== "git") return + const wasEnabled = layout.sidebar.workspaces(project.worktree)() + layout.sidebar.toggleWorkspaces(project.worktree) + showToast({ + title: wasEnabled + ? language.t("toast.workspace.disabled.title") + : language.t("toast.workspace.enabled.title"), + description: wasEnabled + ? language.t("toast.workspace.disabled.description") + : language.t("toast.workspace.enabled.description"), + }) + }, + }, { id: "theme.cycle", title: language.t("command.theme.cycle"), From a9fca05d8b8f0e87dc9774f6d660fe65831b6da5 Mon Sep 17 00:00:00 2001 From: Luiz Guilherme D'Abruzzo Pereira <707366+luiz290788@users.noreply.github.com> Date: Mon, 2 Feb 2026 17:52:32 -0300 Subject: [PATCH 51/64] feat(server): add --mdns-domain flag to customize mDNS hostname (#11796) --- packages/opencode/src/cli/cmd/web.ts | 2 +- packages/opencode/src/cli/network.ts | 9 ++++++++- packages/opencode/src/config/config.ts | 1 + packages/opencode/src/server/mdns.ts | 5 +++-- packages/opencode/src/server/server.ts | 10 ++++++++-- 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 5fa2bb4264..0fe056f21f 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -63,7 +63,7 @@ export const WebCommand = cmd({ UI.println( UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, - `opencode.local:${server.port}`, + `${opts.mdnsDomain}:${server.port}`, ) } diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index fe5731d071..dd09e1689f 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -17,6 +17,11 @@ const options = { describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)", default: false, }, + "mdns-domain": { + type: "string" as const, + describe: "custom domain name for mDNS service (default: opencode.local)", + default: "opencode.local", + }, cors: { type: "string" as const, array: true, @@ -36,9 +41,11 @@ export async function resolveNetworkOptions(args: NetworkOptions) { const portExplicitlySet = process.argv.includes("--port") const hostnameExplicitlySet = process.argv.includes("--hostname") const mdnsExplicitlySet = process.argv.includes("--mdns") + const mdnsDomainExplicitlySet = process.argv.includes("--mdns-domain") const corsExplicitlySet = process.argv.includes("--cors") const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns) + const mdnsDomain = mdnsDomainExplicitlySet ? args["mdns-domain"] : (config?.server?.mdnsDomain ?? args["mdns-domain"]) const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port) const hostname = hostnameExplicitlySet ? args.hostname @@ -49,5 +56,5 @@ export async function resolveNetworkOptions(args: NetworkOptions) { const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : [] const cors = [...configCors, ...argsCors] - return { hostname, port, mdns, cors } + return { hostname, port, mdns, mdnsDomain, cors } } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index b0164e8aa8..54ca94ae4d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -860,6 +860,7 @@ export namespace Config { port: z.number().int().positive().optional().describe("Port to listen on"), hostname: z.string().optional().describe("Hostname to listen on"), mdns: z.boolean().optional().describe("Enable mDNS service discovery"), + mdnsDomain: z.string().optional().describe("Custom domain name for mDNS service (default: opencode.local)"), cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"), }) .strict() diff --git a/packages/opencode/src/server/mdns.ts b/packages/opencode/src/server/mdns.ts index 953269de44..778afa26ac 100644 --- a/packages/opencode/src/server/mdns.ts +++ b/packages/opencode/src/server/mdns.ts @@ -7,17 +7,18 @@ export namespace MDNS { let bonjour: Bonjour | undefined let currentPort: number | undefined - export function publish(port: number) { + export function publish(port: number, domain?: string) { if (currentPort === port) return if (bonjour) unpublish() try { + const host = domain ?? "opencode.local" const name = `opencode-${port}` bonjour = new Bonjour() const service = bonjour.publish({ name, type: "http", - host: "opencode.local", + host, port, txt: { path: "/" }, }) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index f6dd0d122f..015553802a 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -563,7 +563,13 @@ export namespace Server { return result } - export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) { + export function listen(opts: { + port: number + hostname: string + mdns?: boolean + mdnsDomain?: string + cors?: string[] + }) { _corsWhitelist = opts.cors ?? [] const args = { @@ -591,7 +597,7 @@ export namespace Server { opts.hostname !== "localhost" && opts.hostname !== "::1" if (shouldPublishMDNS) { - MDNS.publish(server.port!) + MDNS.publish(server.port!, opts.mdnsDomain) } else if (opts.mdns) { log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") } From a3f1918489942eb2d99c1ef4e3b8628d55d0dfc7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 20:53:36 +0000 Subject: [PATCH 52/64] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 4 ++++ packages/sdk/openapi.json | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 085c9d9c7e..0cf70241ef 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1332,6 +1332,10 @@ export type ServerConfig = { * Enable mDNS service discovery */ mdns?: boolean + /** + * Custom domain name for mDNS service (default: opencode.local) + */ + mdnsDomain?: string /** * Additional domains to allow for CORS */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 72327a8d72..d179ed8b8c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8910,6 +8910,10 @@ "description": "Enable mDNS service discovery", "type": "boolean" }, + "mdnsDomain": { + "description": "Custom domain name for mDNS service (default: opencode.local)", + "type": "string" + }, "cors": { "description": "Additional domains to allow for CORS", "type": "array", From aa6b552c39fce24d35097de4feb6d1aa0598b1c5 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:28:02 -0600 Subject: [PATCH 53/64] Revert pr that was mistakenly merged (#11844) --- packages/opencode/src/session/processor.ts | 10 +-- packages/opencode/src/session/prompt.ts | 69 +++++++------------ packages/opencode/src/tool/batch.ts | 8 +-- packages/opencode/src/tool/read.ts | 4 ++ packages/opencode/src/tool/tool.ts | 2 +- packages/opencode/test/session/prompt.test.ts | 62 ----------------- 6 files changed, 33 insertions(+), 122 deletions(-) delete mode 100644 packages/opencode/test/session/prompt.test.ts diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 24b4a4f9fb..b5289e903a 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -172,14 +172,6 @@ export namespace SessionProcessor { case "tool-result": { const match = toolcalls[value.toolCallId] if (match && match.state.status === "running") { - const attachments = value.output.attachments?.map( - (attachment: Omit) => ({ - ...attachment, - id: Identifier.ascending("part"), - messageID: match.messageID, - sessionID: match.sessionID, - }), - ) await Session.updatePart({ ...match, state: { @@ -192,7 +184,7 @@ export namespace SessionProcessor { start: match.state.time.start, end: Date.now(), }, - attachments, + attachments: value.output.attachments, }, }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 98dce97ba9..e0861c4df5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -187,17 +187,13 @@ export namespace SessionPrompt { text: template, }, ] - const matches = ConfigMarkdown.files(template) + const files = ConfigMarkdown.files(template) const seen = new Set() - const names = matches - .map((match) => match[1]) - .filter((name) => { - if (seen.has(name)) return false + await Promise.all( + files.map(async (match) => { + const name = match[1] + if (seen.has(name)) return seen.add(name) - return true - }) - const resolved = await Promise.all( - names.map(async (name) => { const filepath = name.startsWith("~/") ? path.join(os.homedir(), name.slice(2)) : path.resolve(Instance.worktree, name) @@ -205,34 +201,33 @@ export namespace SessionPrompt { const stats = await fs.stat(filepath).catch(() => undefined) if (!stats) { const agent = await Agent.get(name) - if (!agent) return undefined - return { - type: "agent", - name: agent.name, - } satisfies PromptInput["parts"][number] + if (agent) { + parts.push({ + type: "agent", + name: agent.name, + }) + } + return } if (stats.isDirectory()) { - return { + parts.push({ type: "file", url: `file://${filepath}`, filename: name, mime: "application/x-directory", - } satisfies PromptInput["parts"][number] + }) + return } - return { + parts.push({ type: "file", url: `file://${filepath}`, filename: name, mime: "text/plain", - } satisfies PromptInput["parts"][number] + }) }), ) - for (const item of resolved) { - if (!item) continue - parts.push(item) - } return parts } @@ -432,12 +427,6 @@ export namespace SessionPrompt { assistantMessage.time.completed = Date.now() await Session.updateMessage(assistantMessage) if (result && part.state.status === "running") { - const attachments = result.attachments?.map((attachment) => ({ - ...attachment, - id: Identifier.ascending("part"), - messageID: assistantMessage.id, - sessionID: assistantMessage.sessionID, - })) await Session.updatePart({ ...part, state: { @@ -446,7 +435,7 @@ export namespace SessionPrompt { title: result.title, metadata: result.metadata, output: result.output, - attachments, + attachments: result.attachments, time: { ...part.state.time, end: Date.now(), @@ -785,13 +774,16 @@ export namespace SessionPrompt { ) const textParts: string[] = [] - const attachments: Omit[] = [] + const attachments: MessageV2.FilePart[] = [] for (const contentItem of result.content) { if (contentItem.type === "text") { textParts.push(contentItem.text) } else if (contentItem.type === "image") { attachments.push({ + id: Identifier.ascending("part"), + sessionID: input.session.id, + messageID: input.processor.message.id, type: "file", mime: contentItem.mimeType, url: `data:${contentItem.mimeType};base64,${contentItem.data}`, @@ -803,6 +795,9 @@ export namespace SessionPrompt { } if (resource.blob) { attachments.push({ + id: Identifier.ascending("part"), + sessionID: input.session.id, + messageID: input.processor.message.id, type: "file", mime: resource.mimeType ?? "application/octet-stream", url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`, @@ -1051,7 +1046,6 @@ export namespace SessionPrompt { pieces.push( ...result.attachments.map((attachment) => ({ ...attachment, - id: Identifier.ascending("part"), synthetic: true, filename: attachment.filename ?? part.filename, messageID: info.id, @@ -1189,18 +1183,7 @@ export namespace SessionPrompt { }, ] }), - ) - .then((x) => x.flat()) - .then((drafts) => - drafts.map( - (part): MessageV2.Part => ({ - ...part, - id: Identifier.ascending("part"), - messageID: info.id, - sessionID: input.sessionID, - }), - ), - ) + ).then((x) => x.flat()) await Plugin.trigger( "chat.message", diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts index b5c3ad0a12..ba34eb48f5 100644 --- a/packages/opencode/src/tool/batch.ts +++ b/packages/opencode/src/tool/batch.ts @@ -77,12 +77,6 @@ export const BatchTool = Tool.define("batch", async () => { }) const result = await tool.execute(validatedParams, { ...ctx, callID: partID }) - const attachments = result.attachments?.map((attachment) => ({ - ...attachment, - id: Identifier.ascending("part"), - messageID: ctx.messageID, - sessionID: ctx.sessionID, - })) await Session.updatePart({ id: partID, @@ -97,7 +91,7 @@ export const BatchTool = Tool.define("batch", async () => { output: result.output, title: result.title, metadata: result.metadata, - attachments, + attachments: result.attachments, time: { start: callStartTime, end: Date.now(), diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 13236d44dd..f230cdf44c 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -6,6 +6,7 @@ import { LSP } from "../lsp" import { FileTime } from "../file/time" import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" +import { Identifier } from "../id/id" import { assertExternalDirectory } from "./external-directory" import { InstructionPrompt } from "../session/instruction" @@ -78,6 +79,9 @@ export const ReadTool = Tool.define("read", { }, attachments: [ { + id: Identifier.ascending("part"), + sessionID: ctx.sessionID, + messageID: ctx.messageID, type: "file", mime, url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`, diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 0e78ba665c..3d17ea192d 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -36,7 +36,7 @@ export namespace Tool { title: string metadata: M output: string - attachments?: Omit[] + attachments?: MessageV2.FilePart[] }> formatValidationError?(error: z.ZodError): string }> diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts deleted file mode 100644 index e778bfe514..0000000000 --- a/packages/opencode/test/session/prompt.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import path from "path" -import { describe, expect, test } from "bun:test" -import { Session } from "../../src/session" -import { SessionPrompt } from "../../src/session/prompt" -import { MessageV2 } from "../../src/session/message-v2" -import { Instance } from "../../src/project/instance" -import { Log } from "../../src/util/log" -import { tmpdir } from "../fixture/fixture" - -Log.init({ print: false }) - -describe("SessionPrompt ordering", () => { - test("keeps @file order with read output parts", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - await Bun.write(path.join(dir, "a.txt"), "28\n") - await Bun.write(path.join(dir, "b.txt"), "42\n") - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const session = await Session.create({}) - const template = "What numbers are written in files @a.txt and @b.txt ?" - const parts = await SessionPrompt.resolvePromptParts(template) - const fileParts = parts.filter((part) => part.type === "file") - - expect(fileParts.map((part) => part.filename)).toStrictEqual(["a.txt", "b.txt"]) - - const message = await SessionPrompt.prompt({ - sessionID: session.id, - parts, - noReply: true, - }) - const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id }) - const items = stored.parts - const aPath = path.join(tmp.path, "a.txt") - const bPath = path.join(tmp.path, "b.txt") - const sequence = items.flatMap((part) => { - if (part.type === "text") { - if (part.text.includes(aPath)) return ["input:a"] - if (part.text.includes(bPath)) return ["input:b"] - if (part.text.includes("00001| 28")) return ["output:a"] - if (part.text.includes("00001| 42")) return ["output:b"] - return [] - } - if (part.type === "file") { - if (part.filename === "a.txt") return ["file:a"] - if (part.filename === "b.txt") return ["file:b"] - } - return [] - }) - - expect(sequence).toStrictEqual(["input:a", "output:a", "file:a", "input:b", "output:b", "file:b"]) - - await Session.remove(session.id) - }, - }) - }) -}) From 531357b40c22be2ac0ff020962f85d393163e015 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:53:59 -0600 Subject: [PATCH 54/64] fix(app): sidebar losing projects on collapse --- packages/app/src/pages/layout.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index ba888a2805..2f963ae28d 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2420,7 +2420,7 @@ export default function Layout(props: ParentProps) { } const projectName = () => props.project.name || getFilename(props.project.worktree) - const trigger = ( + const Trigger = () => ( { @@ -2499,14 +2499,14 @@ export default function Layout(props: ParentProps) { return ( // @ts-ignore
- + }> } onOpenChange={(value) => { if (menu()) return setOpen(value) From aadd2e13d785dc9c4e78cbb1812d6a0eefc2f4d1 Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Mon, 2 Feb 2026 23:02:56 +0100 Subject: [PATCH 55/64] fix(app): prompt input overflow issue (#11840) --- packages/app/src/components/prompt-input.tsx | 42 +++++++++++++------ .../src/components/session-context-usage.tsx | 2 +- packages/ui/src/components/select.tsx | 4 +- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 5162c0b080..619d4e5d92 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1896,8 +1896,8 @@ export const PromptInput: Component = (props) => {
-
-
+
+
@@ -1909,6 +1909,7 @@ export const PromptInput: Component = (props) => { @@ -1916,7 +1917,8 @@ export const PromptInput: Component = (props) => { options={local.agent.list().map((agent) => agent.name)} current={local.agent.current()?.name ?? ""} onSelect={local.agent.set} - class="capitalize" + class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-[80px]" : "max-w-[120px]"}`} + valueClass="truncate" variant="ghost" /> @@ -1925,36 +1927,51 @@ export const PromptInput: Component = (props) => { fallback={ - } > - + - {local.model.current()?.name ?? language.t("dialog.model.select.title")} - + + {local.model.current()?.name ?? language.t("dialog.model.select.title")} + + 0}> @@ -1971,6 +1988,7 @@ export const PromptInput: Component = (props) => { @@ -2000,7 +2018,7 @@ export const PromptInput: Component = (props) => {
-
+
= (props) => { e.currentTarget.value = "" }} /> -
+