From 06b3455936be221729ed0b01ceaee80d4c20c1b9 Mon Sep 17 00:00:00 2001 From: edoedac0 Date: Sat, 7 Feb 2026 23:54:38 +0100 Subject: [PATCH 01/24] feat(app): add transparent Open in icon toggle --- .../app/src/components/session/session-header.tsx | 13 ++++++++----- packages/app/src/components/settings-general.tsx | 12 ++++++++++++ packages/app/src/context/settings.tsx | 8 ++++++++ packages/app/src/i18n/en.ts | 2 ++ packages/ui/src/assets/icons/app/cursor.svg | 2 +- 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 7eaafc85423b..2920bb22c30f 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -6,6 +6,7 @@ import { useLayout } from "@/context/layout" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" import { useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useGlobalSDK } from "@/context/global-sdk" @@ -31,6 +32,7 @@ export function SessionHeader() { const params = useParams() const command = useCommand() const server = useServer() + const settings = useSettings() const sync = useSync() const platform = usePlatform() const language = useLanguage() @@ -53,6 +55,9 @@ export function SessionHeader() { const showShare = createMemo(() => shareEnabled() && !!currentSession()) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const view = createMemo(() => layout.view(sessionKey)) + const appIconStyle = createMemo(() => + settings.appearance.transparentAppIcons() ? { padding: "0", background: "transparent", border: "none" } : undefined, + ) const OPEN_APPS = [ "vscode", @@ -315,9 +320,7 @@ export function SessionHeader() { aria-label={language.t("session.header.open.copyPath")} > - - {language.t("session.header.open.copyPath")} - + {language.t("session.header.open.copyPath")} } > @@ -328,7 +331,7 @@ export function SessionHeader() { onClick={() => openDir(current().id)} aria-label={language.t("session.header.open.ariaLabel", { app: current().label })} > - + {language.t("session.header.open.action", { app: current().label })} @@ -354,7 +357,7 @@ export function SessionHeader() { > {options().map((o) => ( openDir(o.id)}> - + {o.label} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index b31cfb6cc794..3964727330c4 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -235,6 +235,18 @@ export const SettingsGeneral: Component = () => { )} + + +
+ settings.appearance.setTransparentAppIcons(checked)} + /> +
+
diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 19b3846f84e8..1f0aa3ef1f11 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -26,6 +26,7 @@ export interface Settings { appearance: { fontSize: number font: string + transparentAppIcons: boolean } keybinds: Record permissions: { @@ -46,6 +47,7 @@ const defaultSettings: Settings = { appearance: { fontSize: 14, font: "ibm-plex-mono", + transparentAppIcons: true, }, keybinds: {}, permissions: { @@ -125,6 +127,12 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setFont(value: string) { setStore("appearance", "font", value) }, + transparentAppIcons: createMemo( + () => store.appearance?.transparentAppIcons ?? defaultSettings.appearance.transparentAppIcons, + ), + setTransparentAppIcons(value: boolean) { + setStore("appearance", "transparentAppIcons", value) + }, }, keybinds: { get: (action: string) => store.keybinds?.[action], diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 62dc35eae134..a3d2326b799e 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -596,6 +596,8 @@ export const dict = { "settings.general.row.theme.description": "Customise how OpenCode is themed.", "settings.general.row.font.title": "Font", "settings.general.row.font.description": "Customise the mono font used in code blocks", + "settings.general.row.transparentAppIcons.title": "Transparent app icons", + "settings.general.row.transparentAppIcons.description": "Show app logos without the background frame in Open in menu", "settings.general.row.releaseNotes.title": "Release notes", "settings.general.row.releaseNotes.description": "Show What's New popups after updates", diff --git a/packages/ui/src/assets/icons/app/cursor.svg b/packages/ui/src/assets/icons/app/cursor.svg index c2c8c18199d6..5aa26e8e71fd 100644 --- a/packages/ui/src/assets/icons/app/cursor.svg +++ b/packages/ui/src/assets/icons/app/cursor.svg @@ -1 +1 @@ - \ No newline at end of file + From dcb145c4c779103d3038da31193c74b366f48488 Mon Sep 17 00:00:00 2001 From: edoedac0 Date: Sun, 8 Feb 2026 00:04:51 +0100 Subject: [PATCH 02/24] feat(app): make transparent app icon preference desktop-only --- .../app/src/components/settings-general.tsx | 24 ++++++++++--------- packages/app/src/i18n/en.ts | 3 +++ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 3964727330c4..a7ddd5ec8045 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -236,17 +236,19 @@ export const SettingsGeneral: Component = () => { - -
- settings.appearance.setTransparentAppIcons(checked)} - /> -
-
+ {platform.platform === "desktop" && ( + +
+ settings.appearance.setTransparentAppIcons(checked)} + /> +
+
+ )} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index a3d2326b799e..5502a74a882f 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -500,6 +500,9 @@ export const dict = { "session.messages.loadEarlier": "Load earlier messages", "session.messages.loading": "Loading messages...", "session.messages.jumpToLatest": "Jump to latest", + "session.messages.reverted.single": "1 message reverted", + "session.messages.reverted.multiple": "{{count}} messages reverted", + "session.messages.reverted.restore": "to restore", "session.context.addToContext": "Add {{selection}} to context", From e772fc6e2372cf2b82252d3ce2154696cec25d72 Mon Sep 17 00:00:00 2001 From: Caleb Norton Date: Sat, 7 Feb 2026 19:26:10 -0600 Subject: [PATCH 03/24] fix: revert "feat(app): add web input focus shortcut (#12493)" (#12639) --- bun.lock | 2 +- packages/opencode/script/build.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index fd562bd237f2..fcb2f8f0cf9c 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,6 @@ { "lockfileVersion": 1, - "configVersion": 0, + "configVersion": 1, "workspaces": { "": { "name": "opencode", diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index b55f04d87048..f0b3fa828a78 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import solidPlugin from "../../../node_modules/@opentui/solid/scripts/solid-plugin" +import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin" import path from "path" import fs from "fs" import { $ } from "bun" From ecaeb9e6027c6be2d3f848812dc41c22342e9917 Mon Sep 17 00:00:00 2001 From: Ryan Miville Date: Sat, 7 Feb 2026 20:26:54 -0500 Subject: [PATCH 04/24] fix(app): respect terminal toggle keybind when terminal is focused (#12635) --- packages/app/e2e/settings/settings-keybinds.spec.ts | 11 +++++++---- packages/app/src/components/terminal.tsx | 12 +++++++----- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/app/e2e/settings/settings-keybinds.spec.ts b/packages/app/e2e/settings/settings-keybinds.spec.ts index eceb82b7414c..a8e7f335266a 100644 --- a/packages/app/e2e/settings/settings-keybinds.spec.ts +++ b/packages/app/e2e/settings/settings-keybinds.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "../fixtures" import { openSettings, closeDialog, withSession } from "../actions" -import { keybindButtonSelector } from "../selectors" +import { keybindButtonSelector, terminalSelector } from "../selectors" import { modKey } from "../utils" test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => { @@ -267,11 +267,14 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) => await closeDialog(page, dialog) + const terminal = page.locator(terminalSelector) + await expect(terminal).not.toBeVisible() + await page.keyboard.press(`${modKey}+Y`) - await page.waitForTimeout(100) + await expect(terminal).toBeVisible() - const pageStable = await page.evaluate(() => document.readyState === "complete") - expect(pageStable).toBe(true) + await page.keyboard.press(`${modKey}+Y`) + await expect(terminal).not.toBeVisible() }) test("changing command palette keybind works", async ({ page, gotoSession }) => { diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 64adc797c919..3baafe5111ee 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -3,6 +3,7 @@ import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitPr import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" import { monoFontFamily, useSettings } from "@/context/settings" +import { parseKeybind, matchKeybind } from "@/context/command" import { SerializeAddon } from "@/addons/serialize" import { LocalPTY } from "@/context/terminal" import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme" @@ -10,6 +11,8 @@ import { useLanguage } from "@/context/language" import { showToast } from "@opencode-ai/ui/toast" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" +const TOGGLE_TERMINAL_ID = "terminal.toggle" +const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" export interface TerminalProps extends ComponentProps<"div"> { pty: LocalPTY onSubmit?: () => void @@ -237,12 +240,11 @@ export const Terminal = (props: TerminalProps) => { return true } - // allow for ctrl-` to toggle terminal in parent - if (event.ctrlKey && key === "`") { - return true - } + // allow for toggle terminal keybinds in parent + const config = settings.keybinds.get(TOGGLE_TERMINAL_ID) ?? DEFAULT_TOGGLE_TERMINAL_KEYBIND + const keybinds = parseKeybind(config) - return false + return matchKeybind(keybinds, event) }) const fit = new mod.FitAddon() From 3408c100576deab14e0334acdd6b4c3d67573f0e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 8 Feb 2026 01:34:30 +0000 Subject: [PATCH 05/24] chore: update nix node_modules hashes --- nix/hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/hashes.json b/nix/hashes.json index eb1578dcde28..e4653cc0328a 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -2,7 +2,7 @@ "nodeModules": { "x86_64-linux": "sha256-UBz5qXhO+Xy6XptVdbo9V0wKsvZgItmHkWDm6I5VRCk=", "aarch64-linux": "sha256-G2ezu/ThZR3kYfHnbD0EOcLoAa6hwtICpmo9r+bqibE=", - "aarch64-darwin": "sha256-PhSE23OzNlyfNFP5LffA3AtyN+hsyCeGInmDBBRjr0g=", + "aarch64-darwin": "sha256-pSsE0p3ms4pBJ4ygUDPIBGEtiH9/JPMsbizs9MJQr98=", "x86_64-darwin": "sha256-vWusYJD+7ClDLUFy1wEqRLf9hY8V43iqdqnZ6YWkh1Q=" } } From 85d0ed5989c1f5d4fbcb32c6df481a39f18bcc08 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 7 Feb 2026 23:25:53 -0500 Subject: [PATCH 06/24] wip: zen --- packages/console/app/src/routes/zen/util/handler.ts | 12 ++++++++++-- packages/console/core/src/model.ts | 3 ++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 91fa306af45a..af2a8c3e60b0 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -119,6 +119,9 @@ export async function handler( Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => { headers.set(k, headers.get(v)!) }) + Object.entries(providerInfo.headers ?? {}).forEach(([k, v]) => { + headers.set(k, v) + }) headers.delete("host") headers.delete("content-length") headers.delete("x-opencode-request") @@ -250,13 +253,18 @@ export async function handler( part = part.trim() usageParser.parse(part) - if (providerInfo.format !== opts.format) { + if (providerInfo.bodyModifier) { + for (const [k, v] of Object.entries(providerInfo.bodyModifier)) { + part = part.replace(k, v) + } + c.enqueue(encoder.encode(part + "\n\n")) + } else if (providerInfo.format !== opts.format) { part = streamConverter(part) c.enqueue(encoder.encode(part + "\n\n")) } } - if (providerInfo.format === opts.format) { + if (!providerInfo.bodyModifier && providerInfo.format === opts.format) { c.enqueue(value) } diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index 831b3c5fc9cc..3848d82bc192 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -53,7 +53,8 @@ export namespace ZenData { weight: z.number().optional(), disabled: z.boolean().optional(), storeModel: z.string().optional(), - headerMappings: z.record(z.string(), z.string()).optional(), + headers: z.record(z.string(), z.string()).optional(), + bodyModifier: z.record(z.string(), z.string()).optional(), }), ), }) From 7631060a824a333ba359528dfa4fbf8f33fc6129 Mon Sep 17 00:00:00 2001 From: Caleb Norton Date: Sat, 7 Feb 2026 23:55:56 -0600 Subject: [PATCH 07/24] feat(nix): disable build time models.dev fetching (#12644) --- nix/opencode.nix | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nix/opencode.nix b/nix/opencode.nix index 23d9fbe34e04..b7d6f95947c1 100644 --- a/nix/opencode.nix +++ b/nix/opencode.nix @@ -34,6 +34,7 @@ stdenvNoCC.mkDerivation (finalAttrs: { ''; env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json"; + env.OPENCODE_DISABLE_MODELS_FETCH = true; env.OPENCODE_VERSION = finalAttrs.version; env.OPENCODE_CHANNEL = "local"; @@ -79,7 +80,7 @@ stdenvNoCC.mkDerivation (finalAttrs: { writableTmpDirAsHomeHook ]; doInstallCheck = true; - versionCheckKeepEnvironment = [ "HOME" ]; + versionCheckKeepEnvironment = [ "HOME" "OPENCODE_DISABLE_MODELS_FETCH" ]; versionCheckProgramArg = "--version"; passthru = { From 19b1222cd85060f7b0999b99586e93f84821b94b Mon Sep 17 00:00:00 2001 From: Caleb Norton Date: Sat, 7 Feb 2026 23:56:19 -0600 Subject: [PATCH 08/24] feat(nix): expose overlay for downstream use (#12643) --- flake.nix | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/flake.nix b/flake.nix index ea78b1a43482..40e9d337f58b 100644 --- a/flake.nix +++ b/flake.nix @@ -30,6 +30,26 @@ }; }); + overlays = { + default = + final: _prev: + let + node_modules = final.callPackage ./nix/node_modules.nix { + inherit rev; + }; + opencode = final.callPackage ./nix/opencode.nix { + inherit node_modules; + }; + desktop = final.callPackage ./nix/desktop.nix { + inherit opencode; + }; + in + { + inherit opencode; + opencode-desktop = desktop; + }; + }; + packages = forEachSystem ( pkgs: let From d1ebe0767c264d395c8bc504c0957ccc3af90103 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 8 Feb 2026 05:02:19 -0600 Subject: [PATCH 09/24] chore: refactoring and tests (#12629) --- packages/app/playwright.config.ts | 2 +- packages/app/script/e2e-local.ts | 120 +++++--- .../context/global-sync/event-reducer.test.ts | 283 +++++++++++++++++- .../app/src/context/layout-scroll.test.ts | 60 ++-- packages/app/src/pages/directory-layout.tsx | 5 +- packages/app/src/pages/layout/deep-links.ts | 10 +- packages/app/src/pages/layout/helpers.test.ts | 29 ++ packages/app/src/pages/layout/helpers.ts | 7 +- packages/app/src/pages/session.tsx | 10 +- .../app/src/pages/session/helpers.test.ts | 12 +- packages/app/src/pages/session/helpers.ts | 7 + packages/app/src/utils/persist.test.ts | 102 +++++++ packages/app/src/utils/persist.ts | 48 +-- packages/app/src/utils/server-health.test.ts | 74 ++++- packages/app/src/utils/server-health.ts | 72 ++++- packages/opencode/src/file/ripgrep.ts | 10 +- 16 files changed, 741 insertions(+), 110 deletions(-) create mode 100644 packages/app/src/utils/persist.test.ts diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 10819e69ffef..ea85829e0bcc 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ expect: { timeout: 10_000, }, - fullyParallel: true, + fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1", forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], diff --git a/packages/app/script/e2e-local.ts b/packages/app/script/e2e-local.ts index df2107f76d9b..96b35cfb7f4d 100644 --- a/packages/app/script/e2e-local.ts +++ b/packages/app/script/e2e-local.ts @@ -55,6 +55,7 @@ const extraArgs = (() => { const [serverPort, webPort] = await Promise.all([freePort(), freePort()]) const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-")) +const keepSandbox = process.env.OPENCODE_E2E_KEEP_SANDBOX === "1" const serverEnv = { ...process.env, @@ -83,58 +84,99 @@ const runnerEnv = { PLAYWRIGHT_PORT: String(webPort), } satisfies Record -const seed = Bun.spawn(["bun", "script/seed-e2e.ts"], { - cwd: opencodeDir, - env: serverEnv, - stdout: "inherit", - stderr: "inherit", -}) +let seed: ReturnType | undefined +let runner: ReturnType | undefined +let server: { stop: () => Promise | void } | undefined +let inst: { Instance: { disposeAll: () => Promise | void } } | undefined +let cleaned = false +let internalError = false + +const cleanup = async () => { + if (cleaned) return + cleaned = true + + if (seed && seed.exitCode === null) seed.kill("SIGTERM") + if (runner && runner.exitCode === null) runner.kill("SIGTERM") + + const jobs = [ + inst?.Instance.disposeAll(), + server?.stop(), + keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }), + ].filter(Boolean) + await Promise.allSettled(jobs) +} -const seedExit = await seed.exited -if (seedExit !== 0) { - process.exit(seedExit) +const shutdown = (code: number, reason: string) => { + process.exitCode = code + void cleanup().finally(() => { + console.error(`e2e-local shutdown: ${reason}`) + process.exit(code) + }) } -Object.assign(process.env, serverEnv) -process.env.AGENT = "1" -process.env.OPENCODE = "1" +const reportInternalError = (reason: string, error: unknown) => { + internalError = true + console.error(`e2e-local internal error: ${reason}`) + console.error(error) +} -const log = await import("../../opencode/src/util/log") -const install = await import("../../opencode/src/installation") -await log.Log.init({ - print: true, - dev: install.Installation.isLocal(), - level: "WARN", +process.once("SIGINT", () => shutdown(130, "SIGINT")) +process.once("SIGTERM", () => shutdown(143, "SIGTERM")) +process.once("SIGHUP", () => shutdown(129, "SIGHUP")) +process.once("uncaughtException", (error) => { + reportInternalError("uncaughtException", error) +}) +process.once("unhandledRejection", (error) => { + reportInternalError("unhandledRejection", error) }) -const servermod = await import("../../opencode/src/server/server") -const inst = await import("../../opencode/src/project/instance") -const server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" }) -console.log(`opencode server listening on http://127.0.0.1:${serverPort}`) +let code = 1 -const result = await (async () => { - try { - await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`) +try { + seed = Bun.spawn(["bun", "script/seed-e2e.ts"], { + cwd: opencodeDir, + env: serverEnv, + stdout: "inherit", + stderr: "inherit", + }) - const runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], { + const seedExit = await seed.exited + if (seedExit !== 0) { + code = seedExit + } else { + Object.assign(process.env, serverEnv) + process.env.AGENT = "1" + process.env.OPENCODE = "1" + + const log = await import("../../opencode/src/util/log") + const install = await import("../../opencode/src/installation") + await log.Log.init({ + print: true, + dev: install.Installation.isLocal(), + level: "WARN", + }) + + const servermod = await import("../../opencode/src/server/server") + inst = await import("../../opencode/src/project/instance") + server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" }) + console.log(`opencode server listening on http://127.0.0.1:${serverPort}`) + + await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`) + runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], { cwd: appDir, env: runnerEnv, stdout: "inherit", stderr: "inherit", }) - - return { code: await runner.exited } - } catch (error) { - return { error } - } finally { - await inst.Instance.disposeAll() - await server.stop() + code = await runner.exited } -})() - -if ("error" in result) { - console.error(result.error) - process.exit(1) +} catch (error) { + console.error(error) + code = 1 +} finally { + await cleanup() } -process.exit(result.code) +if (code === 0 && internalError) code = 1 + +process.exit(code) diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index f79b9fc958f5..ad63f3c202eb 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Message, Part, Project, Session } from "@opencode-ai/sdk/v2/client" +import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" import { createStore } from "solid-js/store" import type { State } from "./types" import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer" @@ -34,6 +34,29 @@ const textPart = (id: string, sessionID: string, messageID: string) => text: id, }) as Part +const permissionRequest = (id: string, sessionID: string, title = id) => + ({ + id, + sessionID, + permission: title, + patterns: ["*"], + metadata: {}, + always: [], + }) as PermissionRequest + +const questionRequest = (id: string, sessionID: string, title = id) => + ({ + id, + sessionID, + questions: [ + { + question: title, + header: title, + options: [{ label: title, description: title }], + }, + ], + }) as QuestionRequest + const baseState = (input: Partial = {}) => ({ status: "complete", @@ -164,6 +187,264 @@ describe("applyDirectoryEvent", () => { expect(store.session_status.ses_1).toBeUndefined() }) + test("cleans session caches when deleted and decrements only root totals", () => { + const cases = [ + { info: rootSession({ id: "ses_1" }), expectedTotal: 1 }, + { info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2 }, + ] + + for (const item of cases) { + const message = userMessage("msg_1", item.info.id) + const [store, setStore] = createStore( + baseState({ + session: [ + rootSession({ id: "ses_1" }), + rootSession({ id: "ses_2", parentID: "ses_1" }), + rootSession({ id: "ses_3" }), + ], + sessionTotal: 2, + message: { [item.info.id]: [message] }, + part: { [message.id]: [textPart("prt_1", item.info.id, message.id)] }, + session_diff: { [item.info.id]: [] }, + todo: { [item.info.id]: [] }, + permission: { [item.info.id]: [] }, + question: { [item.info.id]: [] }, + session_status: { [item.info.id]: { type: "busy" } }, + }), + ) + + applyDirectoryEvent({ + event: { type: "session.deleted", properties: { info: item.info } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.session.find((x) => x.id === item.info.id)).toBeUndefined() + expect(store.sessionTotal).toBe(item.expectedTotal) + expect(store.message[item.info.id]).toBeUndefined() + expect(store.part[message.id]).toBeUndefined() + expect(store.session_diff[item.info.id]).toBeUndefined() + expect(store.todo[item.info.id]).toBeUndefined() + expect(store.permission[item.info.id]).toBeUndefined() + expect(store.question[item.info.id]).toBeUndefined() + expect(store.session_status[item.info.id]).toBeUndefined() + } + }) + + test("upserts and removes messages while clearing orphaned parts", () => { + const sessionID = "ses_1" + const [store, setStore] = createStore( + baseState({ + message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_3", sessionID)] }, + part: { msg_2: [textPart("prt_1", sessionID, "msg_2")] }, + }), + ) + + applyDirectoryEvent({ + event: { type: "message.updated", properties: { info: userMessage("msg_2", sessionID) } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2", "msg_3"]) + + applyDirectoryEvent({ + event: { + type: "message.updated", + properties: { + info: { + ...userMessage("msg_2", sessionID), + role: "assistant", + } as Message, + }, + }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.message[sessionID]?.find((x) => x.id === "msg_2")?.role).toBe("assistant") + + applyDirectoryEvent({ + event: { type: "message.removed", properties: { sessionID, messageID: "msg_2" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_3"]) + expect(store.part.msg_2).toBeUndefined() + }) + + test("upserts and prunes message parts", () => { + const sessionID = "ses_1" + const messageID = "msg_1" + const [store, setStore] = createStore( + baseState({ + part: { [messageID]: [textPart("prt_1", sessionID, messageID), textPart("prt_3", sessionID, messageID)] }, + }), + ) + + applyDirectoryEvent({ + event: { type: "message.part.updated", properties: { part: textPart("prt_2", sessionID, messageID) } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.part[messageID]?.map((x) => x.id)).toEqual(["prt_1", "prt_2", "prt_3"]) + + applyDirectoryEvent({ + event: { + type: "message.part.updated", + properties: { + part: { + ...textPart("prt_2", sessionID, messageID), + text: "changed", + } as Part, + }, + }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + const updated = store.part[messageID]?.find((x) => x.id === "prt_2") + expect(updated?.type).toBe("text") + if (updated?.type === "text") expect(updated.text).toBe("changed") + + applyDirectoryEvent({ + event: { type: "message.part.removed", properties: { messageID, partID: "prt_1" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + applyDirectoryEvent({ + event: { type: "message.part.removed", properties: { messageID, partID: "prt_2" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + applyDirectoryEvent({ + event: { type: "message.part.removed", properties: { messageID, partID: "prt_3" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.part[messageID]).toBeUndefined() + }) + + test("tracks permission and question request lifecycles", () => { + const sessionID = "ses_1" + const [store, setStore] = createStore( + baseState({ + permission: { [sessionID]: [permissionRequest("perm_1", sessionID), permissionRequest("perm_3", sessionID)] }, + question: { [sessionID]: [questionRequest("q_1", sessionID), questionRequest("q_3", sessionID)] }, + }), + ) + + applyDirectoryEvent({ + event: { type: "permission.asked", properties: permissionRequest("perm_2", sessionID) }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_2", "perm_3"]) + + applyDirectoryEvent({ + event: { type: "permission.asked", properties: permissionRequest("perm_2", sessionID, "updated") }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.permission).toBe("updated") + + applyDirectoryEvent({ + event: { type: "permission.replied", properties: { sessionID, requestID: "perm_2" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"]) + + applyDirectoryEvent({ + event: { type: "question.asked", properties: questionRequest("q_2", sessionID) }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_2", "q_3"]) + + applyDirectoryEvent({ + event: { type: "question.asked", properties: questionRequest("q_2", sessionID, "updated") }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.questions[0]?.header).toBe("updated") + + applyDirectoryEvent({ + event: { type: "question.rejected", properties: { sessionID, requestID: "q_2" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_3"]) + }) + + test("updates vcs branch in store and cache", () => { + const [store, setStore] = createStore(baseState()) + const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] }) + + applyDirectoryEvent({ + event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + vcsCache: { + store: cacheStore, + setStore: setCacheStore, + ready: () => true, + }, + }) + + expect(store.vcs).toEqual({ branch: "feature/test" }) + expect(cacheStore.value).toEqual({ branch: "feature/test" }) + }) + test("routes disposal and lsp events to side-effect handlers", () => { const [store, setStore] = createStore(baseState()) const pushes: string[] = [] diff --git a/packages/app/src/context/layout-scroll.test.ts b/packages/app/src/context/layout-scroll.test.ts index c421a58b67e6..2a13e40204f3 100644 --- a/packages/app/src/context/layout-scroll.test.ts +++ b/packages/app/src/context/layout-scroll.test.ts @@ -1,36 +1,44 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect, test, vi } from "bun:test" import { createScrollPersistence } from "./layout-scroll" describe("createScrollPersistence", () => { - test("debounces persisted scroll writes", async () => { - const snapshot = { - session: { - review: { x: 0, y: 0 }, - }, - } as Record> - const writes: Array> = [] - const scroll = createScrollPersistence({ - debounceMs: 10, - getSnapshot: (sessionKey) => snapshot[sessionKey], - onFlush: (sessionKey, next) => { - snapshot[sessionKey] = next - writes.push(next) - }, - }) + test("debounces persisted scroll writes", () => { + vi.useFakeTimers() + try { + const snapshot = { + session: { + review: { x: 0, y: 0 }, + }, + } as Record> + const writes: Array> = [] + const scroll = createScrollPersistence({ + debounceMs: 10, + getSnapshot: (sessionKey) => snapshot[sessionKey], + onFlush: (sessionKey, next) => { + snapshot[sessionKey] = next + writes.push(next) + }, + }) - for (const i of Array.from({ length: 30 }, (_, n) => n + 1)) { - scroll.setScroll("session", "review", { x: 0, y: i }) - } + for (const i of Array.from({ length: 30 }, (_, n) => n + 1)) { + scroll.setScroll("session", "review", { x: 0, y: i }) + } + + vi.advanceTimersByTime(9) + expect(writes).toHaveLength(0) - await new Promise((resolve) => setTimeout(resolve, 40)) + vi.advanceTimersByTime(1) - expect(writes).toHaveLength(1) - expect(writes[0]?.review).toEqual({ x: 0, y: 30 }) + expect(writes).toHaveLength(1) + expect(writes[0]?.review).toEqual({ x: 0, y: 30 }) - scroll.setScroll("session", "review", { x: 0, y: 30 }) - await new Promise((resolve) => setTimeout(resolve, 20)) + scroll.setScroll("session", "review", { x: 0, y: 30 }) + vi.advanceTimersByTime(20) - expect(writes).toHaveLength(1) - scroll.dispose() + expect(writes).toHaveLength(1) + scroll.dispose() + } finally { + vi.useRealTimers() + } }) }) diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 2f4db8564998..b2a17b96b90c 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -15,6 +15,7 @@ export default function Layout(props: ParentProps) { const params = useParams() const navigate = useNavigate() const language = useLanguage() + let invalid = "" const directory = createMemo(() => { return decode64(params.dir) ?? "" }) @@ -22,12 +23,14 @@ export default function Layout(props: ParentProps) { createEffect(() => { if (!params.dir) return if (directory()) return + if (invalid === params.dir) return + invalid = params.dir showToast({ variant: "error", title: language.t("common.requestFailed"), description: language.t("directory.error.invalidUrl"), }) - navigate("/") + navigate("/", { replace: true }) }) return ( diff --git a/packages/app/src/pages/layout/deep-links.ts b/packages/app/src/pages/layout/deep-links.ts index 772e6ece6b93..7bdb002a366e 100644 --- a/packages/app/src/pages/layout/deep-links.ts +++ b/packages/app/src/pages/layout/deep-links.ts @@ -2,7 +2,15 @@ export const deepLinkEvent = "opencode:deep-link" export const parseDeepLink = (input: string) => { if (!input.startsWith("opencode://")) return - const url = new URL(input) + if (typeof URL.canParse === "function" && !URL.canParse(input)) return + const url = (() => { + try { + return new URL(input) + } catch { + return undefined + } + })() + if (!url) return if (url.hostname !== "open-project") return const directory = url.searchParams.get("directory") if (!directory) return diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 8a8ea78c7793..83d8f4748aba 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -12,6 +12,27 @@ describe("layout deep links", () => { expect(parseDeepLink("https://example.com")).toBeUndefined() }) + test("ignores malformed deep links safely", () => { + expect(() => parseDeepLink("opencode://open-project/%E0%A4%A%")).not.toThrow() + expect(parseDeepLink("opencode://open-project/%E0%A4%A%")).toBeUndefined() + }) + + test("parses links when URL.canParse is unavailable", () => { + const original = Object.getOwnPropertyDescriptor(URL, "canParse") + Object.defineProperty(URL, "canParse", { configurable: true, value: undefined }) + try { + expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toBe("/tmp/demo") + } finally { + if (original) Object.defineProperty(URL, "canParse", original) + if (!original) Reflect.deleteProperty(URL, "canParse") + } + }) + + test("ignores open-project deep links without directory", () => { + expect(parseDeepLink("opencode://open-project")).toBeUndefined() + expect(parseDeepLink("opencode://open-project?directory=")).toBeUndefined() + }) + test("collects only valid open-project directories", () => { const result = collectOpenProjectDeepLinks([ "opencode://open-project?directory=/a", @@ -39,6 +60,14 @@ describe("layout workspace helpers", () => { expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo") }) + test("preserves posix and drive roots in workspace key", () => { + expect(workspaceKey("/")).toBe("/") + expect(workspaceKey("///")).toBe("/") + expect(workspaceKey("C:\\")).toBe("C:\\") + expect(workspaceKey("C:\\\\\\")).toBe("C:\\") + expect(workspaceKey("C:///")).toBe("C:/") + }) + test("keeps local first while preserving known order", () => { const result = syncWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"]) expect(result).toEqual(["/root", "/c", "/b"]) diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 4d144f34ec5c..6ecccb95cf8a 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,7 +1,12 @@ import { getFilename } from "@opencode-ai/util/path" import { type Session } from "@opencode-ai/sdk/v2/client" -export const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "") +export const workspaceKey = (directory: string) => { + const drive = directory.match(/^([A-Za-z]:)[\\/]+$/) + if (drive) return `${drive[1]}${directory.includes("\\") ? "\\" : "/"}` + if (/^[\\/]+$/.test(directory)) return directory.includes("\\") ? "\\" : "/" + return directory.replace(/[\\/]+$/, "") +} export function sortSessions(now: number) { const oneMinuteAgo = now - 60 * 1000 diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 98f8bdbcdd0e..7678ea6a8d14 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -40,7 +40,7 @@ import { showToast } from "@opencode-ai/ui/toast" import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session" import { navMark, navParams } from "@/utils/perf" import { same } from "@/utils/same" -import { createOpenReviewFile, focusTerminalById } from "@/pages/session/helpers" +import { createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "@/pages/session/helpers" import { createScrollSpy } from "@/pages/session/scroll-spy" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" @@ -844,11 +844,9 @@ export default function Page() { const { draggable, droppable } = event if (draggable && droppable) { const currentTabs = tabs().all() - const fromIndex = currentTabs?.indexOf(draggable.id.toString()) - const toIndex = currentTabs?.indexOf(droppable.id.toString()) - if (fromIndex !== toIndex && toIndex !== undefined) { - tabs().move(draggable.id.toString(), toIndex) - } + const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString()) + if (toIndex === undefined) return + tabs().move(draggable.id.toString(), toIndex) } } diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index 0afc7eb6a594..d877d5b2e221 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { combineCommandSections, createOpenReviewFile, focusTerminalById } from "./helpers" +import { combineCommandSections, createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "./helpers" describe("createOpenReviewFile", () => { test("opens and loads selected review file", () => { @@ -59,3 +59,13 @@ describe("combineCommandSections", () => { expect(result.map((item) => item.id)).toEqual(["a", "b", "c"]) }) }) + +describe("getTabReorderIndex", () => { + test("returns target index for valid drag reorder", () => { + expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2) + }) + + test("returns undefined for unknown droppable id", () => { + expect(getTabReorderIndex(["a", "b", "c"], "a", "missing")).toBeUndefined() + }) +}) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index d9ce90793f2b..dcf2c8784998 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -36,3 +36,10 @@ export const createOpenReviewFile = (input: { export const combineCommandSections = (sections: readonly (readonly CommandOption[])[]) => { return sections.flatMap((section) => section) } + +export const getTabReorderIndex = (tabs: readonly string[], from: string, to: string) => { + const fromIndex = tabs.indexOf(from) + const toIndex = tabs.indexOf(to) + if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return undefined + return toIndex +} diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts new file mode 100644 index 000000000000..5be68f3b0eef --- /dev/null +++ b/packages/app/src/utils/persist.test.ts @@ -0,0 +1,102 @@ +import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" + +type PersistTestingType = typeof import("./persist").PersistTesting + +class MemoryStorage implements Storage { + private values = new Map() + readonly events: string[] = [] + readonly calls = { get: 0, set: 0, remove: 0 } + + clear() { + this.values.clear() + } + + get length() { + return this.values.size + } + + key(index: number) { + return Array.from(this.values.keys())[index] ?? null + } + + getItem(key: string) { + this.calls.get += 1 + this.events.push(`get:${key}`) + if (key.startsWith("opencode.throw")) throw new Error("storage get failed") + return this.values.get(key) ?? null + } + + setItem(key: string, value: string) { + this.calls.set += 1 + this.events.push(`set:${key}`) + if (key.startsWith("opencode.quota")) throw new DOMException("quota", "QuotaExceededError") + if (key.startsWith("opencode.throw")) throw new Error("storage set failed") + this.values.set(key, value) + } + + removeItem(key: string) { + this.calls.remove += 1 + this.events.push(`remove:${key}`) + if (key.startsWith("opencode.throw")) throw new Error("storage remove failed") + this.values.delete(key) + } +} + +const storage = new MemoryStorage() + +let persistTesting: PersistTestingType + +beforeAll(async () => { + mock.module("@/context/platform", () => ({ + usePlatform: () => ({ platform: "web" }), + })) + + const mod = await import("./persist") + persistTesting = mod.PersistTesting +}) + +beforeEach(() => { + storage.clear() + storage.events.length = 0 + storage.calls.get = 0 + storage.calls.set = 0 + storage.calls.remove = 0 + Object.defineProperty(globalThis, "localStorage", { + value: storage, + configurable: true, + }) +}) + +describe("persist localStorage resilience", () => { + test("does not cache values as persisted when quota write and eviction fail", () => { + const storageApi = persistTesting.localStorageWithPrefix("opencode.quota.scope") + storageApi.setItem("value", '{"value":1}') + + expect(storage.getItem("opencode.quota.scope:value")).toBeNull() + expect(storageApi.getItem("value")).toBeNull() + }) + + test("disables only the failing scope when storage throws", () => { + const bad = persistTesting.localStorageWithPrefix("opencode.throw.scope") + bad.setItem("value", '{"value":1}') + + const before = storage.calls.set + bad.setItem("value", '{"value":2}') + expect(storage.calls.set).toBe(before) + expect(bad.getItem("value")).toBeNull() + + const healthy = persistTesting.localStorageWithPrefix("opencode.safe.scope") + healthy.setItem("value", '{"value":3}') + expect(storage.getItem("opencode.safe.scope:value")).toBe('{"value":3}') + }) + + test("failing fallback scope does not poison direct storage scope", () => { + const broken = persistTesting.localStorageWithPrefix("opencode.throw.scope2") + broken.setItem("value", '{"value":1}') + + const direct = persistTesting.localStorageDirect() + direct.setItem("direct-value", '{"value":5}') + + expect(storage.getItem("direct-value")).toBe('{"value":5}') + }) +}) diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 0ca3abad0691..329a406dd83e 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -17,7 +17,7 @@ type PersistTarget = { const LEGACY_STORAGE = "default.dat" const GLOBAL_STORAGE = "opencode.global.dat" const LOCAL_PREFIX = "opencode." -const fallback = { disabled: false } +const fallback = new Map() const CACHE_MAX_ENTRIES = 500 const CACHE_MAX_BYTES = 8 * 1024 * 1024 @@ -65,6 +65,14 @@ function cacheGet(key: string) { return entry.value } +function fallbackDisabled(scope: string) { + return fallback.get(scope) === true +} + +function fallbackSet(scope: string) { + fallback.set(scope, true) +} + function quota(error: unknown) { if (error instanceof DOMException) { if (error.name === "QuotaExceededError") return true @@ -142,7 +150,6 @@ function write(storage: Storage, key: string, value: string) { } const ok = evict(storage, key, value) - if (!ok) cacheSet(key, value) return ok } @@ -196,18 +203,19 @@ function workspaceStorage(dir: string) { function localStorageWithPrefix(prefix: string): SyncStorage { const base = `${prefix}:` + const scope = `prefix:${prefix}` const item = (key: string) => base + key return { getItem: (key) => { const name = item(key) const cached = cacheGet(name) - if (fallback.disabled && cached !== undefined) return cached + if (fallbackDisabled(scope)) return cached ?? null const stored = (() => { try { return localStorage.getItem(name) } catch { - fallback.disabled = true + fallbackSet(scope) return null } })() @@ -217,40 +225,40 @@ function localStorageWithPrefix(prefix: string): SyncStorage { }, setItem: (key, value) => { const name = item(key) - cacheSet(name, value) - if (fallback.disabled) return + if (fallbackDisabled(scope)) return try { if (write(localStorage, name, value)) return } catch { - fallback.disabled = true + fallbackSet(scope) return } - fallback.disabled = true + fallbackSet(scope) }, removeItem: (key) => { const name = item(key) cacheDelete(name) - if (fallback.disabled) return + if (fallbackDisabled(scope)) return try { localStorage.removeItem(name) } catch { - fallback.disabled = true + fallbackSet(scope) } }, } } function localStorageDirect(): SyncStorage { + const scope = "direct" return { getItem: (key) => { const cached = cacheGet(key) - if (fallback.disabled && cached !== undefined) return cached + if (fallbackDisabled(scope)) return cached ?? null const stored = (() => { try { return localStorage.getItem(key) } catch { - fallback.disabled = true + fallbackSet(scope) return null } })() @@ -259,28 +267,32 @@ function localStorageDirect(): SyncStorage { return stored }, setItem: (key, value) => { - cacheSet(key, value) - if (fallback.disabled) return + if (fallbackDisabled(scope)) return try { if (write(localStorage, key, value)) return } catch { - fallback.disabled = true + fallbackSet(scope) return } - fallback.disabled = true + fallbackSet(scope) }, removeItem: (key) => { cacheDelete(key) - if (fallback.disabled) return + if (fallbackDisabled(scope)) return try { localStorage.removeItem(key) } catch { - fallback.disabled = true + fallbackSet(scope) } }, } } +export const PersistTesting = { + localStorageDirect, + localStorageWithPrefix, +} + export const Persist = { global(key: string, legacy?: string[]): PersistTarget { return { storage: GLOBAL_STORAGE, key, legacy } diff --git a/packages/app/src/utils/server-health.test.ts b/packages/app/src/utils/server-health.test.ts index 34c86685ae3a..26bda070a67a 100644 --- a/packages/app/src/utils/server-health.test.ts +++ b/packages/app/src/utils/server-health.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "bun:test" import { checkServerHealth } from "./server-health" +function abortFromInput(input: RequestInfo | URL, init?: RequestInit) { + if (init?.signal) return init.signal + if (input instanceof Request) return input.signal + return undefined +} + describe("checkServerHealth", () => { test("returns healthy response with version", async () => { const fetch = (async () => @@ -24,10 +30,40 @@ describe("checkServerHealth", () => { expect(result).toEqual({ healthy: false }) }) + test("uses timeout fallback when AbortSignal.timeout is unavailable", async () => { + const timeout = Object.getOwnPropertyDescriptor(AbortSignal, "timeout") + Object.defineProperty(AbortSignal, "timeout", { + configurable: true, + value: undefined, + }) + + let aborted = false + const fetch = ((input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = abortFromInput(input, init) + signal?.addEventListener( + "abort", + () => { + aborted = true + reject(new DOMException("Aborted", "AbortError")) + }, + { once: true }, + ) + })) as unknown as typeof globalThis.fetch + + const result = await checkServerHealth("http://localhost:4096", fetch, { timeoutMs: 10 }).finally(() => { + if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout) + if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout") + }) + + expect(aborted).toBe(true) + expect(result).toEqual({ healthy: false }) + }) + test("uses provided abort signal", async () => { let signal: AbortSignal | undefined const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - signal = init?.signal ?? (input instanceof Request ? input.signal : undefined) + signal = abortFromInput(input, init) return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { status: 200, headers: { "content-type": "application/json" }, @@ -39,4 +75,40 @@ describe("checkServerHealth", () => { expect(signal).toBe(abort.signal) }) + + test("retries transient failures and eventually succeeds", async () => { + let count = 0 + const fetch = (async () => { + count += 1 + if (count < 3) throw new TypeError("network") + return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) as unknown as typeof globalThis.fetch + + const result = await checkServerHealth("http://localhost:4096", fetch, { + retryCount: 2, + retryDelayMs: 1, + }) + + expect(count).toBe(3) + expect(result).toEqual({ healthy: true, version: "1.2.3" }) + }) + + test("returns unhealthy when retries are exhausted", async () => { + let count = 0 + const fetch = (async () => { + count += 1 + throw new TypeError("network") + }) as unknown as typeof globalThis.fetch + + const result = await checkServerHealth("http://localhost:4096", fetch, { + retryCount: 2, + retryDelayMs: 1, + }) + + expect(count).toBe(3) + expect(result).toEqual({ healthy: false }) + }) }) diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index ab33460b2b5e..929826d0dea4 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -5,10 +5,50 @@ export type ServerHealth = { healthy: boolean; version?: string } interface CheckServerHealthOptions { timeoutMs?: number signal?: AbortSignal + retryCount?: number + retryDelayMs?: number } +const defaultTimeoutMs = 3000 +const defaultRetryCount = 2 +const defaultRetryDelayMs = 100 + function timeoutSignal(timeoutMs: number) { - return (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(timeoutMs) + const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout + if (timeout) { + try { + return { signal: timeout.call(AbortSignal, timeoutMs), clear: undefined as (() => void) | undefined } + } catch {} + } + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + return { signal: controller.signal, clear: () => clearTimeout(timer) } +} + +function wait(ms: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("Aborted", "AbortError")) + return + } + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort) + resolve() + }, ms) + const onAbort = () => { + clearTimeout(timer) + reject(new DOMException("Aborted", "AbortError")) + } + signal?.addEventListener("abort", onAbort, { once: true }) + }) +} + +function retryable(error: unknown, signal?: AbortSignal) { + if (signal?.aborted) return false + if (!(error instanceof Error)) return false + if (error.name === "AbortError" || error.name === "TimeoutError") return false + if (error instanceof TypeError) return true + return /network|fetch|econnreset|econnrefused|enotfound|timedout/i.test(error.message) } export async function checkServerHealth( @@ -16,14 +56,24 @@ export async function checkServerHealth( fetch: typeof globalThis.fetch, opts?: CheckServerHealthOptions, ): Promise { - const signal = opts?.signal ?? timeoutSignal(opts?.timeoutMs ?? 3000) - const sdk = createOpencodeClient({ - baseUrl: url, - fetch, - signal, - }) - return sdk.global - .health() - .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version })) - .catch(() => ({ healthy: false })) + const timeout = opts?.signal ? undefined : timeoutSignal(opts?.timeoutMs ?? defaultTimeoutMs) + const signal = opts?.signal ?? timeout?.signal + const retryCount = opts?.retryCount ?? defaultRetryCount + const retryDelayMs = opts?.retryDelayMs ?? defaultRetryDelayMs + const next = (count: number, error: unknown) => { + if (count >= retryCount || !retryable(error, signal)) return Promise.resolve({ healthy: false } as const) + return wait(retryDelayMs * (count + 1), signal) + .then(() => attempt(count + 1)) + .catch(() => ({ healthy: false })) + } + const attempt = (count: number): Promise => + createOpencodeClient({ + baseUrl: url, + fetch, + signal, + }) + .global.health() + .then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version })) + .catch((error) => next(count, error)) + return attempt(0).finally(() => timeout?.clear?.()) } diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index c1e5113bf89c..58f9af7cdbab 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -123,9 +123,13 @@ export namespace Ripgrep { ) const state = lazy(async () => { - let filepath = Bun.which("rg") - if (filepath) return { filepath } - filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : "")) + const system = Bun.which("rg") + if (system) { + const stat = await fs.stat(system).catch(() => undefined) + if (stat?.isFile()) return { filepath: system } + log.warn("bun.which returned invalid rg path", { filepath: system }) + } + const filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : "")) const file = Bun.file(filepath) if (!(await file.exists())) { From d5036cf01f673a2d0069e0daf811f396a9eeb582 Mon Sep 17 00:00:00 2001 From: Abdul Rahman ArM <39548998+invarrow@users.noreply.github.com> Date: Sun, 8 Feb 2026 16:54:09 +0530 Subject: [PATCH 10/24] fix(desktop): add native clipboard image paste and fix text paste (#12682) --- bun.lock | 3 +++ packages/app/src/components/prompt-input.tsx | 3 +++ .../components/prompt-input/attachments.ts | 11 +++++++++ packages/app/src/context/platform.tsx | 3 +++ packages/desktop/package.json | 1 + .../src-tauri/capabilities/default.json | 3 ++- packages/desktop/src/index.tsx | 24 +++++++++++++++++++ 7 files changed, 47 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index fcb2f8f0cf9c..8d2fe82d78f9 100644 --- a/bun.lock +++ b/bun.lock @@ -190,6 +190,7 @@ "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-clipboard-manager": "~2", "@tauri-apps/plugin-deep-link": "~2", "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-http": "~2", @@ -1784,6 +1785,8 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="], + "@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="], + "@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.6", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-UUOSt0U5juK20uhO2MoHZX/IPblkrhUh+VPtIeu3RwtzI0R9Em3Auzfg/PwcZ9Pv8mLne3cQ4p9CFXD6WxqCZA=="], "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.6.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg=="], diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 0b303cf55129..7f21a36dea37 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -35,6 +35,7 @@ import { Persist, persisted } from "@/utils/persist" import { SessionContextUsage } from "@/components/session-context-usage" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history" @@ -97,6 +98,7 @@ export const PromptInput: Component = (props) => { const command = useCommand() const permission = usePermission() const language = useLanguage() + const platform = usePlatform() let editorRef!: HTMLDivElement let fileInputRef!: HTMLInputElement let scrollRef!: HTMLDivElement @@ -766,6 +768,7 @@ export const PromptInput: Component = (props) => { setCursorPosition(editorRef, promptLength(prompt.current())) }, addPart, + readClipboardImage: platform.readClipboardImage, }) const { abort, handleSubmit } = createPromptSubmit({ diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index 48eda3742326..b384bf7d84e4 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -14,6 +14,7 @@ type PromptAttachmentsInput = { setDraggingType: (type: "image" | "@mention" | null) => void focusEditor: () => void addPart: (part: ContentPart) => void + readClipboardImage?: () => Promise } export function createPromptAttachments(input: PromptAttachmentsInput) { @@ -76,6 +77,16 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { } const plainText = clipboardData.getData("text/plain") ?? "" + + // Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images + if (input.readClipboardImage && !plainText) { + const file = await input.readClipboardImage() + if (file) { + await addImageAttachment(file) + return + } + } + if (!plainText) return input.addPart({ type: "text", content: plainText, start: 0, end: 0 }) } diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 127b9260b3b0..3fca502badb8 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -65,6 +65,9 @@ export type Platform = { /** Check if an editor app exists (desktop only) */ checkAppExists?(appName: string): Promise + + /** Read image from clipboard (desktop only) */ + readClipboardImage?(): Promise } export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({ diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 36bbe7bc2c12..8f09ed169ff5 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -18,6 +18,7 @@ "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-clipboard-manager": "~2", "@tauri-apps/plugin-deep-link": "~2", "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-opener": "^2", diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json index 2d38d49a985e..4d0276c832ea 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/packages/desktop/src-tauri/capabilities/default.json @@ -46,6 +46,7 @@ { "identifier": "http:default", "allow": [{ "url": "http://*" }, { "url": "https://*" }, { "url": "http://*:*/*" }] - } + }, + "clipboard-manager:allow-read-image" ] } diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index d3377e95aa3e..dd78224e345c 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -16,6 +16,7 @@ import { fetch as tauriFetch } from "@tauri-apps/plugin-http" import { Store } from "@tauri-apps/plugin-store" import { Splash } from "@opencode-ai/ui/logo" import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js" +import { readImage } from "@tauri-apps/plugin-clipboard-manager" import { UPDATER_ENABLED } from "./updater" import { initI18n, t } from "./i18n" @@ -344,6 +345,29 @@ const createPlatform = (password: Accessor): Platform => ({ checkAppExists: async (appName: string) => { return commands.checkAppExists(appName) }, + + async readClipboardImage() { + const image = await readImage().catch(() => null) + if (!image) return null + const bytes = await image.rgba().catch(() => null) + if (!bytes || bytes.length === 0) return null + const size = await image.size().catch(() => null) + if (!size) return null + const canvas = document.createElement("canvas") + canvas.width = size.width + canvas.height = size.height + const ctx = canvas.getContext("2d") + if (!ctx) return null + const imageData = ctx.createImageData(size.width, size.height) + imageData.data.set(bytes) + ctx.putImageData(imageData, 0, 0) + return new Promise((resolve) => { + canvas.toBlob((blob) => { + if (!blob) return resolve(null) + resolve(new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" })) + }, "image/png") + }) + }, }) let menuTrigger = null as null | ((id: string) => void) From c639200edea00927cdc91ee17ab03a59ecb01368 Mon Sep 17 00:00:00 2001 From: Devin Griffin <31415269+DNGriffin@users.noreply.github.com> Date: Sun, 8 Feb 2026 05:26:31 -0600 Subject: [PATCH 11/24] fix(app): Toast when session is missing on prompt-submit (#12654) --- packages/app/src/components/prompt-input/submit.ts | 9 ++++++++- packages/app/src/i18n/ar.ts | 1 + packages/app/src/i18n/br.ts | 1 + packages/app/src/i18n/bs.ts | 1 + packages/app/src/i18n/da.ts | 1 + packages/app/src/i18n/de.ts | 1 + packages/app/src/i18n/en.ts | 1 + packages/app/src/i18n/es.ts | 1 + packages/app/src/i18n/fr.ts | 1 + packages/app/src/i18n/ja.ts | 1 + packages/app/src/i18n/ko.ts | 1 + packages/app/src/i18n/no.ts | 1 + packages/app/src/i18n/pl.ts | 1 + packages/app/src/i18n/ru.ts | 1 + packages/app/src/i18n/th.ts | 1 + packages/app/src/i18n/zh.ts | 1 + packages/app/src/i18n/zht.ts | 1 + 17 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 5ed5eedadae3..2750e6c86b4b 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -200,7 +200,14 @@ export function createPromptSubmit(input: PromptSubmitInput) { navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) } } - if (!session) return + if (!session) { + showToast({ + title: language.t("prompt.toast.promptSendFailed.title"), + description: language.t("prompt.toast.promptSendFailed.description"), + }) + return + } + input.onSubmit?.() diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 8b3ad72239b3..3778adcd6799 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -234,6 +234,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "فشل إرسال أمر shell", "prompt.toast.commandSendFailed.title": "فشل إرسال الأمر", "prompt.toast.promptSendFailed.title": "فشل إرسال الموجه", + "prompt.toast.promptSendFailed.description": "تعذر استرداد الجلسة", "dialog.mcp.title": "MCPs", "dialog.mcp.description": "{{enabled}} من {{total}} مفعل", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 654443bc7402..74bfd8707c88 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -234,6 +234,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "Falha ao enviar comando shell", "prompt.toast.commandSendFailed.title": "Falha ao enviar comando", "prompt.toast.promptSendFailed.title": "Falha ao enviar prompt", + "prompt.toast.promptSendFailed.description": "Não foi possível recuperar a sessão", "dialog.mcp.title": "MCPs", "dialog.mcp.description": "{{enabled}} de {{total}} habilitados", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 475842911deb..05eca1628e5e 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -242,6 +242,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "Neuspješno slanje shell naredbe", "prompt.toast.commandSendFailed.title": "Neuspješno slanje komande", "prompt.toast.promptSendFailed.title": "Neuspješno slanje upita", + "prompt.toast.promptSendFailed.description": "Nije moguće dohvatiti sesiju", "dialog.mcp.title": "MCP-ovi", "dialog.mcp.description": "{{enabled}} od {{total}} omogućeno", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index e80b4d5d360f..7242fb5849f2 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -234,6 +234,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "Kunne ikke sende shell-kommando", "prompt.toast.commandSendFailed.title": "Kunne ikke sende kommando", "prompt.toast.promptSendFailed.title": "Kunne ikke sende forespørgsel", + "prompt.toast.promptSendFailed.description": "Kunne ikke hente session", "dialog.mcp.title": "MCP'er", "dialog.mcp.description": "{{enabled}} af {{total}} aktiveret", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index a62b9cb99bcd..bd8acae5e8fc 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -277,6 +277,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "Shell-Befehl konnte nicht gesendet werden", "prompt.toast.commandSendFailed.title": "Befehl konnte nicht gesendet werden", "prompt.toast.promptSendFailed.title": "Eingabe konnte nicht gesendet werden", + "prompt.toast.promptSendFailed.description": "Sitzung konnte nicht abgerufen werden", "dialog.mcp.title": "MCPs", "dialog.mcp.description": "{{enabled}} von {{total}} aktiviert", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 62dc35eae134..8fba6861b0b3 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -279,6 +279,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "Failed to send shell command", "prompt.toast.commandSendFailed.title": "Failed to send command", "prompt.toast.promptSendFailed.title": "Failed to send prompt", + "prompt.toast.promptSendFailed.description": "Unable to retrieve session", "dialog.mcp.title": "MCPs", "dialog.mcp.description": "{{enabled}} of {{total}} enabled", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 8c48bd9d062f..f9b11ade8705 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -234,6 +234,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "Fallo al enviar comando de shell", "prompt.toast.commandSendFailed.title": "Fallo al enviar comando", "prompt.toast.promptSendFailed.title": "Fallo al enviar prompt", + "prompt.toast.promptSendFailed.description": "No se pudo recuperar la sesión", "dialog.mcp.title": "MCPs", "dialog.mcp.description": "{{enabled}} de {{total}} habilitados", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 5f9c2f4988cc..0cc81e5ea7a6 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -234,6 +234,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "Échec de l'envoi de la commande shell", "prompt.toast.commandSendFailed.title": "Échec de l'envoi de la commande", "prompt.toast.promptSendFailed.title": "Échec de l'envoi du message", + "prompt.toast.promptSendFailed.description": "Impossible de récupérer la session", "dialog.mcp.title": "MCPs", "dialog.mcp.description": "{{enabled}} sur {{total}} activés", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 776968e1aa10..337e1b0d349f 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -233,6 +233,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "シェルコマンドの送信に失敗しました", "prompt.toast.commandSendFailed.title": "コマンドの送信に失敗しました", "prompt.toast.promptSendFailed.title": "プロンプトの送信に失敗しました", + "prompt.toast.promptSendFailed.description": "セッションを取得できませんでした", "dialog.mcp.title": "MCP", "dialog.mcp.description": "{{total}}個中{{enabled}}個が有効", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 4194dfdfe1f0..283bb6f3bdc6 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -237,6 +237,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "셸 명령 전송 실패", "prompt.toast.commandSendFailed.title": "명령 전송 실패", "prompt.toast.promptSendFailed.title": "프롬프트 전송 실패", + "prompt.toast.promptSendFailed.description": "세션을 가져올 수 없습니다", "dialog.mcp.title": "MCP", "dialog.mcp.description": "{{total}}개 중 {{enabled}}개 활성화됨", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index a7826fce29d6..bbffd0083d14 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -237,6 +237,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "Kunne ikke sende shell-kommando", "prompt.toast.commandSendFailed.title": "Kunne ikke sende kommando", "prompt.toast.promptSendFailed.title": "Kunne ikke sende forespørsel", + "prompt.toast.promptSendFailed.description": "Kunne ikke hente økt", "dialog.mcp.title": "MCP-er", "dialog.mcp.description": "{{enabled}} av {{total}} aktivert", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 96032149544f..2d36ca8c1809 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -234,6 +234,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "Nie udało się wysłać polecenia powłoki", "prompt.toast.commandSendFailed.title": "Nie udało się wysłać polecenia", "prompt.toast.promptSendFailed.title": "Nie udało się wysłać zapytania", + "prompt.toast.promptSendFailed.description": "Nie udało się pobrać sesji", "dialog.mcp.title": "MCP", "dialog.mcp.description": "{{enabled}} z {{total}} włączone", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index ce66b1781621..18b0ba5f47d5 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -234,6 +234,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "Не удалось отправить команду оболочки", "prompt.toast.commandSendFailed.title": "Не удалось отправить команду", "prompt.toast.promptSendFailed.title": "Не удалось отправить запрос", + "prompt.toast.promptSendFailed.description": "Не удалось получить сессию", "dialog.mcp.title": "MCP", "dialog.mcp.description": "{{enabled}} из {{total}} включено", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 816d844c0592..d48a7cea665b 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -239,6 +239,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "ไม่สามารถส่งคำสั่งเชลล์", "prompt.toast.commandSendFailed.title": "ไม่สามารถส่งคำสั่ง", "prompt.toast.promptSendFailed.title": "ไม่สามารถส่งพร้อมท์", + "prompt.toast.promptSendFailed.description": "ไม่สามารถดึงเซสชันได้", "dialog.mcp.title": "MCPs", "dialog.mcp.description": "{{enabled}} จาก {{total}} ที่เปิดใช้งาน", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index fbce17837eec..070064d1c416 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -275,6 +275,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "发送 shell 命令失败", "prompt.toast.commandSendFailed.title": "发送命令失败", "prompt.toast.promptSendFailed.title": "发送提示失败", + "prompt.toast.promptSendFailed.description": "无法获取会话", "dialog.mcp.title": "MCPs", "dialog.mcp.description": "已启用 {{enabled}} / {{total}}", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index bb0821f88832..39dcd92e2767 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -272,6 +272,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "傳送 shell 命令失敗", "prompt.toast.commandSendFailed.title": "傳送命令失敗", "prompt.toast.promptSendFailed.title": "傳送提示失敗", + "prompt.toast.promptSendFailed.description": "無法取得工作階段", "dialog.mcp.title": "MCP", "dialog.mcp.description": "已啟用 {{enabled}} / {{total}}", From bc25efdf72e1e7a5543566daf0f9743c3d269e88 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 8 Feb 2026 06:26:59 -0500 Subject: [PATCH 12/24] refine(app): tighten slash autocomplete matching (#12647) --- packages/app/src/components/prompt-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 7f21a36dea37..da45c351ec76 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -415,7 +415,7 @@ export const PromptInput: Component = (props) => { } = useFilteredList({ items: slashCommands, key: (x) => x?.id, - filterKeys: ["trigger", "title", "description"], + filterKeys: ["trigger", "title"], onSelect: handleSlashSelect, }) From d5c86b03baccae5175d71374c5283fa2592cd8aa Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 8 Feb 2026 11:27:37 +0000 Subject: [PATCH 13/24] chore: generate --- packages/app/src/components/prompt-input/submit.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 2750e6c86b4b..a96bdcbad5b2 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -200,14 +200,13 @@ export function createPromptSubmit(input: PromptSubmitInput) { navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) } } - if (!session) { + if (!session) { showToast({ title: language.t("prompt.toast.promptSendFailed.title"), description: language.t("prompt.toast.promptSendFailed.description"), }) return } - input.onSubmit?.() From 4187a5fe7fadcf52515ef491d61224ae9a01968b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 8 Feb 2026 11:33:28 +0000 Subject: [PATCH 14/24] 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 e4653cc0328a..ddd1253ed1a5 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-UBz5qXhO+Xy6XptVdbo9V0wKsvZgItmHkWDm6I5VRCk=", - "aarch64-linux": "sha256-G2ezu/ThZR3kYfHnbD0EOcLoAa6hwtICpmo9r+bqibE=", - "aarch64-darwin": "sha256-pSsE0p3ms4pBJ4ygUDPIBGEtiH9/JPMsbizs9MJQr98=", - "x86_64-darwin": "sha256-vWusYJD+7ClDLUFy1wEqRLf9hY8V43iqdqnZ6YWkh1Q=" + "x86_64-linux": "sha256-1IpZnnN6+acCcV0AgO4OVdvgf4TFBFId5dms5W5ecA0=", + "aarch64-linux": "sha256-TKmPhXokOav46ucP9AFwHGgKmB9CdGCcUtwqUtLlzG4=", + "aarch64-darwin": "sha256-xJQuw3+QHYnlClDrafQKPQyR+aqyAEofvYYjCowHDps=", + "x86_64-darwin": "sha256-ywU3Oka2QNGKu/HI+//3bdYJ9qo1N7K5Wr2vpTgSM/g=" } } From 7c6b8d7a8a7180f330b83b736c46a62fb5f91258 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 8 Feb 2026 06:36:53 -0600 Subject: [PATCH 15/24] fix(ui): context stale in prompt input (#12695) --- .../session/session-context-metrics.test.ts | 9 +++++---- .../components/session/session-context-metrics.ts | 14 +------------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts index 68903a455b2d..e90df9a9486f 100644 --- a/packages/app/src/components/session/session-context-metrics.test.ts +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -79,15 +79,16 @@ describe("getSessionContextMetrics", () => { expect(metrics.context?.usage).toBeNull() }) - test("memoizes by message and provider array identity", () => { + test("recomputes when message array is mutated in place", () => { const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)] const providers = [{ id: "openai", models: {} }] const one = getSessionContextMetrics(messages, providers) + messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75)) const two = getSessionContextMetrics(messages, providers) - const three = getSessionContextMetrics([...messages], providers) - expect(two).toBe(one) - expect(three).not.toBe(one) + expect(one.context?.message.id).toBe("a1") + expect(two.context?.message.id).toBe("a2") + expect(two.totalCost).toBe(1) }) }) diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts index 2b6edbd951dd..357205afb592 100644 --- a/packages/app/src/components/session/session-context-metrics.ts +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -34,8 +34,6 @@ type Metrics = { context: Context | undefined } -const cache = new WeakMap>() - const tokenTotal = (msg: AssistantMessage) => { return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write } @@ -80,15 +78,5 @@ const build = (messages: Message[], providers: Provider[]): Metrics => { } export function getSessionContextMetrics(messages: Message[], providers: Provider[]) { - const byProvider = cache.get(messages) - if (byProvider) { - const hit = byProvider.get(providers) - if (hit) return hit - } - - const value = build(messages, providers) - const next = byProvider ?? new WeakMap() - next.set(providers, value) - if (!byProvider) cache.set(messages, next) - return value + return build(messages, providers) } From ca0839db7da3d1b091db21e49a83ebc3788fdf0c Mon Sep 17 00:00:00 2001 From: edoedac0 Date: Sun, 8 Feb 2026 15:23:02 +0100 Subject: [PATCH 16/24] feat(app): make open-in icons always transparent --- .../src/components/session/session-header.tsx | 12 ++++-------- .../app/src/components/settings-general.tsx | 13 ------------- packages/app/src/context/settings.tsx | 8 -------- packages/app/src/entry.tsx | 18 ++++++++++++++++++ packages/app/src/i18n/en.ts | 2 -- 5 files changed, 22 insertions(+), 31 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 2920bb22c30f..ff1024622990 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -6,7 +6,6 @@ import { useLayout } from "@/context/layout" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" -import { useSettings } from "@/context/settings" import { useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useGlobalSDK } from "@/context/global-sdk" @@ -32,7 +31,6 @@ export function SessionHeader() { const params = useParams() const command = useCommand() const server = useServer() - const settings = useSettings() const sync = useSync() const platform = usePlatform() const language = useLanguage() @@ -55,9 +53,7 @@ export function SessionHeader() { const showShare = createMemo(() => shareEnabled() && !!currentSession()) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const view = createMemo(() => layout.view(sessionKey)) - const appIconStyle = createMemo(() => - settings.appearance.transparentAppIcons() ? { padding: "0", background: "transparent", border: "none" } : undefined, - ) + const appIconStyle = { padding: "0", background: "transparent", border: "none" } const OPEN_APPS = [ "vscode", @@ -158,7 +154,7 @@ export function SessionHeader() { const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) - const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) + const canOpen = createMemo(() => !!platform.openPath && server.isLocal()) const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0]) createEffect(() => { @@ -331,7 +327,7 @@ export function SessionHeader() { onClick={() => openDir(current().id)} aria-label={language.t("session.header.open.ariaLabel", { app: current().label })} > - + {language.t("session.header.open.action", { app: current().label })} @@ -357,7 +353,7 @@ export function SessionHeader() { > {options().map((o) => ( openDir(o.id)}> - + {o.label} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index a7ddd5ec8045..94fb17dfb508 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -236,19 +236,6 @@ export const SettingsGeneral: Component = () => { - {platform.platform === "desktop" && ( - -
- settings.appearance.setTransparentAppIcons(checked)} - /> -
-
- )} diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 1f0aa3ef1f11..19b3846f84e8 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -26,7 +26,6 @@ export interface Settings { appearance: { fontSize: number font: string - transparentAppIcons: boolean } keybinds: Record permissions: { @@ -47,7 +46,6 @@ const defaultSettings: Settings = { appearance: { fontSize: 14, font: "ibm-plex-mono", - transparentAppIcons: true, }, keybinds: {}, permissions: { @@ -127,12 +125,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setFont(value: string) { setStore("appearance", "font", value) }, - transparentAppIcons: createMemo( - () => store.appearance?.transparentAppIcons ?? defaultSettings.appearance.transparentAppIcons, - ), - setTransparentAppIcons(value: boolean) { - setStore("appearance", "transparentAppIcons", value) - }, }, keybinds: { get: (action: string) => store.keybinds?.[action], diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index aa52fa1e7cb5..10353d380261 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -31,6 +31,24 @@ const platform: Platform = { openLink(url: string) { window.open(url, "_blank") }, + openPath(path: string, app?: string) { + const value = encodeURI(path.replaceAll("\\", "/")) + const key = app?.toLowerCase() + if (key === "code" || key === "visual studio code") { + window.open(`vscode://file/${value}`, "_blank") + return Promise.resolve() + } + if (key === "cursor") { + window.open(`cursor://file/${value}`, "_blank") + return Promise.resolve() + } + if (key === "zed") { + window.open(`zed://file/${value}`, "_blank") + return Promise.resolve() + } + window.open(`file://${value}`, "_blank") + return Promise.resolve() + }, back() { window.history.back() }, diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 5502a74a882f..0ae2710ea6a5 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -599,8 +599,6 @@ export const dict = { "settings.general.row.theme.description": "Customise how OpenCode is themed.", "settings.general.row.font.title": "Font", "settings.general.row.font.description": "Customise the mono font used in code blocks", - "settings.general.row.transparentAppIcons.title": "Transparent app icons", - "settings.general.row.transparentAppIcons.description": "Show app logos without the background frame in Open in menu", "settings.general.row.releaseNotes.title": "Release notes", "settings.general.row.releaseNotes.description": "Show What's New popups after updates", From e4850091afeffac5da6188c69ad688b149e593fc Mon Sep 17 00:00:00 2001 From: edoedac0 Date: Sun, 8 Feb 2026 15:33:25 +0100 Subject: [PATCH 17/24] Revert "feat(app): make open-in icons always transparent" This reverts commit ca0839db7da3d1b091db21e49a83ebc3788fdf0c. --- .../src/components/session/session-header.tsx | 12 ++++++++---- .../app/src/components/settings-general.tsx | 13 +++++++++++++ packages/app/src/context/settings.tsx | 8 ++++++++ packages/app/src/entry.tsx | 18 ------------------ packages/app/src/i18n/en.ts | 2 ++ 5 files changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index ff1024622990..2920bb22c30f 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -6,6 +6,7 @@ import { useLayout } from "@/context/layout" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" import { useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useGlobalSDK } from "@/context/global-sdk" @@ -31,6 +32,7 @@ export function SessionHeader() { const params = useParams() const command = useCommand() const server = useServer() + const settings = useSettings() const sync = useSync() const platform = usePlatform() const language = useLanguage() @@ -53,7 +55,9 @@ export function SessionHeader() { const showShare = createMemo(() => shareEnabled() && !!currentSession()) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const view = createMemo(() => layout.view(sessionKey)) - const appIconStyle = { padding: "0", background: "transparent", border: "none" } + const appIconStyle = createMemo(() => + settings.appearance.transparentAppIcons() ? { padding: "0", background: "transparent", border: "none" } : undefined, + ) const OPEN_APPS = [ "vscode", @@ -154,7 +158,7 @@ export function SessionHeader() { const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) - const canOpen = createMemo(() => !!platform.openPath && server.isLocal()) + const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0]) createEffect(() => { @@ -327,7 +331,7 @@ export function SessionHeader() { onClick={() => openDir(current().id)} aria-label={language.t("session.header.open.ariaLabel", { app: current().label })} > - + {language.t("session.header.open.action", { app: current().label })} @@ -353,7 +357,7 @@ export function SessionHeader() { > {options().map((o) => ( openDir(o.id)}> - + {o.label} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 94fb17dfb508..a7ddd5ec8045 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -236,6 +236,19 @@ export const SettingsGeneral: Component = () => { + {platform.platform === "desktop" && ( + +
+ settings.appearance.setTransparentAppIcons(checked)} + /> +
+
+ )} diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 19b3846f84e8..1f0aa3ef1f11 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -26,6 +26,7 @@ export interface Settings { appearance: { fontSize: number font: string + transparentAppIcons: boolean } keybinds: Record permissions: { @@ -46,6 +47,7 @@ const defaultSettings: Settings = { appearance: { fontSize: 14, font: "ibm-plex-mono", + transparentAppIcons: true, }, keybinds: {}, permissions: { @@ -125,6 +127,12 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setFont(value: string) { setStore("appearance", "font", value) }, + transparentAppIcons: createMemo( + () => store.appearance?.transparentAppIcons ?? defaultSettings.appearance.transparentAppIcons, + ), + setTransparentAppIcons(value: boolean) { + setStore("appearance", "transparentAppIcons", value) + }, }, keybinds: { get: (action: string) => store.keybinds?.[action], diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 10353d380261..aa52fa1e7cb5 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -31,24 +31,6 @@ const platform: Platform = { openLink(url: string) { window.open(url, "_blank") }, - openPath(path: string, app?: string) { - const value = encodeURI(path.replaceAll("\\", "/")) - const key = app?.toLowerCase() - if (key === "code" || key === "visual studio code") { - window.open(`vscode://file/${value}`, "_blank") - return Promise.resolve() - } - if (key === "cursor") { - window.open(`cursor://file/${value}`, "_blank") - return Promise.resolve() - } - if (key === "zed") { - window.open(`zed://file/${value}`, "_blank") - return Promise.resolve() - } - window.open(`file://${value}`, "_blank") - return Promise.resolve() - }, back() { window.history.back() }, diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 0ae2710ea6a5..5502a74a882f 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -599,6 +599,8 @@ export const dict = { "settings.general.row.theme.description": "Customise how OpenCode is themed.", "settings.general.row.font.title": "Font", "settings.general.row.font.description": "Customise the mono font used in code blocks", + "settings.general.row.transparentAppIcons.title": "Transparent app icons", + "settings.general.row.transparentAppIcons.description": "Show app logos without the background frame in Open in menu", "settings.general.row.releaseNotes.title": "Release notes", "settings.general.row.releaseNotes.description": "Show What's New popups after updates", From cfd873fec801fb4afa599a559f4ba28761c70580 Mon Sep 17 00:00:00 2001 From: edoedac0 Date: Sun, 8 Feb 2026 16:17:02 +0100 Subject: [PATCH 18/24] feat(app): add Tahoe Finder icon support --- .../src/components/session/session-header.tsx | 22 ++++++++++++++++++- packages/app/src/context/platform.tsx | 3 +++ packages/desktop/src/index.tsx | 3 ++- packages/ui/src/assets/icons/app/zed.svg | 16 +++++++++++++- packages/ui/src/components/app-icon.css | 4 ++++ packages/ui/src/components/app-icon.tsx | 2 ++ packages/ui/src/components/app-icons/types.ts | 1 + 7 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 2920bb22c30f..b092d290f543 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -115,6 +115,26 @@ export function SessionHeader() { return "unknown" }) + const finder = createMemo(() => { + if (os() !== "macos") return "finder" + if (platform.platform === "desktop" && platform.os === "macos" && platform.osVersion) { + const parts = platform.osVersion.split(/[._]/).map((part) => Number(part)) + const major = parts[0] + if (!Number.isFinite(major)) return "finder" + const value = major === 10 ? parts[1] ?? 0 : major + if (value >= 26) return "finder-tahoe" + return "finder" + } + if (typeof navigator !== "object") return "finder" + const value = navigator.userAgent.match(/Mac OS X (\d+)(?:[._](\d+))?/i) + if (!value) return "finder" + const major = Number(value[1]) + if (!Number.isFinite(major)) return "finder" + const version = major === 10 ? Number(value[2] ?? 0) : major + if (version >= 26) return "finder-tahoe" + return "finder" + }) + const [exists, setExists] = createStore>>({ finder: true }) createEffect(() => { @@ -140,7 +160,7 @@ export function SessionHeader() { const options = createMemo(() => { if (os() === "macos") { - return [{ id: "finder", label: "Finder", icon: "finder" }, ...MAC_APPS.filter((app) => exists[app.id])] as const + return [{ id: "finder", label: "Finder", icon: finder() }, ...MAC_APPS.filter((app) => exists[app.id])] as const } if (os() === "windows") { diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 127b9260b3b0..65c71f9a2d7c 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -9,6 +9,9 @@ export type Platform = { /** Desktop OS (Tauri only) */ os?: "macos" | "windows" | "linux" + /** Desktop OS version (Tauri only) */ + osVersion?: string + /** App version */ version?: string diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index d3377e95aa3e..80a944d3bd9a 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -6,7 +6,7 @@ import { open, save } from "@tauri-apps/plugin-dialog" import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link" import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener" import { open as shellOpen } from "@tauri-apps/plugin-shell" -import { type as ostype } from "@tauri-apps/plugin-os" +import { type as ostype, version as osversion } from "@tauri-apps/plugin-os" import { check, Update } from "@tauri-apps/plugin-updater" import { getCurrentWindow } from "@tauri-apps/api/window" import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification" @@ -57,6 +57,7 @@ const createPlatform = (password: Accessor): Platform => ({ if (type === "macos" || type === "windows" || type === "linux") return type return undefined })(), + osVersion: osversion(), version: pkg.version, async openDirectoryPickerDialog(opts) { diff --git a/packages/ui/src/assets/icons/app/zed.svg b/packages/ui/src/assets/icons/app/zed.svg index 7c9a0e59149f..a845bf18157d 100644 --- a/packages/ui/src/assets/icons/app/zed.svg +++ b/packages/ui/src/assets/icons/app/zed.svg @@ -1 +1,15 @@ - \ No newline at end of file + + + + + + + + + + diff --git a/packages/ui/src/components/app-icon.css b/packages/ui/src/components/app-icon.css index edcdbcceb5ff..8581408ce8f5 100644 --- a/packages/ui/src/components/app-icon.css +++ b/packages/ui/src/components/app-icon.css @@ -7,3 +7,7 @@ img[data-component="app-icon"] { border: 1px solid var(--smoke-light-alpha-4); object-fit: contain; } + +html[data-color-scheme="dark"] img[data-component="app-icon"][data-app-icon-id="zed"] { + filter: invert(1); +} diff --git a/packages/ui/src/components/app-icon.tsx b/packages/ui/src/components/app-icon.tsx index e3f2a0fb2399..1a7cde315f00 100644 --- a/packages/ui/src/components/app-icon.tsx +++ b/packages/ui/src/components/app-icon.tsx @@ -23,6 +23,7 @@ const icons = { zed, "file-explorer": fileExplorer, finder, + "finder-tahoe": "https://upload.wikimedia.org/wikipedia/commons/b/b9/Finder_Icon_macOS_Tahoe.png", terminal, iterm2, ghostty, @@ -43,6 +44,7 @@ export const AppIcon: Component = (props) => { return ( {local.alt Date: Sun, 8 Feb 2026 16:30:52 +0100 Subject: [PATCH 19/24] feat(app): make Open in action a button group --- .../src/components/session/session-header.tsx | 103 +++++++++--------- 1 file changed, 53 insertions(+), 50 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index b092d290f543..961cc68fdfb6 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -345,57 +345,60 @@ export function SessionHeader() { } >
- - - + +
+ + + + + + {language.t("session.header.openIn")} + { + if (!OPEN_APPS.includes(value as OpenApp)) return + setPrefs("app", value as OpenApp) + }} + > + {options().map((o) => ( + openDir(o.id)}> + + {o.label} + + + + + ))} + + + + + + + {language.t("session.header.open.copyPath")} + + + + + +
From 80c1c59ed34cd19119bbb53f40e5214cae35ad29 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 8 Feb 2026 10:31:07 -0500 Subject: [PATCH 20/24] wip: zen --- infra/console.ts | 10 +++++ .../console/core/script/promote-models.ts | 2 +- packages/console/core/script/pull-models.ts | 2 +- packages/console/core/script/update-models.ts | 2 +- packages/console/core/src/model.ts | 12 +++++- packages/console/core/sst-env.d.ts | 40 +++++++++++++++++++ packages/console/function/sst-env.d.ts | 40 +++++++++++++++++++ packages/console/resource/sst-env.d.ts | 40 +++++++++++++++++++ packages/enterprise/sst-env.d.ts | 40 +++++++++++++++++++ packages/function/sst-env.d.ts | 40 +++++++++++++++++++ sst-env.d.ts | 40 +++++++++++++++++++ 11 files changed, 264 insertions(+), 4 deletions(-) diff --git a/infra/console.ts b/infra/console.ts index ba1ff15bf2d1..5abffb555a82 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -135,6 +135,16 @@ const ZEN_MODELS = [ new sst.Secret("ZEN_MODELS8"), new sst.Secret("ZEN_MODELS9"), new sst.Secret("ZEN_MODELS10"), + new sst.Secret("ZEN_MODELS11"), + new sst.Secret("ZEN_MODELS12"), + new sst.Secret("ZEN_MODELS13"), + new sst.Secret("ZEN_MODELS14"), + new sst.Secret("ZEN_MODELS15"), + new sst.Secret("ZEN_MODELS16"), + new sst.Secret("ZEN_MODELS17"), + new sst.Secret("ZEN_MODELS18"), + new sst.Secret("ZEN_MODELS19"), + new sst.Secret("ZEN_MODELS20"), ] const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY") const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY") diff --git a/packages/console/core/script/promote-models.ts b/packages/console/core/script/promote-models.ts index 62d9bd6ff903..17715bedfb8a 100755 --- a/packages/console/core/script/promote-models.ts +++ b/packages/console/core/script/promote-models.ts @@ -9,7 +9,7 @@ const stage = process.argv[2] if (!stage) throw new Error("Stage is required") const root = path.resolve(process.cwd(), "..", "..", "..") -const PARTS = 10 +const PARTS = 20 // read the secret const ret = await $`bun sst secret list`.cwd(root).text() diff --git a/packages/console/core/script/pull-models.ts b/packages/console/core/script/pull-models.ts index 01240a71553f..4c376210ff66 100755 --- a/packages/console/core/script/pull-models.ts +++ b/packages/console/core/script/pull-models.ts @@ -9,7 +9,7 @@ const stage = process.argv[2] if (!stage) throw new Error("Stage is required") const root = path.resolve(process.cwd(), "..", "..", "..") -const PARTS = 10 +const PARTS = 20 // read the secret const ret = await $`bun sst secret list --stage ${stage}`.cwd(root).text() diff --git a/packages/console/core/script/update-models.ts b/packages/console/core/script/update-models.ts index 7d1fa99c3d16..9025a6526e5e 100755 --- a/packages/console/core/script/update-models.ts +++ b/packages/console/core/script/update-models.ts @@ -7,7 +7,7 @@ import { ZenData } from "../src/model" const root = path.resolve(process.cwd(), "..", "..", "..") const models = await $`bun sst secret list`.cwd(root).text() -const PARTS = 10 +const PARTS = 20 // read the line starting with "ZEN_MODELS" const lines = models.split("\n") diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index 3848d82bc192..e1e540fb7a44 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -86,7 +86,17 @@ export namespace ZenData { Resource.ZEN_MODELS7.value + Resource.ZEN_MODELS8.value + Resource.ZEN_MODELS9.value + - Resource.ZEN_MODELS10.value, + Resource.ZEN_MODELS10.value + + Resource.ZEN_MODELS11.value + + Resource.ZEN_MODELS12.value + + Resource.ZEN_MODELS13.value + + Resource.ZEN_MODELS14.value + + Resource.ZEN_MODELS15.value + + Resource.ZEN_MODELS16.value + + Resource.ZEN_MODELS17.value + + Resource.ZEN_MODELS18.value + + Resource.ZEN_MODELS19.value + + Resource.ZEN_MODELS20.value, ) return ModelsSchema.parse(json) }) diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts index 0769c76335b1..fea908213dfc 100644 --- a/packages/console/core/sst-env.d.ts +++ b/packages/console/core/sst-env.d.ts @@ -137,10 +137,50 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS11": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS12": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS13": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS14": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS15": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS16": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS17": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS18": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS19": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS2": { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS20": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS3": { "type": "sst.sst.Secret" "value": string diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts index 0769c76335b1..fea908213dfc 100644 --- a/packages/console/function/sst-env.d.ts +++ b/packages/console/function/sst-env.d.ts @@ -137,10 +137,50 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS11": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS12": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS13": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS14": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS15": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS16": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS17": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS18": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS19": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS2": { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS20": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS3": { "type": "sst.sst.Secret" "value": string diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts index 0769c76335b1..fea908213dfc 100644 --- a/packages/console/resource/sst-env.d.ts +++ b/packages/console/resource/sst-env.d.ts @@ -137,10 +137,50 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS11": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS12": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS13": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS14": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS15": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS16": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS17": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS18": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS19": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS2": { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS20": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS3": { "type": "sst.sst.Secret" "value": string diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts index 0769c76335b1..fea908213dfc 100644 --- a/packages/enterprise/sst-env.d.ts +++ b/packages/enterprise/sst-env.d.ts @@ -137,10 +137,50 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS11": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS12": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS13": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS14": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS15": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS16": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS17": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS18": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS19": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS2": { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS20": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS3": { "type": "sst.sst.Secret" "value": string diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index 0769c76335b1..fea908213dfc 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -137,10 +137,50 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS11": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS12": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS13": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS14": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS15": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS16": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS17": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS18": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS19": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS2": { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS20": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS3": { "type": "sst.sst.Secret" "value": string diff --git a/sst-env.d.ts b/sst-env.d.ts index 2d60c83ecdd4..f87d4d603f6a 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -163,10 +163,50 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS11": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS12": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS13": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS14": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS15": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS16": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS17": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS18": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS19": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS2": { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS20": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS3": { "type": "sst.sst.Secret" "value": string From 27c8a0814465fbcbb61fbe6b1dd149675d7046e8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 8 Feb 2026 12:37:59 -0500 Subject: [PATCH 21/24] ui: default TextField copy affordance to clipboard (#12714) --- .../src/components/session/session-header.tsx | 9 +++++- packages/ui/src/components/text-field.tsx | 29 ++++++++++++------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 7eaafc85423b..fcba47004be7 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -420,7 +420,14 @@ export function SessionHeader() { } >
- +
} > @@ -332,7 +329,7 @@ export function SessionHeader() { onClick={() => openDir(current().id)} aria-label={language.t("session.header.open.ariaLabel", { app: current().label })} > - + {language.t("session.header.open.action", { app: current().label })} @@ -359,7 +356,7 @@ export function SessionHeader() { > {options().map((o) => ( openDir(o.id)}> - + {o.label} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index a7ddd5ec8045..b31cfb6cc794 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -235,20 +235,6 @@ export const SettingsGeneral: Component = () => { )} - - {platform.platform === "desktop" && ( - -
- settings.appearance.setTransparentAppIcons(checked)} - /> -
-
- )}
diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 1f0aa3ef1f11..19b3846f84e8 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -26,7 +26,6 @@ export interface Settings { appearance: { fontSize: number font: string - transparentAppIcons: boolean } keybinds: Record permissions: { @@ -47,7 +46,6 @@ const defaultSettings: Settings = { appearance: { fontSize: 14, font: "ibm-plex-mono", - transparentAppIcons: true, }, keybinds: {}, permissions: { @@ -127,12 +125,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setFont(value: string) { setStore("appearance", "font", value) }, - transparentAppIcons: createMemo( - () => store.appearance?.transparentAppIcons ?? defaultSettings.appearance.transparentAppIcons, - ), - setTransparentAppIcons(value: boolean) { - setStore("appearance", "transparentAppIcons", value) - }, }, keybinds: { get: (action: string) => store.keybinds?.[action], diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 4ec499a8da0c..e9f437659d65 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -600,8 +600,6 @@ export const dict = { "settings.general.row.theme.description": "Customise how OpenCode is themed.", "settings.general.row.font.title": "Font", "settings.general.row.font.description": "Customise the mono font used in code blocks", - "settings.general.row.transparentAppIcons.title": "Transparent app icons", - "settings.general.row.transparentAppIcons.description": "Show app logos without the background frame in Open in menu", "settings.general.row.releaseNotes.title": "Release notes", "settings.general.row.releaseNotes.description": "Show What's New popups after updates", diff --git a/packages/ui/src/components/app-icon.css b/packages/ui/src/components/app-icon.css index 8581408ce8f5..afb3ade5b16d 100644 --- a/packages/ui/src/components/app-icon.css +++ b/packages/ui/src/components/app-icon.css @@ -1,10 +1,6 @@ img[data-component="app-icon"] { display: block; box-sizing: border-box; - padding: 2px; - border-radius: 0.125rem; - background: var(--smoke-light-2); - border: 1px solid var(--smoke-light-alpha-4); object-fit: contain; }