From a45841396fa1f804bc9e1f2e8d3db657f4fffe55 Mon Sep 17 00:00:00 2001 From: Dax Date: Thu, 5 Feb 2026 17:27:27 -0500 Subject: [PATCH 01/54] core: fix unhandled errors when aborting with queued messages (#12369) --- packages/opencode/src/session/prompt.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3eec9ed4a..bcfccfb3e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -71,9 +71,6 @@ export namespace SessionPrompt { async (current) => { for (const item of Object.values(current)) { item.abort.abort() - for (const callback of item.callbacks) { - callback.reject(new DOMException("Aborted", "AbortError")) - } } }, ) @@ -251,9 +248,6 @@ export namespace SessionPrompt { return } match.abort.abort() - for (const item of match.callbacks) { - item.reject(new DOMException("Aborted", "AbortError")) - } delete s[sessionID] SessionStatus.set(sessionID, { type: "idle" }) return From 36637b3be09e2244433f2ee95d94d900f8491ad3 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:31:52 -0600 Subject: [PATCH 02/54] chore: rm hardcoded conditional that was used for testing (#12371) --- packages/opencode/src/plugin/codex.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index fe9f7d1ab..c8b833bae 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -371,7 +371,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { } } - if (!provider.models["gpt-5.3-codex"] || true) { + if (!provider.models["gpt-5.3-codex"]) { const model = { id: "gpt-5.3-codex", providerID: "openai", From 1824db13cf6f92b83c972ac81e58a4ab181777fc Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:49:48 -0600 Subject: [PATCH 03/54] tweak: load user plugins after builtin (#12373) --- packages/opencode/src/plugin/index.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index a734d8fa4..9bd5454e8 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -44,12 +44,15 @@ export namespace Plugin { hooks.push(init) } - const plugins: string[] = [] + const plugins = [] if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { plugins.push(...BUILTIN) } - plugins.push(...(config.plugin ?? [])) - if (plugins.length) await Config.waitForDependencies() + if (config.plugin) { + plugins.push(...config.plugin) + } + const wait = Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS ? plugins.length : plugins.length > BUILTIN.length + if (wait) await Config.waitForDependencies() for (let plugin of plugins) { // ignore old codex plugin since it is supported first party now From 9ff423bebf46cdc246a41a5b7cc34c556aa3b8ae Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 5 Feb 2026 17:50:54 -0500 Subject: [PATCH 04/54] senior engineer senior engineering --- packages/opencode/src/plugin/index.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 9bd5454e8..fe8a90513 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -44,15 +44,11 @@ export namespace Plugin { hooks.push(init) } - const plugins = [] + let plugins = config.plugin ?? [] + if (plugins.length) await Config.waitForDependencies() if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { - plugins.push(...BUILTIN) + plugins = [...BUILTIN, ...plugins] } - if (config.plugin) { - plugins.push(...config.plugin) - } - const wait = Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS ? plugins.length : plugins.length > BUILTIN.length - if (wait) await Config.waitForDependencies() for (let plugin of plugins) { // ignore old codex plugin since it is supported first party now From 3da2f2f33b2aacb0781f71e1d68b19c88d65e278 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:51:39 -0600 Subject: [PATCH 05/54] chore: make dax shut up (#12375) From 579902ace6e9fb925f50b7d9fdf11a6b47895307 Mon Sep 17 00:00:00 2001 From: opencode Date: Thu, 5 Feb 2026 23:08:56 +0000 Subject: [PATCH 06/54] release: v1.1.53 --- bun.lock | 30 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++------ packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 18 files changed, 37 insertions(+), 37 deletions(-) diff --git a/bun.lock b/bun.lock index ea8c612d8..dd9790f4b 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.52", + "version": "1.1.53", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -73,7 +73,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.52", + "version": "1.1.53", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -107,7 +107,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.52", + "version": "1.1.53", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -134,7 +134,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.52", + "version": "1.1.53", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -158,7 +158,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.52", + "version": "1.1.53", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -182,7 +182,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.1.52", + "version": "1.1.53", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -213,7 +213,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.52", + "version": "1.1.53", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -242,7 +242,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.52", + "version": "1.1.53", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -258,7 +258,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.52", + "version": "1.1.53", "bin": { "opencode": "./bin/opencode", }, @@ -364,7 +364,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.52", + "version": "1.1.53", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -384,7 +384,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.52", + "version": "1.1.53", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -395,7 +395,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.52", + "version": "1.1.53", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -408,7 +408,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.52", + "version": "1.1.53", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -450,7 +450,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.52", + "version": "1.1.53", "dependencies": { "zod": "catalog:", }, @@ -461,7 +461,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.52", + "version": "1.1.53", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 12688ac07..abef97e81 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.52", + "version": "1.1.53", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 045363ea1..2c289f78b 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.1.52", + "version": "1.1.53", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 256a817d0..4304b1779 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.1.52", + "version": "1.1.53", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 19e9da661..7ad6c9934 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.1.52", + "version": "1.1.53", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 5e328cfb6..c314f3392 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.1.52", + "version": "1.1.53", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 5991ace37..2ef89fd4b 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.1.52", + "version": "1.1.53", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 0663f98ed..289d8fcb8 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.1.52", + "version": "1.1.53", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 8df70b643..216f864fe 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.1.52" +version = "1.1.53" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.52/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.52/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.52/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.52/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.52/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index e029196d4..b3086a180 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.1.52", + "version": "1.1.53", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 6d52c7609..8aacfd0eb 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.52", + "version": "1.1.53", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 86275fc77..2ac19ea51 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.1.52", + "version": "1.1.53", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index bda7bbe86..33a0f1c16 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.1.52", + "version": "1.1.53", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 9522f33a2..4e8028f99 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.1.52", + "version": "1.1.53", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index cdc8baa0b..6948d5288 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.1.52", + "version": "1.1.53", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index 2be458dfb..7c05563d2 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.1.52", + "version": "1.1.53", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index 4e0cafc1b..79fda964f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.1.52", + "version": "1.1.53", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 5b36c06ff..837e4191d 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.1.52", + "version": "1.1.53", "publisher": "sst-dev", "repository": { "type": "git", From 2e9a63fe4fa746125538ac432e2d59e0ce128a24 Mon Sep 17 00:00:00 2001 From: Andrew Pashynnyk <30318777+kynnyhsap@users.noreply.github.com> Date: Fri, 6 Feb 2026 01:35:35 +0200 Subject: [PATCH 07/54] fix(opencode): improve skills dialog readability (#12356) --- packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx index 1ca109f23..66f57a0dd 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -18,9 +18,10 @@ export function DialogSkill(props: DialogSkillProps) { const options = createMemo[]>(() => { const list = skills() ?? [] + const maxWidth = Math.max(0, ...list.map((s) => s.name.length)) return list.map((skill) => ({ - title: skill.name, - description: skill.description, + title: skill.name.padEnd(maxWidth), + description: skill.description?.replace(/\s+/g, " ").trim(), value: skill.name, category: "Skills", onSelect: () => { From 154d0ebf536c8bcb08603db2f66315a3dcd4b987 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 5 Feb 2026 18:38:31 -0500 Subject: [PATCH 08/54] increase skill dialog width --- packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx index 66f57a0dd..4bcd3c7bd 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -10,6 +10,7 @@ export type DialogSkillProps = { export function DialogSkill(props: DialogSkillProps) { const dialog = useDialog() const sdk = useSDK() + dialog.setSize("large") const [skills] = createResource(async () => { const result = await sdk.client.app.skills() From 229cdafcc43ef53611a12dc2e8137575669e8706 Mon Sep 17 00:00:00 2001 From: Hank Stoever Date: Thu, 5 Feb 2026 18:10:35 -0800 Subject: [PATCH 09/54] fix(config): handle $ character with {file:} pattern (#12390) Co-authored-by: Hank Stoever <1109058+hstove@users.noreply.github.com> --- packages/opencode/src/config/config.ts | 2 +- packages/opencode/test/config/config.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ed1b15500..c56bd6c78 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1267,7 +1267,7 @@ export namespace Config { }) ).trim() // escape newlines/quotes, strip outer quotes - text = text.replace(match, JSON.stringify(fileContent).slice(1, -1)) + text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1)) } } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index dee633110..91b87f649 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -193,6 +193,25 @@ test("handles file inclusion substitution", async () => { }) }) +test("handles file inclusion with replacement tokens", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`") + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + theme: "{file:included.md}", + }) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.theme).toBe("const out = await Bun.$`echo hi`") + }, + }) +}) + test("validates config schema and throws on invalid fields", async () => { await using tmp = await tmpdir({ init: async (dir) => { From 683d234d805e4d1097751d3cd583117856e41de5 Mon Sep 17 00:00:00 2001 From: Akshar Patel <123344143+AksharP5@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:11:08 -0500 Subject: [PATCH 10/54] feat(tui): highlight esc label on hover in dialog (#12383) --- .../cli/cmd/tui/component/dialog-provider.tsx | 14 +++++++++++--- .../src/cli/cmd/tui/component/dialog-status.tsx | 16 ++++++++++++---- .../opencode/src/cli/cmd/tui/ui/dialog-alert.tsx | 15 ++++++++++++--- .../src/cli/cmd/tui/ui/dialog-confirm.tsx | 16 ++++++++++++---- .../src/cli/cmd/tui/ui/dialog-export-options.tsx | 16 ++++++++++++---- .../opencode/src/cli/cmd/tui/ui/dialog-help.tsx | 15 ++++++++++++--- .../src/cli/cmd/tui/ui/dialog-prompt.tsx | 16 ++++++++++++---- .../src/cli/cmd/tui/ui/dialog-select.tsx | 16 ++++++++++++---- 8 files changed, 95 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 93e76cbdf..f8be5577b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -124,6 +124,7 @@ function AutoMethod(props: AutoMethodProps) { const dialog = useDialog() const sync = useSync() const toast = useToast() + const [hover, setHover] = createSignal(false) useKeyboard((evt) => { if (evt.name === "c" && !evt.ctrl && !evt.meta) { @@ -154,9 +155,16 @@ function AutoMethod(props: AutoMethodProps) { {props.title} - dialog.clear()}> - esc - + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => dialog.clear()} + > + esc + diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index 3e6e30951..e2ab579a9 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -2,7 +2,7 @@ import { TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog } from "@tui/ui/dialog" import { useSync } from "@tui/context/sync" -import { For, Match, Switch, Show, createMemo } from "solid-js" +import { For, Match, Switch, Show, createMemo, createSignal } from "solid-js" import { Installation } from "@/installation" export type DialogStatusProps = {} @@ -11,6 +11,7 @@ export function DialogStatus() { const sync = useSync() const { theme } = useTheme() const dialog = useDialog() + const [hover, setHover] = createSignal(false) const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled)) @@ -45,9 +46,16 @@ export function DialogStatus() { Status - dialog.clear()}> - esc - + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => dialog.clear()} + > + esc + OpenCode v{Installation.VERSION} 0} fallback={No MCP Servers}> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx index 642c73b48..8b4b61476 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx @@ -2,6 +2,7 @@ import { TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { useKeyboard } from "@opentui/solid" +import { createSignal } from "solid-js" export type DialogAlertProps = { title: string @@ -12,6 +13,7 @@ export type DialogAlertProps = { export function DialogAlert(props: DialogAlertProps) { const dialog = useDialog() const { theme } = useTheme() + const [hover, setHover] = createSignal(false) useKeyboard((evt) => { if (evt.name === "return") { @@ -25,9 +27,16 @@ export function DialogAlert(props: DialogAlertProps) { {props.title} - dialog.clear()}> - esc - + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => dialog.clear()} + > + esc + {props.message} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx index b86bd4325..7d9b74cde 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx @@ -2,7 +2,7 @@ import { TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" -import { For } from "solid-js" +import { createSignal, For } from "solid-js" import { useKeyboard } from "@opentui/solid" import { Locale } from "@/util/locale" @@ -16,6 +16,7 @@ export type DialogConfirmProps = { export function DialogConfirm(props: DialogConfirmProps) { const dialog = useDialog() const { theme } = useTheme() + const [hover, setHover] = createSignal(false) const [store, setStore] = createStore({ active: "confirm" as "confirm" | "cancel", }) @@ -37,9 +38,16 @@ export function DialogConfirm(props: DialogConfirmProps) { {props.title} - dialog.clear()}> - esc - + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => dialog.clear()} + > + esc + {props.message} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx index 1e8d09bb0..957467c5d 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx @@ -2,7 +2,7 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" -import { onMount, Show, type JSX } from "solid-js" +import { createSignal, onMount, Show, type JSX } from "solid-js" import { useKeyboard } from "@opentui/solid" export type DialogExportOptionsProps = { @@ -25,6 +25,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { const dialog = useDialog() const { theme } = useTheme() let textarea: TextareaRenderable + const [hover, setHover] = createSignal(false) const [store, setStore] = createStore({ thinking: props.defaultThinking, toolDetails: props.defaultToolDetails, @@ -80,9 +81,16 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { Export Options - dialog.clear()}> - esc - + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => dialog.clear()} + > + esc + diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx index 4e4527930..f56347d4a 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx @@ -3,11 +3,13 @@ import { useTheme } from "@tui/context/theme" import { useDialog } from "./dialog" import { useKeyboard } from "@opentui/solid" import { useKeybind } from "@tui/context/keybind" +import { createSignal } from "solid-js" export function DialogHelp() { const dialog = useDialog() const { theme } = useTheme() const keybind = useKeybind() + const [hover, setHover] = createSignal(false) useKeyboard((evt) => { if (evt.name === "return" || evt.name === "escape") { @@ -21,9 +23,16 @@ export function DialogHelp() { Help - dialog.clear()}> - esc/enter - + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => dialog.clear()} + > + esc/enter + diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx index b1b05a0f1..03814e17d 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx @@ -1,7 +1,7 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" -import { onMount, type JSX } from "solid-js" +import { createSignal, onMount, type JSX } from "solid-js" import { useKeyboard } from "@opentui/solid" export type DialogPromptProps = { @@ -17,6 +17,7 @@ export function DialogPrompt(props: DialogPromptProps) { const dialog = useDialog() const { theme } = useTheme() let textarea: TextareaRenderable + const [hover, setHover] = createSignal(false) useKeyboard((evt) => { if (evt.name === "return") { @@ -39,9 +40,16 @@ export function DialogPrompt(props: DialogPromptProps) { {props.title} - dialog.clear()}> - esc - + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => dialog.clear()} + > + esc + {props.description} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 490a10072..7792900bc 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -1,7 +1,7 @@ import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import { useTheme, selectedForeground } from "@tui/context/theme" import { entries, filter, flatMap, groupBy, pipe, take } from "remeda" -import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js" +import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on } from "solid-js" import { createStore } from "solid-js/store" import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import * as fuzzysort from "fuzzysort" @@ -49,6 +49,7 @@ export type DialogSelectRef = { export function DialogSelect(props: DialogSelectProps) { const dialog = useDialog() const { theme } = useTheme() + const [hover, setHover] = createSignal(false) const [store, setStore] = createStore({ selected: 0, filter: "", @@ -226,9 +227,16 @@ export function DialogSelect(props: DialogSelectProps) { {props.title} - dialog.clear()}> - esc - + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => dialog.clear()} + > + esc + Date: Fri, 6 Feb 2026 12:15:34 +1000 Subject: [PATCH 11/54] chore: align windows test runner to blacksmith (#12364) --- .github/actions/setup-bun/action.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 7584334a7..65fbf0f3d 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -6,7 +6,7 @@ runs: - name: Mount Bun Cache uses: useblacksmith/stickydisk@v1 with: - key: ${{ github.repository }}-bun-cache + key: ${{ github.repository }}-bun-cache-${{ runner.os }} path: ~/.bun - name: Setup Bun diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2a36c07e1..ae08c1ed6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,7 @@ jobs: git config --global user.name "opencode" bun turbo test - name: windows - host: windows-latest + host: blacksmith-4vcpu-windows-2025 playwright: bunx playwright install workdir: packages/app command: bun test:e2e:local From 0f1fdeceda453a07499b4fdc534b96d18a53aecd Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 6 Feb 2026 00:22:06 -0500 Subject: [PATCH 12/54] zen: fix usage graph --- .../console/app/src/routes/workspace/[id]/graph-section.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/console/app/src/routes/workspace/[id]/graph-section.tsx b/packages/console/app/src/routes/workspace/[id]/graph-section.tsx index 93cba2042..140e1e732 100644 --- a/packages/console/app/src/routes/workspace/[id]/graph-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/graph-section.tsx @@ -1,4 +1,4 @@ -import { and, Database, eq, gte, inArray, isNull, lte, or, sql, sum } from "@opencode-ai/console-core/drizzle/index.js" +import { and, Database, eq, gte, inArray, isNull, lt, or, sql, sum } from "@opencode-ai/console-core/drizzle/index.js" import { UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js" import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js" import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" @@ -27,7 +27,7 @@ async function getCosts(workspaceID: string, year: number, month: number) { "use server" return withActor(async () => { const startDate = new Date(year, month, 1) - const endDate = new Date(year, month + 1, 0) + const endDate = new Date(year, month + 1, 1) const usageData = await Database.use((tx) => tx .select({ @@ -42,7 +42,7 @@ async function getCosts(workspaceID: string, year: number, month: number) { and( eq(UsageTable.workspaceID, workspaceID), gte(UsageTable.timeCreated, startDate), - lte(UsageTable.timeCreated, endDate), + lt(UsageTable.timeCreated, endDate), ), ) .groupBy( From 266de27a0b56c29a3bdb81a5adb211f93214f5a8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 6 Feb 2026 00:29:43 -0500 Subject: [PATCH 13/54] feat(skill): add skill discovery from URLs via well-known RFC Implement the Agent Skills Discovery RFC to allow fetching skills from URLs: - Add 'urls' field to config.skills for specifying skill registry URLs - Create Discovery namespace in skill/discovery.ts with pull() function - Download skills from /.well-known/skills/index.json endpoints - Cache downloaded skills to ~/.cache/opencode/skills/ - Skip re-downloading existing files for efficiency Users can now configure: { "skills": { "urls": ["https://example.com/.well-known/skills/"] } } Implements: https://github.com/cloudflare/agent-skills-discovery-rfc --- packages/opencode/src/config/config.ts | 4 ++ packages/opencode/src/skill/discovery.ts | 80 ++++++++++++++++++++++++ packages/opencode/src/skill/skill.ts | 17 +++++ 3 files changed, 101 insertions(+) create mode 100644 packages/opencode/src/skill/discovery.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index c56bd6c78..6dd0592d5 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -660,6 +660,10 @@ export namespace Config { export const Skills = z.object({ paths: z.array(z.string()).optional().describe("Additional paths to skill folders"), + urls: z + .array(z.string()) + .optional() + .describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"), }) export type Skills = z.infer diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts new file mode 100644 index 000000000..12b3cb3f7 --- /dev/null +++ b/packages/opencode/src/skill/discovery.ts @@ -0,0 +1,80 @@ +import path from "path" +import { mkdir } from "fs/promises" +import { Log } from "../util/log" +import { Global } from "@/global" + +export namespace Discovery { + const log = Log.create({ service: "skill-discovery" }) + + type Index = { + skills: Array<{ + name: string + description: string + files: string[] + }> + } + + export function dir() { + return path.join(Global.Path.cache, "skills") + } + + async function get(url: string, dest: string): Promise { + if (await Bun.file(dest).exists()) return true + try { + const response = await fetch(url) + if (!response.ok) { + log.error("failed to download", { url, status: response.status }) + return false + } + const content = await response.text() + await Bun.write(dest, content) + return true + } catch (err) { + log.error("failed to download", { url, err }) + return false + } + } + + export async function pull(url: string): Promise { + const result: string[] = [] + const indexUrl = new URL("index.json", url.endsWith("/") ? url : `${url}/`).href + const cacheDir = dir() + + try { + log.info("fetching index", { url: indexUrl }) + const response = await fetch(indexUrl) + if (!response.ok) { + log.error("failed to fetch index", { url: indexUrl, status: response.status }) + return result + } + + const index = (await response.json()) as Index + if (!index.skills || !Array.isArray(index.skills)) { + log.warn("invalid index format", { url: indexUrl }) + return result + } + + for (const skill of index.skills) { + if (!skill.name || !skill.files || !Array.isArray(skill.files)) { + log.warn("invalid skill entry", { url: indexUrl, skill }) + continue + } + + const skillDir = path.join(cacheDir, skill.name) + for (const file of skill.files) { + const fileUrl = new URL(file, `${url.replace(/\/$/, "")}/${skill.name}/`).href + const localPath = path.join(skillDir, file) + await mkdir(path.dirname(localPath), { recursive: true }) + await get(fileUrl, localPath) + } + + const skillMd = path.join(skillDir, "SKILL.md") + if (await Bun.file(skillMd).exists()) result.push(skillDir) + } + } catch (err) { + log.error("failed to fetch from URL", { url, err }) + } + + return result + } +} diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index b4f4acd52..b8eb64250 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -11,6 +11,7 @@ import { Filesystem } from "@/util/filesystem" import { Flag } from "@/flag/flag" import { Bus } from "@/bus" import { Session } from "@/session" +import { Discovery } from "./discovery" export namespace Skill { const log = Log.create({ service: "skill" }) @@ -151,6 +152,22 @@ export namespace Skill { } } + // Download and load skills from URLs + for (const skillUrl of config.skills?.urls ?? []) { + const downloadedDirs = await Discovery.pull(skillUrl) + for (const dir of downloadedDirs) { + dirs.add(dir) + for await (const match of SKILL_GLOB.scan({ + cwd: dir, + absolute: true, + onlyFiles: true, + followSymlinks: true, + })) { + await addSkill(match) + } + } + } + return { skills, dirs: Array.from(dirs), From c35bd39829e3760cfc2749a91cc0de779eb677f9 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 6 Feb 2026 00:36:08 -0500 Subject: [PATCH 14/54] tui: parallelize skill downloads for faster loading Refactored skill discovery to download skills in parallel instead of sequentially, reducing load times when multiple skills need to be fetched from remote URLs. Also updated AGENTS.md with guidance on using dev branch for diffs. --- AGENTS.md | 1 + packages/opencode/src/skill/discovery.ts | 109 +++++++++++++---------- packages/opencode/src/skill/skill.ts | 6 +- 3 files changed, 67 insertions(+), 49 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eeec0c341..d51134c0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ - To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`. - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. - The default branch in this repo is `dev`. +- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. - Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility. ## Style Guide diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index 12b3cb3f7..a4bf97d7a 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -1,7 +1,7 @@ import path from "path" import { mkdir } from "fs/promises" import { Log } from "../util/log" -import { Global } from "@/global" +import { Global } from "../global" export namespace Discovery { const log = Log.create({ service: "skill-discovery" }) @@ -20,61 +20,78 @@ export namespace Discovery { async function get(url: string, dest: string): Promise { if (await Bun.file(dest).exists()) return true - try { - const response = await fetch(url) - if (!response.ok) { - log.error("failed to download", { url, status: response.status }) + return fetch(url) + .then(async (response) => { + if (!response.ok) { + log.error("failed to download", { url, status: response.status }) + return false + } + await Bun.write(dest, await response.text()) + return true + }) + .catch((err) => { + log.error("failed to download", { url, err }) return false - } - const content = await response.text() - await Bun.write(dest, content) - return true - } catch (err) { - log.error("failed to download", { url, err }) - return false - } + }) } export async function pull(url: string): Promise { const result: string[] = [] - const indexUrl = new URL("index.json", url.endsWith("/") ? url : `${url}/`).href - const cacheDir = dir() + const base = url.endsWith("/") ? url : `${url}/` + const index = new URL("index.json", base).href + const cache = dir() + const host = base.slice(0, -1) - try { - log.info("fetching index", { url: indexUrl }) - const response = await fetch(indexUrl) - if (!response.ok) { - log.error("failed to fetch index", { url: indexUrl, status: response.status }) - return result - } - - const index = (await response.json()) as Index - if (!index.skills || !Array.isArray(index.skills)) { - log.warn("invalid index format", { url: indexUrl }) - return result - } - - for (const skill of index.skills) { - if (!skill.name || !skill.files || !Array.isArray(skill.files)) { - log.warn("invalid skill entry", { url: indexUrl, skill }) - continue + log.info("fetching index", { url: index }) + const data = await fetch(index) + .then(async (response) => { + if (!response.ok) { + log.error("failed to fetch index", { url: index, status: response.status }) + return undefined } + return response + .json() + .then((json) => json as Index) + .catch((err) => { + log.error("failed to parse index", { url: index, err }) + return undefined + }) + }) + .catch((err) => { + log.error("failed to fetch index", { url: index, err }) + return undefined + }) - const skillDir = path.join(cacheDir, skill.name) - for (const file of skill.files) { - const fileUrl = new URL(file, `${url.replace(/\/$/, "")}/${skill.name}/`).href - const localPath = path.join(skillDir, file) - await mkdir(path.dirname(localPath), { recursive: true }) - await get(fileUrl, localPath) - } - - const skillMd = path.join(skillDir, "SKILL.md") - if (await Bun.file(skillMd).exists()) result.push(skillDir) - } - } catch (err) { - log.error("failed to fetch from URL", { url, err }) + if (!data?.skills || !Array.isArray(data.skills)) { + log.warn("invalid index format", { url: index }) + return result } + const list = data.skills.filter((skill) => { + if (!skill?.name || !Array.isArray(skill.files)) { + log.warn("invalid skill entry", { url: index, skill }) + return false + } + return true + }) + + await Promise.all( + list.map(async (skill) => { + const root = path.join(cache, skill.name) + await Promise.all( + skill.files.map(async (file) => { + const link = new URL(file, `${host}/${skill.name}/`).href + const dest = path.join(root, file) + await mkdir(path.dirname(dest), { recursive: true }) + await get(link, dest) + }), + ) + + const md = path.join(root, "SKILL.md") + if (await Bun.file(md).exists()) result.push(root) + }), + ) + return result } } diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index b8eb64250..42795b7eb 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -153,9 +153,9 @@ export namespace Skill { } // Download and load skills from URLs - for (const skillUrl of config.skills?.urls ?? []) { - const downloadedDirs = await Discovery.pull(skillUrl) - for (const dir of downloadedDirs) { + for (const url of config.skills?.urls ?? []) { + const list = await Discovery.pull(url) + for (const dir of list) { dirs.add(dir) for await (const match of SKILL_GLOB.scan({ cwd: dir, From 9f00b8c8dc67a806ce02251977abdfdb305aadab Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 6 Feb 2026 05:37:00 +0000 Subject: [PATCH 15/54] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 4 ++++ packages/sdk/openapi.json | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index cb1606e3f..81df47844 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1662,6 +1662,10 @@ export type Config = { * Additional paths to skill folders */ paths?: Array + /** + * URLs to fetch skills from (e.g., https://example.com/.well-known/skills/) + */ + urls?: Array } watcher?: { ignore?: Array diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 1425a6e9c..feb4a29a5 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9547,6 +9547,13 @@ "items": { "type": "string" } + }, + "urls": { + "description": "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)", + "type": "array", + "items": { + "type": "string" + } } } }, From 26e1901bd0810e9dedce498c4f7bf35e222880f3 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 6 Feb 2026 15:30:13 +0800 Subject: [PATCH 16/54] desktop: maximize main window by default (#12433) --- packages/desktop/src-tauri/src/lib.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index d309b918b..e1cb89cd7 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -15,7 +15,7 @@ use std::{ sync::{Arc, Mutex}, time::{Duration, Instant}, }; -use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewWindowBuilder}; +use tauri::{AppHandle, Manager, RunEvent, State, WebviewWindowBuilder}; #[cfg(windows)] use tauri_plugin_decorum::WebviewWindowExt; #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] @@ -32,7 +32,7 @@ const SETTINGS_STORE: &str = "opencode.settings.dat"; const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl"; fn window_state_flags() -> StateFlags { - StateFlags::all() - StateFlags::DECORATIONS + StateFlags::all() - StateFlags::DECORATIONS - StateFlags::VISIBLE } #[derive(Clone, serde::Serialize, specta::Type)] @@ -326,11 +326,6 @@ pub fn run() { #[cfg(windows)] app.manage(JobObjectState::new()); - let primary_monitor = app.primary_monitor().ok().flatten(); - let size = primary_monitor - .map(|m| m.size().to_logical(m.scale_factor())) - .unwrap_or(LogicalSize::new(1920, 1080)); - let config = app .config() .app @@ -341,7 +336,7 @@ pub fn run() { let window_builder = WebviewWindowBuilder::from_config(&app, config) .expect("Failed to create window builder from config") - .inner_size(size.width as f64, size.height as f64) + .maximized(true) .initialization_script(format!( r#" window.__OPENCODE__ ??= {{}}; @@ -584,6 +579,7 @@ fn setup_window_state_listener(app: &tauri::AppHandle, window: &tauri::WebviewWi let handle = app.clone(); let app = app.clone(); let _ = handle.run_on_main_thread(move || { + println!("saving window state"); let _ = app.save_window_state(window_state_flags()); }); }; From 9b20679a610af778e4f0fb741baed524edb2514a Mon Sep 17 00:00:00 2001 From: Devin Griffin <31415269+DNGriffin@users.noreply.github.com> Date: Fri, 6 Feb 2026 01:46:21 -0600 Subject: [PATCH 17/54] fix(app): always show project menu button for mobile a11y (#11258) Co-authored-by: Brendan Allan --- packages/app/src/pages/layout.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 251984d77..c538b920c 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2664,7 +2664,10 @@ export default function Layout(props: ParentProps) { variant="ghost" data-action="project-menu" data-project={base64Encode(p().worktree)} - class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active" + class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active" + classList={{ + "opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile, + }} aria-label={language.t("common.moreOptions")} /> From e0e32ed3a846be03f66d2f48e2b2659a9e658a11 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 6 Feb 2026 18:10:48 +0800 Subject: [PATCH 18/54] desktop: add more basic menu bar items --- .prettierignore | 2 +- packages/app/src/app.tsx | 11 ++- packages/app/src/components/titlebar.tsx | 15 +++ packages/app/src/index.ts | 1 + packages/desktop/src/bindings.ts | 2 +- packages/desktop/src/index.tsx | 23 ++++- packages/desktop/src/menu.ts | 118 ++++++++++++++++++----- 7 files changed, 138 insertions(+), 34 deletions(-) diff --git a/.prettierignore b/.prettierignore index 5f86f710f..a2a277659 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,2 @@ sst-env.d.ts -desktop/src/bindings.ts +packages/desktop/src/bindings.ts diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 11fdb5743..8a111472b 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -30,7 +30,7 @@ import { HighlightsProvider } from "@/context/highlights" import Layout from "@/pages/layout" import DirectoryLayout from "@/pages/directory-layout" import { ErrorPage } from "./pages/error" -import { Suspense } from "solid-js" +import { Suspense, JSX } from "solid-js" const Home = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) @@ -84,7 +84,7 @@ function ServerKey(props: ParentProps) { ) } -export function AppInterface(props: { defaultUrl?: string }) { +export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element }) { const platform = usePlatform() const stored = (() => { @@ -111,7 +111,7 @@ export function AppInterface(props: { defaultUrl?: string }) { ( + root={(routerProps) => ( @@ -119,7 +119,10 @@ export function AppInterface(props: { defaultUrl?: string }) { - {props.children} + + {props.children} + {routerProps.children} + diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 86b4fbeb1..32e36815e 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -82,6 +82,21 @@ export function Titlebar() { navigate(to) } + command.register(() => [ + { + id: "common.goBack", + title: language.t("common.goBack"), + category: language.t("command.category.view"), + onSelect: back, + }, + { + id: "common.goForward", + title: language.t("common.goForward"), + category: language.t("command.category.view"), + onSelect: forward, + }, + ]) + const getWin = () => { if (platform.platform !== "desktop") return diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index df3181133..c32af728a 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,2 +1,3 @@ export { PlatformProvider, type Platform } from "./context/platform" export { AppBaseProviders, AppInterface } from "./app" +export { useCommand } from './context/command' diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index 440e138b4..c6ca0fec7 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -1,6 +1,6 @@ // This file has been generated by Tauri Specta. Do not edit this file manually. -import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core" +import { invoke as __TAURI_INVOKE } from "@tauri-apps/api/core" /** Commands */ export const commands = { diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 30cb7ba7a..66e86bf52 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -1,7 +1,7 @@ // @refresh reload import { webviewZoom } from "./webview-zoom" import { render } from "solid-js/web" -import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app" +import { AppBaseProviders, AppInterface, PlatformProvider, Platform, useCommand } from "@opencode-ai/app" 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" @@ -18,11 +18,11 @@ import { Splash } from "@opencode-ai/ui/logo" import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js" import { UPDATER_ENABLED } from "./updater" -import { createMenu } from "./menu" import { initI18n, t } from "./i18n" import pkg from "../package.json" import "./styles.css" import { commands } from "./bindings" +import { createMenu } from "./menu" const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { @@ -342,7 +342,10 @@ const createPlatform = (password: Accessor): Platform => ({ webviewZoom, }) -createMenu() +let menuTrigger = null as null | ((id: string) => void) +createMenu((id) => { + menuTrigger?.(id) +}) void listenForDeepLinks() render(() => { @@ -373,7 +376,19 @@ render(() => { window.__OPENCODE__ ??= {} window.__OPENCODE__.serverPassword = data().password ?? undefined - return + function Inner() { + const cmd = useCommand() + + menuTrigger = (id) => cmd.trigger(id) + + return null + } + + return ( + + + + ) }} diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index d41084404..9af6d2b84 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -1,13 +1,14 @@ import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu" import { type as ostype } from "@tauri-apps/plugin-os" import { relaunch } from "@tauri-apps/plugin-process" +import { openUrl } from "@tauri-apps/plugin-opener" import { runUpdater, UPDATER_ENABLED } from "./updater" import { installCli } from "./cli" import { initI18n, t } from "./i18n" import { commands } from "./bindings" -export async function createMenu() { +export async function createMenu(trigger: (id: string) => void) { if (ostype() !== "macos") return await initI18n() @@ -60,29 +61,25 @@ export async function createMenu() { }), ].filter(Boolean), }), - // await Submenu.new({ - // text: "File", - // items: [ - // await MenuItem.new({ - // enabled: false, - // text: "Open Project...", - // }), - // await PredefinedMenuItem.new({ - // item: "Separator" - // }), - // await MenuItem.new({ - // enabled: false, - // text: "New Session", - // }), - // await PredefinedMenuItem.new({ - // item: "Separator" - // }), - // await MenuItem.new({ - // enabled: false, - // text: "Close Project", - // }) - // ] - // }), + await Submenu.new({ + text: "File", + items: [ + await MenuItem.new({ + text: "New Session", + action: () => trigger("session.new"), + }), + await MenuItem.new({ + text: "Open Project...", + action: () => trigger("project.open"), + }), + await PredefinedMenuItem.new({ + item: "Separator", + }), + await PredefinedMenuItem.new({ + item: "CloseWindow", + }), + ], + }), await Submenu.new({ text: "Edit", items: [ @@ -109,6 +106,79 @@ export async function createMenu() { }), ], }), + await Submenu.new({ + text: "View", + items: [ + await MenuItem.new({ + action: () => trigger("sidebar.toggle"), + text: "Toggle Sidebar", + }), + await MenuItem.new({ + action: () => trigger("terminal.toggle"), + text: "Toggle Terminal", + }), + await MenuItem.new({ + action: () => trigger("fileTree.toggle"), + text: "Toggle File Tree", + }), + await PredefinedMenuItem.new({ + item: "Separator", + }), + await MenuItem.new({ + action: () => trigger("common.goBack"), + text: "Back", + }), + await MenuItem.new({ + action: () => trigger("common.goForward"), + text: "Forward", + }), + await PredefinedMenuItem.new({ + item: "Separator", + }), + await MenuItem.new({ + action: () => trigger("session.next"), + text: "Previous Session", + }), + await MenuItem.new({ + action: () => trigger("session.previous"), + text: "Next Session", + }), + await PredefinedMenuItem.new({ + item: "Separator", + }), + ], + }), + await Submenu.new({ + text: "Help", + items: [ + // missing native macos search + await MenuItem.new({ + action: () => openUrl("https://opencode.ai/docs"), + text: "OpenCode Documentation", + }), + await MenuItem.new({ + action: () => openUrl("https://discord.com/invite/opencode"), + text: "Support Forum", + }), + await PredefinedMenuItem.new({ + item: "Separator", + }), + // await MenuItem.new({ + // text: "Release Notes", + // }), + await PredefinedMenuItem.new({ + item: "Separator", + }), + await MenuItem.new({ + action: () => openUrl("https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml"), + text: "Share Feedback", + }), + await MenuItem.new({ + action: () => openUrl("https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml"), + text: "Report a Bug", + }), + ], + }), ], }) menu.setAsAppMenu() From 5ae4463b633c96e6d6e5dd58affa057efd8c2fc7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 6 Feb 2026 10:11:44 +0000 Subject: [PATCH 19/54] chore: generate --- packages/app/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index c32af728a..fb6682009 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,3 +1,3 @@ export { PlatformProvider, type Platform } from "./context/platform" export { AppBaseProviders, AppInterface } from "./app" -export { useCommand } from './context/command' +export { useCommand } from "./context/command" From 3c5e1a98fcd704d7765b77a8e354b0d3816a94b1 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 6 Feb 2026 18:20:04 +0800 Subject: [PATCH 20/54] desktop: add key accelerators to menu itms --- packages/desktop/src/menu.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index 9af6d2b84..1fc04238b 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -66,10 +66,12 @@ export async function createMenu(trigger: (id: string) => void) { items: [ await MenuItem.new({ text: "New Session", + accelerator: "Shift+Cmd+S", action: () => trigger("session.new"), }), await MenuItem.new({ text: "Open Project...", + accelerator: "Cmd+O", action: () => trigger("project.open"), }), await PredefinedMenuItem.new({ @@ -112,10 +114,12 @@ export async function createMenu(trigger: (id: string) => void) { await MenuItem.new({ action: () => trigger("sidebar.toggle"), text: "Toggle Sidebar", + accelerator: "Cmd+B" }), await MenuItem.new({ action: () => trigger("terminal.toggle"), text: "Toggle Terminal", + accelerator: "Ctrl+`" }), await MenuItem.new({ action: () => trigger("fileTree.toggle"), @@ -136,12 +140,14 @@ export async function createMenu(trigger: (id: string) => void) { item: "Separator", }), await MenuItem.new({ - action: () => trigger("session.next"), + action: () => trigger("session.previous"), text: "Previous Session", + accelerator: "Option+ArrowUp" }), await MenuItem.new({ - action: () => trigger("session.previous"), + action: () => trigger("session.next"), text: "Next Session", + accelerator: "Option+ArrowDown" }), await PredefinedMenuItem.new({ item: "Separator", From 981b80d40b52f846e16eeddab1be30bb955d0cb8 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 6 Feb 2026 10:20:52 +0000 Subject: [PATCH 21/54] chore: generate --- packages/desktop/src/menu.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index 1fc04238b..9fcb6115b 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -114,12 +114,12 @@ export async function createMenu(trigger: (id: string) => void) { await MenuItem.new({ action: () => trigger("sidebar.toggle"), text: "Toggle Sidebar", - accelerator: "Cmd+B" + accelerator: "Cmd+B", }), await MenuItem.new({ action: () => trigger("terminal.toggle"), text: "Toggle Terminal", - accelerator: "Ctrl+`" + accelerator: "Ctrl+`", }), await MenuItem.new({ action: () => trigger("fileTree.toggle"), @@ -142,12 +142,12 @@ export async function createMenu(trigger: (id: string) => void) { await MenuItem.new({ action: () => trigger("session.previous"), text: "Previous Session", - accelerator: "Option+ArrowUp" + accelerator: "Option+ArrowUp", }), await MenuItem.new({ action: () => trigger("session.next"), text: "Next Session", - accelerator: "Option+ArrowDown" + accelerator: "Option+ArrowDown", }), await PredefinedMenuItem.new({ item: "Separator", From 80a5c3d7ed65e6fce599f82f7db68f3d5ac01d91 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 6 Feb 2026 05:35:00 -0600 Subject: [PATCH 22/54] chore: cleanup --- packages/app/src/pages/session.tsx | 10 ++++------ packages/desktop/src/bindings.ts | 21 +++++++++++---------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index cb07c3b47..433e47925 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -2463,8 +2463,7 @@ export default function Page() { "sticky top-0 z-30 bg-background-stronger": true, "w-full": true, "px-4 md:px-6": true, - "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": - centered(), + "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": centered(), }} >
@@ -2586,8 +2585,7 @@ export default function Page() { class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]" classList={{ "w-full": true, - "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": - centered(), + "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": centered(), "mt-0.5": centered(), "mt-0": !centered(), }} @@ -2640,7 +2638,7 @@ export default function Page() { data-message-id={message.id} classList={{ "min-w-0 w-full max-w-full": true, - "md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": centered(), + "md:max-w-200 3xl:max-w-[1200px]": centered(), }} > diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index c6ca0fec7..64d0c113d 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -4,16 +4,17 @@ import { invoke as __TAURI_INVOKE } from "@tauri-apps/api/core" /** Commands */ export const commands = { - killSidecar: () => __TAURI_INVOKE("kill_sidecar"), - installCli: () => __TAURI_INVOKE("install_cli"), - ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), - getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), - setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), - parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), -} + killSidecar: () => __TAURI_INVOKE("kill_sidecar"), + installCli: () => __TAURI_INVOKE("install_cli"), + ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), + getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), + setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), + parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), +}; /* Types */ export type ServerReadyData = { - url: string - password: string | null -} + url: string, + password: string | null, + }; + From 5d922198121338ba67ec1e809a57cf257889bb3c Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 6 Feb 2026 05:50:38 -0600 Subject: [PATCH 23/54] fix(app): retry error unwrapping (#12462) --- packages/ui/src/components/session-turn.css | 8 ++ packages/ui/src/components/session-turn.tsx | 99 ++++++++++++++++++++- 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 4b8ba8d7a..9887ce2fc 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -501,6 +501,7 @@ [data-slot="session-turn-collapsible-trigger-content"] { max-width: 100%; + min-width: 0; display: flex; align-items: center; gap: 8px; @@ -525,6 +526,10 @@ [data-slot="session-turn-retry-message"] { font-weight: 500; color: var(--syntax-critical); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } [data-slot="session-turn-retry-seconds"] { @@ -549,6 +554,9 @@ .error-card { color: var(--text-on-critical-base); max-height: 240px; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; overflow-y: auto; } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 5ea9f64bb..c2e26b9c7 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -27,6 +27,59 @@ import { createResizeObserver } from "@solid-primitives/resize-observer" type Translator = (key: UiI18nKey, params?: UiI18nParams) => string +function record(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function unwrap(message: string) { + const text = message.replace(/^Error:\s*/, "").trim() + + const parse = (value: string) => { + try { + return JSON.parse(value) as unknown + } catch { + return undefined + } + } + + const read = (value: string) => { + const first = parse(value) + if (typeof first !== "string") return first + return parse(first.trim()) + } + + let json = read(text) + + if (json === undefined) { + const start = text.indexOf("{") + const end = text.lastIndexOf("}") + if (start !== -1 && end > start) { + json = read(text.slice(start, end + 1)) + } + } + + if (!record(json)) return message + + const err = record(json.error) ? json.error : undefined + if (err) { + const type = typeof err.type === "string" ? err.type : undefined + const msg = typeof err.message === "string" ? err.message : undefined + if (type && msg) return `${type}: ${msg}` + if (msg) return msg + if (type) return type + const code = typeof err.code === "string" ? err.code : undefined + if (code) return code + } + + const msg = typeof json.message === "string" ? json.message : undefined + if (msg) return msg + + const reason = typeof json.error === "string" ? json.error : undefined + if (reason) return reason + + return message +} + function computeStatusFromPart(part: PartType | undefined, t: Translator): string | undefined { if (!part) return undefined @@ -236,6 +289,12 @@ export function SessionTurn( const lastAssistantMessage = createMemo(() => assistantMessages().at(-1)) const error = createMemo(() => assistantMessages().find((m) => m.error)?.error) + const errorText = createMemo(() => { + const msg = error()?.data?.message + if (typeof msg === "string") return unwrap(msg) + if (msg === undefined || msg === null) return "" + return unwrap(String(msg)) + }) const lastTextPart = createMemo(() => { const msgs = assistantMessages() @@ -463,6 +522,39 @@ export function SessionTurn( onCleanup(() => clearInterval(timer)) }) + let retryLog = "" + createEffect(() => { + const r = retry() + if (!r) return + const key = `${r.attempt}:${r.next}:${r.message}` + if (key === retryLog) return + retryLog = key + console.warn("[session-turn] retry", { + sessionID: props.sessionID, + messageID: props.messageID, + attempt: r.attempt, + next: r.next, + raw: r.message, + parsed: unwrap(r.message), + }) + }) + + let errorLog = "" + createEffect(() => { + const value = error()?.data?.message + if (value === undefined || value === null) return + const raw = typeof value === "string" ? value : String(value) + if (!raw) return + if (raw === errorLog) return + errorLog = raw + console.warn("[session-turn] assistant-error", { + sessionID: props.sessionID, + messageID: props.messageID, + raw, + parsed: unwrap(raw), + }) + }) + createEffect(() => { const update = () => { setStore("duration", duration()) @@ -595,7 +687,8 @@ export function SessionTurn( {(() => { const r = retry() if (!r) return "" - return r.message.length > 60 ? r.message.slice(0, 60) + "..." : r.message + const msg = unwrap(r.message) + return msg.length > 60 ? msg.slice(0, 60) + "..." : msg })()} @@ -640,7 +733,7 @@ export function SessionTurn( - {error()?.data?.message as string} + {errorText()}
@@ -696,7 +789,7 @@ export function SessionTurn(
- {error()?.data?.message as string} + {errorText()} From 4afec6731d1051b6b7ea1f3a278c7e16b577736c Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 6 Feb 2026 05:51:01 -0600 Subject: [PATCH 24/54] ignore: refactoring and tests (#12460) --- .../src/components/titlebar-history.test.ts | 63 ++++ .../app/src/components/titlebar-history.ts | 57 +++ packages/app/src/components/titlebar.tsx | 43 +-- packages/app/src/context/command.test.ts | 25 ++ packages/app/src/context/command.tsx | 48 ++- packages/app/src/context/global-sync.test.ts | 136 ++++++++ packages/app/src/context/global-sync.tsx | 329 +++++++++++++++--- .../app/src/context/notification-index.ts | 66 ++++ packages/app/src/context/notification.test.ts | 73 ++++ packages/app/src/context/notification.tsx | 57 +-- packages/app/src/pages/layout.tsx | 294 +++++++--------- 11 files changed, 899 insertions(+), 292 deletions(-) create mode 100644 packages/app/src/components/titlebar-history.test.ts create mode 100644 packages/app/src/components/titlebar-history.ts create mode 100644 packages/app/src/context/command.test.ts create mode 100644 packages/app/src/context/global-sync.test.ts create mode 100644 packages/app/src/context/notification-index.ts create mode 100644 packages/app/src/context/notification.test.ts diff --git a/packages/app/src/components/titlebar-history.test.ts b/packages/app/src/components/titlebar-history.test.ts new file mode 100644 index 000000000..25035d7cc --- /dev/null +++ b/packages/app/src/components/titlebar-history.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { applyPath, backPath, forwardPath, type TitlebarHistory } from "./titlebar-history" + +function history(): TitlebarHistory { + return { stack: [], index: 0, action: undefined } +} + +describe("titlebar history", () => { + test("append and trim keeps max bounded", () => { + let state = history() + state = applyPath(state, "/", 3) + state = applyPath(state, "/a", 3) + state = applyPath(state, "/b", 3) + state = applyPath(state, "/c", 3) + + expect(state.stack).toEqual(["/a", "/b", "/c"]) + expect(state.stack.length).toBe(3) + expect(state.index).toBe(2) + }) + + test("back and forward indexes stay correct after trimming", () => { + let state = history() + state = applyPath(state, "/", 3) + state = applyPath(state, "/a", 3) + state = applyPath(state, "/b", 3) + state = applyPath(state, "/c", 3) + + expect(state.stack).toEqual(["/a", "/b", "/c"]) + expect(state.index).toBe(2) + + const back = backPath(state) + expect(back?.to).toBe("/b") + expect(back?.state.index).toBe(1) + + const afterBack = applyPath(back!.state, back!.to, 3) + expect(afterBack.stack).toEqual(["/a", "/b", "/c"]) + expect(afterBack.index).toBe(1) + + const forward = forwardPath(afterBack) + expect(forward?.to).toBe("/c") + expect(forward?.state.index).toBe(2) + + const afterForward = applyPath(forward!.state, forward!.to, 3) + expect(afterForward.stack).toEqual(["/a", "/b", "/c"]) + expect(afterForward.index).toBe(2) + }) + + test("action-driven navigation does not push duplicate history entries", () => { + const state: TitlebarHistory = { + stack: ["/", "/a", "/b"], + index: 2, + action: undefined, + } + + const back = backPath(state) + expect(back?.to).toBe("/a") + + const next = applyPath(back!.state, back!.to, 10) + expect(next.stack).toEqual(["/", "/a", "/b"]) + expect(next.index).toBe(1) + expect(next.action).toBeUndefined() + }) +}) diff --git a/packages/app/src/components/titlebar-history.ts b/packages/app/src/components/titlebar-history.ts new file mode 100644 index 000000000..44dbbfa3a --- /dev/null +++ b/packages/app/src/components/titlebar-history.ts @@ -0,0 +1,57 @@ +export const MAX_TITLEBAR_HISTORY = 100 + +export type TitlebarAction = "back" | "forward" | undefined + +export type TitlebarHistory = { + stack: string[] + index: number + action: TitlebarAction +} + +export function applyPath(state: TitlebarHistory, current: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory { + if (!state.stack.length) { + const stack = current === "/" ? ["/"] : ["/", current] + return { stack, index: stack.length - 1, action: undefined } + } + + const active = state.stack[state.index] + if (current === active) { + if (!state.action) return state + return { ...state, action: undefined } + } + + if (state.action) return { ...state, action: undefined } + + return pushPath(state, current, max) +} + +export function pushPath(state: TitlebarHistory, path: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory { + const stack = state.stack.slice(0, state.index + 1).concat(path) + const next = trimHistory(stack, stack.length - 1, max) + return { ...state, ...next, action: undefined } +} + +export function trimHistory(stack: string[], index: number, max = MAX_TITLEBAR_HISTORY) { + if (stack.length <= max) return { stack, index } + const cut = stack.length - max + return { + stack: stack.slice(cut), + index: Math.max(0, index - cut), + } +} + +export function backPath(state: TitlebarHistory) { + if (state.index <= 0) return + const index = state.index - 1 + const to = state.stack[index] + if (!to) return + return { state: { ...state, index, action: "back" as const }, to } +} + +export function forwardPath(state: TitlebarHistory) { + if (state.index >= state.stack.length - 1) return + const index = state.index + 1 + const to = state.stack[index] + if (!to) return + return { state: { ...state, index, action: "forward" as const }, to } +} diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 32e36815e..d8735410a 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -11,6 +11,7 @@ import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" +import { applyPath, backPath, forwardPath } from "./titlebar-history" export function Titlebar() { const layout = useLayout() @@ -39,25 +40,9 @@ export function Titlebar() { const current = path() untrack(() => { - if (!history.stack.length) { - const stack = current === "/" ? ["/"] : ["/", current] - setHistory({ stack, index: stack.length - 1 }) - return - } - - const active = history.stack[history.index] - if (current === active) { - if (history.action) setHistory("action", undefined) - return - } - - if (history.action) { - setHistory("action", undefined) - return - } - - const next = history.stack.slice(0, history.index + 1).concat(current) - setHistory({ stack: next, index: next.length - 1 }) + const next = applyPath(history, current) + if (next === history) return + setHistory(next) }) }) @@ -65,21 +50,17 @@ export function Titlebar() { const canForward = createMemo(() => history.index < history.stack.length - 1) const back = () => { - if (!canBack()) return - const index = history.index - 1 - const to = history.stack[index] - if (!to) return - setHistory({ index, action: "back" }) - navigate(to) + const next = backPath(history) + if (!next) return + setHistory(next.state) + navigate(next.to) } const forward = () => { - if (!canForward()) return - const index = history.index + 1 - const to = history.stack[index] - if (!to) return - setHistory({ index, action: "forward" }) - navigate(to) + const next = forwardPath(history) + if (!next) return + setHistory(next.state) + navigate(next.to) } command.register(() => [ diff --git a/packages/app/src/context/command.test.ts b/packages/app/src/context/command.test.ts new file mode 100644 index 000000000..2b956287c --- /dev/null +++ b/packages/app/src/context/command.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import { upsertCommandRegistration } from "./command" + +describe("upsertCommandRegistration", () => { + test("replaces keyed registrations", () => { + const one = () => [{ id: "one", title: "One" }] + const two = () => [{ id: "two", title: "Two" }] + + const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two }) + + expect(next).toHaveLength(1) + expect(next[0]?.options).toBe(two) + }) + + test("keeps unkeyed registrations additive", () => { + const one = () => [{ id: "one", title: "One" }] + const two = () => [{ id: "two", title: "Two" }] + + const next = upsertCommandRegistration([{ options: one }], { options: two }) + + expect(next).toHaveLength(2) + expect(next[0]?.options).toBe(two) + expect(next[1]?.options).toBe(one) + }) +}) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 791569584..e6a16fd4b 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -64,6 +64,16 @@ export type CommandCatalogItem = { slash?: string } +export type CommandRegistration = { + key?: string + options: Accessor +} + +export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) { + if (entry.key === undefined) return [entry, ...registrations] + return [entry, ...registrations.filter((x) => x.key !== entry.key)] +} + export function parseKeybind(config: string): Keybind[] { if (!config || config === "none") return [] @@ -166,9 +176,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const settings = useSettings() const language = useLanguage() const [store, setStore] = createStore({ - registrations: [] as Accessor[], + registrations: [] as CommandRegistration[], suspendCount: 0, }) + const warnedDuplicates = new Set() const [catalog, setCatalog, _, catalogReady] = persisted( Persist.global("command.catalog.v1"), @@ -187,8 +198,14 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const all: CommandOption[] = [] for (const reg of store.registrations) { - for (const opt of reg()) { - if (seen.has(opt.id)) continue + for (const opt of reg.options()) { + if (seen.has(opt.id)) { + if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) { + warnedDuplicates.add(opt.id) + console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`) + } + continue + } seen.add(opt.id) all.push(opt) } @@ -296,14 +313,25 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex document.removeEventListener("keydown", handleKeyDown) }) + function register(cb: () => CommandOption[]): void + function register(key: string, cb: () => CommandOption[]): void + function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) { + const id = typeof key === "string" ? key : undefined + const next = typeof key === "function" ? key : cb + if (!next) return + const options = createMemo(next) + const entry: CommandRegistration = { + key: id, + options, + } + setStore("registrations", (arr) => upsertCommandRegistration(arr, entry)) + onCleanup(() => { + setStore("registrations", (arr) => arr.filter((x) => x !== entry)) + }) + } + return { - register(cb: () => CommandOption[]) { - const results = createMemo(cb) - setStore("registrations", (arr) => [results, ...arr]) - onCleanup(() => { - setStore("registrations", (arr) => arr.filter((x) => x !== results)) - }) - }, + register, trigger(id: string, source?: "palette" | "keybind" | "slash") { run(id, source) }, diff --git a/packages/app/src/context/global-sync.test.ts b/packages/app/src/context/global-sync.test.ts new file mode 100644 index 000000000..396b41231 --- /dev/null +++ b/packages/app/src/context/global-sync.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test" +import { + canDisposeDirectory, + estimateRootSessionTotal, + loadRootSessionsWithFallback, + pickDirectoriesToEvict, +} from "./global-sync" + +describe("pickDirectoriesToEvict", () => { + test("keeps pinned stores and evicts idle stores", () => { + const now = 5_000 + const picks = pickDirectoriesToEvict({ + stores: ["a", "b", "c", "d"], + state: new Map([ + ["a", { lastAccessAt: 1_000 }], + ["b", { lastAccessAt: 4_900 }], + ["c", { lastAccessAt: 4_800 }], + ["d", { lastAccessAt: 3_000 }], + ]), + pins: new Set(["a"]), + max: 2, + ttl: 1_500, + now, + }) + + expect(picks).toEqual(["d", "c"]) + }) +}) + +describe("loadRootSessionsWithFallback", () => { + test("uses limited roots query when supported", async () => { + const calls: Array<{ directory: string; roots: true; limit?: number }> = [] + let fallback = 0 + + const result = await loadRootSessionsWithFallback({ + directory: "dir", + limit: 10, + list: async (query) => { + calls.push(query) + return { data: [] } + }, + onFallback: () => { + fallback += 1 + }, + }) + + expect(result.data).toEqual([]) + expect(result.limited).toBe(true) + expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }]) + expect(fallback).toBe(0) + }) + + test("falls back to full roots query on limited-query failure", async () => { + const calls: Array<{ directory: string; roots: true; limit?: number }> = [] + let fallback = 0 + + const result = await loadRootSessionsWithFallback({ + directory: "dir", + limit: 25, + list: async (query) => { + calls.push(query) + if (query.limit) throw new Error("unsupported") + return { data: [] } + }, + onFallback: () => { + fallback += 1 + }, + }) + + expect(result.data).toEqual([]) + expect(result.limited).toBe(false) + expect(calls).toEqual([ + { directory: "dir", roots: true, limit: 25 }, + { directory: "dir", roots: true }, + ]) + expect(fallback).toBe(1) + }) +}) + +describe("estimateRootSessionTotal", () => { + test("keeps exact total for full fetches", () => { + expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42) + }) + + test("marks has-more for full-limit limited fetches", () => { + expect(estimateRootSessionTotal({ count: 10, limit: 10, limited: true })).toBe(11) + }) + + test("keeps exact total when limited fetch is under limit", () => { + expect(estimateRootSessionTotal({ count: 9, limit: 10, limited: true })).toBe(9) + }) +}) + +describe("canDisposeDirectory", () => { + test("rejects pinned or inflight directories", () => { + expect( + canDisposeDirectory({ + directory: "dir", + hasStore: true, + pinned: true, + booting: false, + loadingSessions: false, + }), + ).toBe(false) + expect( + canDisposeDirectory({ + directory: "dir", + hasStore: true, + pinned: false, + booting: true, + loadingSessions: false, + }), + ).toBe(false) + expect( + canDisposeDirectory({ + directory: "dir", + hasStore: true, + pinned: false, + booting: false, + loadingSessions: true, + }), + ).toBe(false) + }) + + test("accepts idle unpinned directory store", () => { + expect( + canDisposeDirectory({ + directory: "dir", + hasStore: true, + pinned: false, + booting: false, + loadingSessions: false, + }), + ).toBe(true) + }) +}) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 0facbdfff..0d6b5dfff 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -27,6 +27,7 @@ import type { InitError } from "../pages/error" import { batch, createContext, + createRoot, createEffect, untrack, getOwner, @@ -131,6 +132,96 @@ function normalizeProviderList(input: ProviderListResponse): ProviderListRespons } } +const MAX_DIR_STORES = 30 +const DIR_IDLE_TTL_MS = 20 * 60 * 1000 + +type DirState = { + lastAccessAt: number +} + +type EvictPlan = { + stores: string[] + state: Map + pins: Set + max: number + ttl: number + now: number +} + +export function pickDirectoriesToEvict(input: EvictPlan) { + const overflow = Math.max(0, input.stores.length - input.max) + let pendingOverflow = overflow + const sorted = input.stores + .filter((dir) => !input.pins.has(dir)) + .slice() + .sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0)) + + const output: string[] = [] + for (const dir of sorted) { + const last = input.state.get(dir)?.lastAccessAt ?? 0 + const idle = input.now - last >= input.ttl + if (!idle && pendingOverflow <= 0) continue + output.push(dir) + if (pendingOverflow > 0) pendingOverflow -= 1 + } + return output +} + +type RootLoadArgs = { + directory: string + limit: number + list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }> + onFallback: () => void +} + +type RootLoadResult = { + data?: Session[] + limit: number + limited: boolean +} + +export async function loadRootSessionsWithFallback(input: RootLoadArgs) { + try { + const result = await input.list({ directory: input.directory, roots: true, limit: input.limit }) + return { + data: result.data, + limit: input.limit, + limited: true, + } satisfies RootLoadResult + } catch { + input.onFallback() + const result = await input.list({ directory: input.directory, roots: true }) + return { + data: result.data, + limit: input.limit, + limited: false, + } satisfies RootLoadResult + } +} + +export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) { + if (!input.limited) return input.count + if (input.count < input.limit) return input.count + return input.count + 1 +} + +type DisposeCheck = { + directory: string + hasStore: boolean + pinned: boolean + booting: boolean + loadingSessions: boolean +} + +export function canDisposeDirectory(input: DisposeCheck) { + if (!input.directory) return false + if (!input.hasStore) return false + if (input.pinned) return false + if (input.booting) return false + if (input.loadingSessions) return false + return true +} + function createGlobalSync() { const globalSDK = useGlobalSDK() const platform = usePlatform() @@ -140,8 +231,133 @@ function createGlobalSync() { const vcsCache = new Map() const metaCache = new Map() const iconCache = new Map() + const lifecycle = new Map() + const pins = new Map() + const ownerPins = new WeakMap>() + const disposers = new Map void>() + const stats = { + evictions: 0, + loadSessionsFallback: 0, + } const sdkCache = new Map>() + + const updateStats = () => { + if (!import.meta.env.DEV) return + ;( + globalThis as { + __OPENCODE_GLOBAL_SYNC_STATS?: { + activeDirectoryStores: number + evictions: number + loadSessionsFullFetchFallback: number + } + } + ).__OPENCODE_GLOBAL_SYNC_STATS = { + activeDirectoryStores: Object.keys(children).length, + evictions: stats.evictions, + loadSessionsFullFetchFallback: stats.loadSessionsFallback, + } + } + + const mark = (directory: string) => { + if (!directory) return + lifecycle.set(directory, { lastAccessAt: Date.now() }) + runEviction() + } + + const pin = (directory: string) => { + if (!directory) return + pins.set(directory, (pins.get(directory) ?? 0) + 1) + mark(directory) + } + + const unpin = (directory: string) => { + if (!directory) return + const next = (pins.get(directory) ?? 0) - 1 + if (next > 0) { + pins.set(directory, next) + return + } + pins.delete(directory) + runEviction() + } + + const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0 + + const pinForOwner = (directory: string) => { + const current = getOwner() + if (!current) return + if (current === owner) return + const key = current as object + const set = ownerPins.get(key) + if (set?.has(directory)) return + if (set) set.add(directory) + else ownerPins.set(key, new Set([directory])) + pin(directory) + onCleanup(() => { + const set = ownerPins.get(key) + if (set) { + set.delete(directory) + if (set.size === 0) ownerPins.delete(key) + } + unpin(directory) + }) + } + + function disposeDirectory(directory: string) { + if ( + !canDisposeDirectory({ + directory, + hasStore: !!children[directory], + pinned: pinned(directory), + booting: booting.has(directory), + loadingSessions: sessionLoads.has(directory), + }) + ) { + return false + } + + queued.delete(directory) + sessionMeta.delete(directory) + sdkCache.delete(directory) + vcsCache.delete(directory) + metaCache.delete(directory) + iconCache.delete(directory) + lifecycle.delete(directory) + + const dispose = disposers.get(directory) + if (dispose) { + dispose() + disposers.delete(directory) + } + + delete children[directory] + updateStats() + return true + } + + function runEviction() { + const stores = Object.keys(children) + if (stores.length === 0) return + const list = pickDirectoriesToEvict({ + stores, + state: lifecycle, + pins: new Set(stores.filter(pinned)), + max: MAX_DIR_STORES, + ttl: DIR_IDLE_TTL_MS, + now: Date.now(), + }) + + if (list.length === 0) return + let changed = false + for (const directory of list) { + if (!disposeDirectory(directory)) continue + stats.evictions += 1 + changed = true + } + if (changed) updateStats() + } + const sdkFor = (directory: string) => { const cached = sdkCache.get(directory) if (cached) return cached @@ -379,52 +595,56 @@ function createGlobalSync() { if (!icon) throw new Error("Failed to create persisted project icon") iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] }) - const init = () => { - const child = createStore({ - project: "", - projectMeta: meta[0].value, - icon: icon[0].value, - provider: { all: [], connected: [], default: {} }, - config: {}, - path: { state: "", config: "", worktree: "", directory: "", home: "" }, - status: "loading" as const, - agent: [], - command: [], - session: [], - sessionTotal: 0, - session_status: {}, - session_diff: {}, - todo: {}, - permission: {}, - question: {}, - mcp: {}, - lsp: [], - vcs: vcsStore.value, - limit: 5, - message: {}, - part: {}, - }) + const init = () => + createRoot((dispose) => { + const child = createStore({ + project: "", + projectMeta: meta[0].value, + icon: icon[0].value, + provider: { all: [], connected: [], default: {} }, + config: {}, + path: { state: "", config: "", worktree: "", directory: "", home: "" }, + status: "loading" as const, + agent: [], + command: [], + session: [], + sessionTotal: 0, + session_status: {}, + session_diff: {}, + todo: {}, + permission: {}, + question: {}, + mcp: {}, + lsp: [], + vcs: vcsStore.value, + limit: 5, + message: {}, + part: {}, + }) - children[directory] = child + children[directory] = child + disposers.set(directory, dispose) - createEffect(() => { - if (!vcsReady()) return - const cached = vcsStore.value - if (!cached?.branch) return - child[1]("vcs", (value) => value ?? cached) - }) + createEffect(() => { + if (!vcsReady()) return + const cached = vcsStore.value + if (!cached?.branch) return + child[1]("vcs", (value) => value ?? cached) + }) - createEffect(() => { - child[1]("projectMeta", meta[0].value) - }) + createEffect(() => { + child[1]("projectMeta", meta[0].value) + }) - createEffect(() => { - child[1]("icon", icon[0].value) + createEffect(() => { + child[1]("icon", icon[0].value) + }) }) - } runWithOwner(owner, init) + updateStats() } + mark(directory) const childStore = children[directory] if (!childStore) throw new Error("Failed to create store") return childStore @@ -432,6 +652,7 @@ function createGlobalSync() { function child(directory: string, options: ChildOptions = {}) { const childStore = ensureChild(directory) + pinForOwner(directory) const shouldBootstrap = options.bootstrap ?? true if (shouldBootstrap && childStore[0].status === "loading") { void bootstrapInstance(directory) @@ -443,6 +664,7 @@ function createGlobalSync() { const pending = sessionLoads.get(directory) if (pending) return pending + pin(directory) const [store, setStore] = child(directory, { bootstrap: false }) const meta = sessionMeta.get(directory) if (meta && meta.limit >= store.limit) { @@ -450,11 +672,20 @@ function createGlobalSync() { if (next.length !== store.session.length) { setStore("session", reconcile(next, { key: "id" })) } + unpin(directory) return } - const promise = globalSDK.client.session - .list({ directory, roots: true }) + const limit = Math.max(store.limit + sessionRecentLimit, sessionRecentLimit) + const promise = loadRootSessionsWithFallback({ + directory, + limit, + list: (query) => globalSDK.client.session.list(query), + onFallback: () => { + stats.loadSessionsFallback += 1 + updateStats() + }, + }) .then((x) => { const nonArchived = (x.data ?? []) .filter((s) => !!s?.id) @@ -468,8 +699,13 @@ function createGlobalSync() { const children = store.session.filter((s) => !!s.parentID) const sessions = trimSessions([...nonArchived, ...children], { limit, permission: store.permission }) - // Store total session count (used for "load more" pagination) - setStore("sessionTotal", nonArchived.length) + // Store root session total for "load more" pagination. + // For limited root queries, preserve has-more behavior by treating + // full-limit responses as "potentially more". + setStore( + "sessionTotal", + estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }), + ) setStore("session", reconcile(sessions, { key: "id" })) sessionMeta.set(directory, { limit }) }) @@ -482,6 +718,7 @@ function createGlobalSync() { sessionLoads.set(directory, promise) promise.finally(() => { sessionLoads.delete(directory) + unpin(directory) }) return promise } @@ -491,6 +728,7 @@ function createGlobalSync() { const pending = booting.get(directory) if (pending) return pending + pin(directory) const promise = (async () => { const [store, setStore] = ensureChild(directory) const cache = vcsCache.get(directory) @@ -605,6 +843,7 @@ function createGlobalSync() { booting.set(directory, promise) promise.finally(() => { booting.delete(directory) + unpin(directory) }) return promise } @@ -670,6 +909,7 @@ function createGlobalSync() { const existing = children[directory] if (!existing) return + mark(directory) const [store, setStore] = existing @@ -955,6 +1195,11 @@ function createGlobalSync() { if (!timer) return clearTimeout(timer) }) + onCleanup(() => { + for (const directory of Object.keys(children)) { + disposeDirectory(directory) + } + }) async function bootstrap() { const health = await globalSDK.client.global diff --git a/packages/app/src/context/notification-index.ts b/packages/app/src/context/notification-index.ts new file mode 100644 index 000000000..0b316e7ec --- /dev/null +++ b/packages/app/src/context/notification-index.ts @@ -0,0 +1,66 @@ +type NotificationIndexItem = { + directory?: string + session?: string + viewed: boolean + type: string +} + +export function buildNotificationIndex(list: T[]) { + const sessionAll = new Map() + const sessionUnseen = new Map() + const sessionUnseenCount = new Map() + const sessionUnseenHasError = new Map() + const projectAll = new Map() + const projectUnseen = new Map() + const projectUnseenCount = new Map() + const projectUnseenHasError = new Map() + + for (const notification of list) { + const session = notification.session + if (session) { + const all = sessionAll.get(session) + if (all) all.push(notification) + else sessionAll.set(session, [notification]) + + if (!notification.viewed) { + const unseen = sessionUnseen.get(session) + if (unseen) unseen.push(notification) + else sessionUnseen.set(session, [notification]) + + sessionUnseenCount.set(session, (sessionUnseenCount.get(session) ?? 0) + 1) + if (notification.type === "error") sessionUnseenHasError.set(session, true) + } + } + + const directory = notification.directory + if (directory) { + const all = projectAll.get(directory) + if (all) all.push(notification) + else projectAll.set(directory, [notification]) + + if (!notification.viewed) { + const unseen = projectUnseen.get(directory) + if (unseen) unseen.push(notification) + else projectUnseen.set(directory, [notification]) + + projectUnseenCount.set(directory, (projectUnseenCount.get(directory) ?? 0) + 1) + if (notification.type === "error") projectUnseenHasError.set(directory, true) + } + } + } + + return { + session: { + all: sessionAll, + unseen: sessionUnseen, + unseenCount: sessionUnseenCount, + unseenHasError: sessionUnseenHasError, + }, + project: { + all: projectAll, + unseen: projectUnseen, + unseenCount: projectUnseenCount, + unseenHasError: projectUnseenHasError, + }, + } +} diff --git a/packages/app/src/context/notification.test.ts b/packages/app/src/context/notification.test.ts new file mode 100644 index 000000000..44bacb704 --- /dev/null +++ b/packages/app/src/context/notification.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test" +import { buildNotificationIndex } from "./notification-index" + +type Notification = { + type: "turn-complete" | "error" + session: string + directory: string + viewed: boolean + time: number +} + +const turn = (session: string, directory: string, viewed = false): Notification => ({ + type: "turn-complete", + session, + directory, + viewed, + time: 1, +}) + +const error = (session: string, directory: string, viewed = false): Notification => ({ + type: "error", + session, + directory, + viewed, + time: 1, +}) + +describe("buildNotificationIndex", () => { + test("builds unseen counts and unseen error flags", () => { + const list = [ + turn("s1", "d1", false), + error("s1", "d1", false), + turn("s1", "d1", true), + turn("s2", "d1", false), + error("s3", "d2", true), + ] + + const index = buildNotificationIndex(list) + + expect(index.session.all.get("s1")?.length).toBe(3) + expect(index.session.unseen.get("s1")?.length).toBe(2) + expect(index.session.unseenCount.get("s1")).toBe(2) + expect(index.session.unseenHasError.get("s1")).toBe(true) + + expect(index.session.unseenCount.get("s2")).toBe(1) + expect(index.session.unseenHasError.get("s2") ?? false).toBe(false) + expect(index.session.unseenCount.get("s3") ?? 0).toBe(0) + expect(index.session.unseenHasError.get("s3") ?? false).toBe(false) + + expect(index.project.unseenCount.get("d1")).toBe(3) + expect(index.project.unseenHasError.get("d1")).toBe(true) + expect(index.project.unseenCount.get("d2") ?? 0).toBe(0) + expect(index.project.unseenHasError.get("d2") ?? false).toBe(false) + }) + + test("updates selectors after viewed transitions", () => { + const list = [turn("s1", "d1", false), error("s1", "d1", false), turn("s2", "d1", false)] + const next = list.map((item) => (item.session === "s1" ? { ...item, viewed: true } : item)) + + const before = buildNotificationIndex(list) + const after = buildNotificationIndex(next) + + expect(before.session.unseenCount.get("s1")).toBe(2) + expect(before.session.unseenHasError.get("s1")).toBe(true) + expect(before.project.unseenCount.get("d1")).toBe(3) + expect(before.project.unseenHasError.get("d1")).toBe(true) + + expect(after.session.unseenCount.get("s1") ?? 0).toBe(0) + expect(after.session.unseenHasError.get("s1") ?? false).toBe(false) + expect(after.project.unseenCount.get("d1")).toBe(1) + expect(after.project.unseenHasError.get("d1") ?? false).toBe(false) + }) +}) diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 6c110cae1..b876bd862 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -13,6 +13,7 @@ import { decode64 } from "@/utils/base64" import { EventSessionError } from "@opencode-ai/sdk/v2" import { Persist, persisted } from "@/utils/persist" import { playSound, soundSrc } from "@/utils/sound" +import { buildNotificationIndex } from "./notification-index" type NotificationBase = { directory?: string @@ -81,49 +82,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi setStore("list", (list) => pruneNotifications([...list, notification])) } - const index = createMemo(() => { - const sessionAll = new Map() - const sessionUnseen = new Map() - const projectAll = new Map() - const projectUnseen = new Map() - - for (const notification of store.list) { - const session = notification.session - if (session) { - const list = sessionAll.get(session) - if (list) list.push(notification) - else sessionAll.set(session, [notification]) - if (!notification.viewed) { - const unseen = sessionUnseen.get(session) - if (unseen) unseen.push(notification) - else sessionUnseen.set(session, [notification]) - } - } - - const directory = notification.directory - if (directory) { - const list = projectAll.get(directory) - if (list) list.push(notification) - else projectAll.set(directory, [notification]) - if (!notification.viewed) { - const unseen = projectUnseen.get(directory) - if (unseen) unseen.push(notification) - else projectUnseen.set(directory, [notification]) - } - } - } - - return { - session: { - all: sessionAll, - unseen: sessionUnseen, - }, - project: { - all: projectAll, - unseen: projectUnseen, - }, - } - }) + const index = createMemo(() => buildNotificationIndex(store.list)) const unsub = globalSDK.event.listen((e) => { const event = e.details @@ -208,6 +167,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi unseen(session: string) { return index().session.unseen.get(session) ?? empty }, + unseenCount(session: string) { + return index().session.unseenCount.get(session) ?? 0 + }, + unseenHasError(session: string) { + return index().session.unseenHasError.get(session) ?? false + }, markViewed(session: string) { setStore("list", (n) => n.session === session, "viewed", true) }, @@ -219,6 +184,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi unseen(directory: string) { return index().project.unseen.get(directory) ?? empty }, + unseenCount(directory: string) { + return index().project.unseenCount.get(directory) ?? 0 + }, + unseenHasError(directory: string) { + return index().project.unseenHasError.get(directory) ?? false + }, markViewed(directory: string) { setStore("list", (n) => n.directory === directory, "viewed", true) }, diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index c538b920c..3b66258c9 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -76,6 +76,44 @@ import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" +const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" + +const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "") + +function sortSessions(now: number) { + const oneMinuteAgo = now - 60 * 1000 + return (a: Session, b: Session) => { + const aUpdated = a.time.updated ?? a.time.created + const bUpdated = b.time.updated ?? b.time.created + const aRecent = aUpdated > oneMinuteAgo + const bRecent = bUpdated > oneMinuteAgo + if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 + if (aRecent && !bRecent) return -1 + if (!aRecent && bRecent) return 1 + return bUpdated - aUpdated + } +} + +const isRootVisibleSession = (session: Session, directory: string) => + workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived + +const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => + store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).toSorted(sortSessions(now)) + +const childMapByParent = (sessions: Session[]) => { + const map = new Map() + for (const session of sessions) { + if (!session.parentID) continue + const existing = map.get(session.parentID) + if (existing) { + existing.push(session.id) + continue + } + map.set(session.parentID, [session.id]) + } + return map +} + export default function Layout(props: ParentProps) { const [store, setStore, , ready] = persisted( Persist.global("layout.page", ["layout.page.v1"]), @@ -119,6 +157,7 @@ export default function Layout(props: ParentProps) { dark: "theme.scheme.dark", } const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme]) + const currentDir = createMemo(() => decode64(params.dir) ?? "") const [state, setState] = createStore({ autoselect: !initialDirectory, @@ -143,8 +182,6 @@ export default function Layout(props: ParentProps) { }) } const isBusy = (directory: string) => state.busyWorkspaces.has(workspaceKey(directory)) - const editorRef = { current: undefined as HTMLInputElement | undefined } - const navLeave = { current: undefined as number | undefined } const aim = createAim({ @@ -289,7 +326,6 @@ export default function Layout(props: ParentProps) { > { - editorRef.current = el requestAnimationFrame(() => el.focus()) }} value={editorValue()} @@ -466,10 +502,9 @@ export default function Layout(props: ParentProps) { } } - const currentDir = decode64(params.dir) const currentSession = params.id - if (directory === currentDir && props.sessionID === currentSession) return - if (directory === currentDir && session?.parentID === currentSession) return + if (directory === currentDir() && props.sessionID === currentSession) return + if (directory === currentDir() && session?.parentID === currentSession) return const existingToastId = toastBySession.get(sessionKey) if (existingToastId !== undefined) toaster.dismiss(existingToastId) @@ -495,20 +530,19 @@ export default function Layout(props: ParentProps) { onCleanup(unsub) createEffect(() => { - const currentDir = decode64(params.dir) const currentSession = params.id - if (!currentDir || !currentSession) return - const sessionKey = `${currentDir}:${currentSession}` + if (!currentDir() || !currentSession) return + const sessionKey = `${currentDir()}:${currentSession}` const toastId = toastBySession.get(sessionKey) if (toastId !== undefined) { toaster.dismiss(toastId) toastBySession.delete(sessionKey) alertedAtBySession.delete(sessionKey) } - const [store] = globalSync.child(currentDir, { bootstrap: false }) + const [store] = globalSync.child(currentDir(), { bootstrap: false }) const childSessions = store.session.filter((s) => s.parentID === currentSession) for (const child of childSessions) { - const childKey = `${currentDir}:${child.id}` + const childKey = `${currentDir()}:${child.id}` const childToastId = toastBySession.get(childKey) if (childToastId !== undefined) { toaster.dismiss(childToastId) @@ -519,20 +553,6 @@ export default function Layout(props: ParentProps) { }) }) - function sortSessions(now: number) { - const oneMinuteAgo = now - 60 * 1000 - return (a: Session, b: Session) => { - const aUpdated = a.time.updated ?? a.time.created - const bUpdated = b.time.updated ?? b.time.created - const aRecent = aUpdated > oneMinuteAgo - const bRecent = bUpdated > oneMinuteAgo - if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 - if (aRecent && !bRecent) return -1 - if (!aRecent && bRecent) return 1 - return bUpdated - aUpdated - } - } - function scrollToSession(sessionId: string, sessionKey: string) { if (!scrollContainerRef) return if (state.scrollSessionKey === sessionKey) return @@ -549,7 +569,7 @@ export default function Layout(props: ParentProps) { } const currentProject = createMemo(() => { - const directory = decode64(params.dir) + const directory = currentDir() if (!directory) return const projects = layout.projects.list() @@ -614,8 +634,6 @@ export default function Layout(props: ParentProps) { ), ) - const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "") - const workspaceName = (directory: string, projectId?: string, branch?: string) => { const key = workspaceKey(directory) const direct = store.workspaceName[key] ?? store.workspaceName[directory] @@ -687,29 +705,23 @@ export default function Layout(props: ParentProps) { const currentSessions = createMemo(() => { const project = currentProject() if (!project) return [] as Session[] - const compare = sortSessions(Date.now()) + const now = Date.now() if (workspaceSetting()) { const dirs = workspaceIds(project) - const activeDir = decode64(params.dir) ?? "" + const activeDir = currentDir() const result: Session[] = [] for (const dir of dirs) { const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree const active = dir === activeDir if (!expanded && !active) continue const [dirStore] = globalSync.child(dir, { bootstrap: true }) - const dirSessions = dirStore.session - .filter((session) => session.directory === dirStore.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(compare) + const dirSessions = sortedRootSessions(dirStore, now) result.push(...dirSessions) } return result } const [projectStore] = globalSync.child(project.worktree) - return projectStore.session - .filter((session) => session.directory === projectStore.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(compare) + return sortedRootSessions(projectStore, now) }) type PrefetchQueue = { @@ -951,7 +963,7 @@ export default function Layout(props: ParentProps) { const sessions = currentSessions() if (sessions.length === 0) return - const hasUnseen = sessions.some((session) => notification.session.unseen(session.id).length > 0) + const hasUnseen = sessions.some((session) => notification.session.unseenCount(session.id) > 0) if (!hasUnseen) return const activeIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1 @@ -961,7 +973,7 @@ export default function Layout(props: ParentProps) { const index = offset > 0 ? (start + i) % sessions.length : (start - i + sessions.length) % sessions.length const session = sessions[index] if (!session) continue - if (notification.session.unseen(session.id).length === 0) continue + if (notification.session.unseenCount(session.id) === 0) continue prefetchSession(session, "high") @@ -1019,7 +1031,7 @@ export default function Layout(props: ParentProps) { } } - command.register(() => { + command.register("layout", () => { const commands: CommandOption[] = [ { id: "sidebar.toggle", @@ -1093,6 +1105,18 @@ export default function Layout(props: ParentProps) { if (session) archiveSession(session) }, }, + { + id: "workspace.new", + title: language.t("workspace.new"), + category: language.t("command.category.workspace"), + keybind: "mod+shift+w", + disabled: !workspaceSetting(), + onSelect: () => { + const project = currentProject() + if (!project) return + return createWorkspace(project) + }, + }, { id: "workspace.toggle", title: language.t("command.workspace.toggle"), @@ -1344,7 +1368,7 @@ export default function Layout(props: ParentProps) { layout.projects.close(directory) layout.projects.open(root) - if (params.dir && decode64(params.dir) === directory) { + if (params.dir && currentDir() === directory) { navigateToProject(root) } } @@ -1584,7 +1608,7 @@ export default function Layout(props: ParentProps) { if (!project) return if (workspaceSetting()) { - const activeDir = decode64(params.dir) ?? "" + const activeDir = currentDir() const dirs = [project.worktree, ...(project.sandboxes ?? [])] for (const directory of dirs) { const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree @@ -1634,7 +1658,7 @@ export default function Layout(props: ParentProps) { const local = project.worktree const dirs = [local, ...(project.sandboxes ?? [])] const active = currentProject() - const directory = active?.worktree === project.worktree ? decode64(params.dir) : undefined + const directory = active?.worktree === project.worktree ? currentDir() : undefined const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false @@ -1688,23 +1712,25 @@ export default function Layout(props: ParentProps) { const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { const notification = useNotification() - const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) - const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const unseenCount = createMemo(() => notification.project.unseenCount(props.project.worktree)) + const hasError = createMemo(() => notification.project.unseenHasError(props.project.worktree)) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" - return (
0 && props.notify }} + classList={{ "badge-mask": unseenCount() > 0 && props.notify }} />
- 0 && props.notify}> + 0 && props.notify}>
+ children: Map }): JSX.Element => { const notification = useNotification() - const notifications = createMemo(() => notification.session.unseen(props.session.id)) - const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id)) + const hasError = createMemo(() => notification.session.unseenHasError(props.session.id)) const [sessionStore] = globalSync.child(props.session.directory) const hasPermissions = createMemo(() => { const permissions = sessionStore.permission?.[props.session.id] ?? [] if (permissions.length > 0) return true - const childIDs = props.children?.get(props.session.id) - if (childIDs) { - for (const id of childIDs) { - const childPermissions = sessionStore.permission?.[id] ?? [] - if (childPermissions.length > 0) return true - } - return false - } - - const childSessions = sessionStore.session.filter((s) => s.parentID === props.session.id) - for (const child of childSessions) { - const childPermissions = sessionStore.permission?.[child.id] ?? [] + for (const id of props.children.get(props.session.id) ?? []) { + const childPermissions = sessionStore.permission?.[id] ?? [] if (childPermissions.length > 0) return true } return false @@ -1758,10 +1774,13 @@ export default function Layout(props: ParentProps) { const tint = createMemo(() => { const messages = sessionStore.message[props.session.id] if (!messages) return undefined - const user = messages - .slice() - .reverse() - .find((m) => m.role === "user") + let user: Message | undefined + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message.role !== "user") continue + user = message + break + } if (!user?.agent) return undefined const agent = sessionStore.agent.find((a) => a.name === user.agent) @@ -1828,7 +1847,7 @@ export default function Layout(props: ParentProps) {
- 0}> + 0}>
@@ -2023,30 +2042,10 @@ export default function Layout(props: ParentProps) { pendingRename: false, }) const slug = createMemo(() => base64Encode(props.directory)) - const sessions = createMemo(() => - workspaceStore.session - .filter((session) => session.directory === workspaceStore.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(sortSessions(Date.now())), - ) - const children = createMemo(() => { - const map = new Map() - for (const session of workspaceStore.session) { - if (!session.parentID) continue - const existing = map.get(session.parentID) - if (existing) { - existing.push(session.id) - continue - } - map.set(session.parentID, [session.id]) - } - return map - }) + const sessions = createMemo(() => sortedRootSessions(workspaceStore, Date.now())) + const children = createMemo(() => childMapByParent(workspaceStore.session)) const local = createMemo(() => props.directory === props.project.worktree) - const active = createMemo(() => { - const current = decode64(params.dir) ?? "" - return current === props.directory - }) + const active = createMemo(() => currentDir() === props.directory) const workspaceValue = createMemo(() => { const branch = workspaceStore.vcs?.branch const name = branch ?? getFilename(props.directory) @@ -2257,7 +2256,7 @@ export default function Layout(props: ParentProps) { const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { const sortable = createSortable(props.project.worktree) const selected = createMemo(() => { - const current = decode64(params.dir) ?? "" + const current = currentDir() return props.project.worktree === current || props.project.sandboxes?.includes(current) }) @@ -2288,25 +2287,16 @@ export default function Layout(props: ParentProps) { return `${kind} : ${name}` } - const sessions = (directory: string) => { + const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) + const projectSessions = createMemo(() => sortedRootSessions(projectStore(), Date.now()).slice(0, 2)) + const projectChildren = createMemo(() => childMapByParent(projectStore().session)) + const workspaceSessions = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) - const root = workspaceKey(directory) - return data.session - .filter((session) => workspaceKey(session.directory) === root) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(sortSessions(Date.now())) - .slice(0, 2) + return sortedRootSessions(data, Date.now()).slice(0, 2) } - - const projectSessions = () => { - const directory = props.project.worktree + const workspaceChildren = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) - const root = workspaceKey(directory) - return data.session - .filter((session) => workspaceKey(session.directory) === root) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(sortSessions(Date.now())) - .slice(0, 2) + return childMapByParent(data.session) } const projectName = () => props.project.name || getFilename(props.project.worktree) @@ -2435,33 +2425,39 @@ export default function Layout(props: ParentProps) { dense mobile={props.mobile} popover={false} + children={projectChildren()} /> )} } > - {(directory) => ( -
-
-
- + {(directory) => { + const sessions = createMemo(() => workspaceSessions(directory)) + const children = createMemo(() => workspaceChildren(directory)) + return ( +
+
+
+ +
+ {label(directory)}
- {label(directory)} + + {(session) => ( + + )} +
- - {(session) => ( - - )} - -
- )} + ) + }}
@@ -2494,27 +2490,8 @@ export default function Layout(props: ParentProps) { return { store, setStore } }) const slug = createMemo(() => base64Encode(props.project.worktree)) - const sessions = createMemo(() => { - const store = workspace().store - return store.session - .filter((session) => session.directory === store.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(sortSessions(Date.now())) - }) - const children = createMemo(() => { - const store = workspace().store - const map = new Map() - for (const session of store.session) { - if (!session.parentID) continue - const existing = map.get(session.parentID) - if (existing) { - existing.push(session.id) - continue - } - map.set(session.parentID, [session.id]) - } - return map - }) + const sessions = createMemo(() => sortedRootSessions(workspace().store, Date.now())) + const children = createMemo(() => childMapByParent(workspace().store.session)) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) const loading = createMemo(() => !booted() && sessions().length === 0) const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length) @@ -2819,21 +2796,6 @@ export default function Layout(props: ParentProps) { const SidebarContent = (sidebarProps: { mobile?: boolean }) => { const expanded = () => sidebarProps.mobile || layout.sidebar.opened() - command.register(() => [ - { - id: "workspace.new", - title: language.t("workspace.new"), - category: language.t("command.category.workspace"), - keybind: "mod+shift+w", - disabled: !workspaceSetting(), - onSelect: () => { - const project = currentProject() - if (!project) return - return createWorkspace(project) - }, - }, - ]) - return (
From 6c0dce67110d811cc5df918cad1c3b50e6b308ce Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 6 Feb 2026 12:13:24 +0000 Subject: [PATCH 25/54] fix(desktop): support desktop titlebar double-click maximize (#12459) --- packages/app/src/components/titlebar.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index d8735410a..2e22dc633 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -83,7 +83,14 @@ export function Titlebar() { const tauri = ( window as unknown as { - __TAURI__?: { window?: { getCurrentWindow?: () => { startDragging?: () => Promise } } } + __TAURI__?: { + window?: { + getCurrentWindow?: () => { + startDragging?: () => Promise + toggleMaximize?: () => Promise + } + } + } } ).__TAURI__ if (!tauri?.window?.getCurrentWindow) return @@ -129,10 +136,23 @@ export function Titlebar() { void win.startDragging().catch(() => undefined) } + const maximize = (e: MouseEvent) => { + if (platform.platform !== "desktop") return + if (interactive(e.target)) return + if (e.target instanceof Element && e.target.closest("[data-tauri-decorum-tb]")) return + + const win = getWin() + if (!win?.toggleMaximize) return + + e.preventDefault() + void win.toggleMaximize().catch(() => undefined) + } + return (
Date: Fri, 6 Feb 2026 07:18:45 -0500 Subject: [PATCH 26/54] fix(ui): add Windows File Explorer icon for session header (#12386) --- packages/app/src/components/session/session-header.tsx | 2 +- packages/ui/src/assets/icons/app/file-explorer.svg | 1 + packages/ui/src/components/app-icon.tsx | 2 ++ packages/ui/src/components/app-icons/types.ts | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/assets/icons/app/file-explorer.svg diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 43057d63b..61bc26e35 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -102,7 +102,7 @@ export function SessionHeader() { { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, - { id: "finder", label: "File Explorer", icon: "finder" }, + { id: "finder", label: "File Explorer", icon: "file-explorer" }, { id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" }, ] as const } diff --git a/packages/ui/src/assets/icons/app/file-explorer.svg b/packages/ui/src/assets/icons/app/file-explorer.svg new file mode 100644 index 000000000..316cbab35 --- /dev/null +++ b/packages/ui/src/assets/icons/app/file-explorer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/components/app-icon.tsx b/packages/ui/src/components/app-icon.tsx index f58b5d38c..c698f32c6 100644 --- a/packages/ui/src/components/app-icon.tsx +++ b/packages/ui/src/components/app-icon.tsx @@ -5,6 +5,7 @@ import type { IconName } from "./app-icons/types" import androidStudio from "../assets/icons/app/android-studio.svg" import antigravity from "../assets/icons/app/antigravity.svg" import cursor from "../assets/icons/app/cursor.svg" +import fileExplorer from "../assets/icons/app/file-explorer.svg" import finder from "../assets/icons/app/finder.png" import ghostty from "../assets/icons/app/ghostty.svg" import iterm2 from "../assets/icons/app/iterm2.svg" @@ -19,6 +20,7 @@ const icons = { vscode, cursor, zed, + "file-explorer": fileExplorer, finder, terminal, iterm2, diff --git a/packages/ui/src/components/app-icons/types.ts b/packages/ui/src/components/app-icons/types.ts index 81964b8da..0ad9f83d1 100644 --- a/packages/ui/src/components/app-icons/types.ts +++ b/packages/ui/src/components/app-icons/types.ts @@ -4,6 +4,7 @@ export const iconNames = [ "vscode", "cursor", "zed", + "file-explorer", "finder", "terminal", "iterm2", From ac88c6b637e3d4f2b1022abd06f1a052d644deca Mon Sep 17 00:00:00 2001 From: Alex Yaroshuk <34632190+alexyaroshuk@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:19:25 +0800 Subject: [PATCH 27/54] feat(app): session last updated time display in command pallete's search (#12376) --- packages/app/src/components/dialog-select-file.tsx | 10 ++++++++++ packages/app/src/utils/time.ts | 14 ++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 packages/app/src/utils/time.ts diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 36448dd3e..8e221577b 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -15,6 +15,7 @@ import { useLayout } from "@/context/layout" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { decode64 } from "@/utils/base64" +import { getRelativeTime } from "@/utils/time" type EntryType = "command" | "file" | "session" @@ -30,6 +31,7 @@ type Entry = { directory?: string sessionID?: string archived?: number + updated?: number } type DialogSelectFileMode = "all" | "files" @@ -120,6 +122,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil title: string description: string archived?: number + updated?: number }): Entry => ({ id: `session:${input.directory}:${input.id}`, type: "session", @@ -129,6 +132,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil directory: input.directory, sessionID: input.id, archived: input.archived, + updated: input.updated, }) const list = createMemo(() => allowed().map(commandItem)) @@ -214,6 +218,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil description, directory, archived: s.time?.archived, + updated: s.time?.updated, })), ) .catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[]) @@ -384,6 +389,11 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
+ + + {getRelativeTime(new Date(item.updated!).toISOString())} + +
diff --git a/packages/app/src/utils/time.ts b/packages/app/src/utils/time.ts new file mode 100644 index 000000000..ac709d86d --- /dev/null +++ b/packages/app/src/utils/time.ts @@ -0,0 +1,14 @@ +export function getRelativeTime(dateString: string): string { + const date = new Date(dateString) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSeconds = Math.floor(diffMs / 1000) + const diffMinutes = Math.floor(diffSeconds / 60) + const diffHours = Math.floor(diffMinutes / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffSeconds < 60) return "Just now" + if (diffMinutes < 60) return `${diffMinutes}m ago` + if (diffHours < 24) return `${diffHours}h ago` + return `${diffDays}d ago` +} From 400bc7973a11835f95c1f3326e6e0d24d6cba096 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:20:03 +0100 Subject: [PATCH 28/54] fix(desktop): update server removal logic to clear default server URL if removed (#12372) --- packages/app/src/components/dialog-select-server.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index e9e7646d5..94596fa87 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -369,6 +369,9 @@ export function DialogSelectServer() { async function handleRemove(url: string) { server.remove(url) + if (await platform.getDefaultServerUrl?.() === url) { + platform.setDefaultServerUrl?.(null) + } } return ( From 732a3dab8c5f4fbb2c767c4d57ca6c9e498b819e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 6 Feb 2026 12:20:53 +0000 Subject: [PATCH 29/54] chore: generate --- packages/app/src/components/dialog-select-server.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 94596fa87..3d8f5b846 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -369,7 +369,7 @@ export function DialogSelectServer() { async function handleRemove(url: string) { server.remove(url) - if (await platform.getDefaultServerUrl?.() === url) { + if ((await platform.getDefaultServerUrl?.()) === url) { platform.setDefaultServerUrl?.(null) } } From 0e73869580f18eb1f19d19f2fb6d7004dcbe4f32 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 6 Feb 2026 06:28:06 -0600 Subject: [PATCH 30/54] fix(www): z icon color --- packages/console/app/src/routes/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/console/app/src/routes/index.tsx b/packages/console/app/src/routes/index.tsx index 252dcbb97..4c5b50a37 100644 --- a/packages/console/app/src/routes/index.tsx +++ b/packages/console/app/src/routes/index.tsx @@ -810,7 +810,7 @@ export default function Home() {
From 0ec5f6608bdfea5be62dbbdc4c04a61de6d3e67c Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 6 Feb 2026 07:10:23 -0600 Subject: [PATCH 31/54] fix(app): hide 'open in app' button on narrow viewports --- .../src/components/session/session-header.tsx | 138 +++++++++--------- 1 file changed, 71 insertions(+), 67 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 61bc26e35..ec2a231e4 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -268,74 +268,78 @@ export function SessionHeader() {
- - - {language.t("session.header.open.copyPath")} - - } - > -
- - - -
-
+ class="rounded-sm h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none" + onClick={copyPath} + aria-label={language.t("session.header.open.copyPath")} + > + + + {language.t("session.header.open.copyPath")} + + + } + > +
+ + + + + + + {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 812597bb8b101896a8988493d37261ff851ae502 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:54:51 -0600 Subject: [PATCH 32/54] feat(web): i18n (#12471) --- packages/console/app/src/app.tsx | 20 +- .../app/src/component/email-signup.tsx | 14 +- packages/console/app/src/component/footer.tsx | 16 +- packages/console/app/src/component/header.tsx | 66 +- .../app/src/component/language-picker.css | 135 ++++ .../app/src/component/language-picker.tsx | 34 + packages/console/app/src/component/legal.tsx | 12 +- packages/console/app/src/context/i18n.tsx | 27 + packages/console/app/src/context/language.tsx | 68 ++ packages/console/app/src/entry-server.tsx | 39 +- packages/console/app/src/i18n/ar.ts | 531 ++++++++++++++++ packages/console/app/src/i18n/br.ts | 466 ++++++++++++++ packages/console/app/src/i18n/da.ts | 463 ++++++++++++++ packages/console/app/src/i18n/de.ts | 468 ++++++++++++++ packages/console/app/src/i18n/en.ts | 591 ++++++++++++++++++ packages/console/app/src/i18n/es.ts | 465 ++++++++++++++ packages/console/app/src/i18n/fr.ts | 471 ++++++++++++++ packages/console/app/src/i18n/index.ts | 43 ++ packages/console/app/src/i18n/it.ts | 464 ++++++++++++++ packages/console/app/src/i18n/ja.ts | 502 +++++++++++++++ packages/console/app/src/i18n/ko.ts | 494 +++++++++++++++ packages/console/app/src/i18n/no.ts | 462 ++++++++++++++ packages/console/app/src/i18n/pl.ts | 466 ++++++++++++++ packages/console/app/src/i18n/ru.ts | 539 ++++++++++++++++ packages/console/app/src/i18n/th.ts | 541 ++++++++++++++++ packages/console/app/src/i18n/tr.ts | 467 ++++++++++++++ packages/console/app/src/i18n/zh.ts | 473 ++++++++++++++ packages/console/app/src/i18n/zht.ts | 473 ++++++++++++++ packages/console/app/src/lib/form-error.ts | 83 +++ packages/console/app/src/lib/language.ts | 175 ++++++ packages/console/app/src/routes/[...404].tsx | 14 +- .../console/app/src/routes/bench/[id].tsx | 74 ++- .../console/app/src/routes/bench/index.tsx | 12 +- packages/console/app/src/routes/black.tsx | 41 +- .../console/app/src/routes/black/common.tsx | 13 +- .../console/app/src/routes/black/index.tsx | 34 +- .../app/src/routes/black/subscribe/[plan].tsx | 80 ++- .../app/src/routes/black/workspace.tsx | 41 +- .../console/app/src/routes/brand/index.css | 1 + .../console/app/src/routes/brand/index.tsx | 12 +- .../app/src/routes/changelog/index.css | 1 + .../app/src/routes/changelog/index.tsx | 20 +- .../console/app/src/routes/docs/[...path].ts | 8 +- packages/console/app/src/routes/docs/index.ts | 8 +- .../console/app/src/routes/download/index.css | 1 + .../console/app/src/routes/download/index.tsx | 116 ++-- .../app/src/routes/enterprise/index.css | 1 + .../app/src/routes/enterprise/index.tsx | 61 +- packages/console/app/src/routes/index.css | 1 + packages/console/app/src/routes/index.tsx | 151 +++-- packages/console/app/src/routes/s/[id].ts | 8 +- .../console/app/src/routes/t/[...path].tsx | 8 +- packages/console/app/src/routes/temp.tsx | 45 +- packages/console/app/src/routes/user-menu.tsx | 4 +- .../app/src/routes/workspace-picker.tsx | 14 +- .../console/app/src/routes/workspace/[id].css | 29 + .../console/app/src/routes/workspace/[id].tsx | 28 +- .../[id]/billing/billing-section.tsx | 35 +- .../workspace/[id]/billing/black-section.tsx | 62 +- .../[id]/billing/monthly-limit-section.tsx | 40 +- .../[id]/billing/payment-section.tsx | 25 +- .../workspace/[id]/billing/reload-section.tsx | 46 +- .../routes/workspace/[id]/graph-section.tsx | 44 +- .../app/src/routes/workspace/[id]/index.tsx | 12 +- .../workspace/[id]/keys/key-section.tsx | 39 +- .../workspace/[id]/members/member-section.tsx | 109 ++-- .../workspace/[id]/members/role-dropdown.tsx | 6 +- .../routes/workspace/[id]/model-section.tsx | 16 +- .../workspace/[id]/new-user-section.tsx | 30 +- .../workspace/[id]/provider-section.tsx | 40 +- .../[id]/settings/settings-section.tsx | 27 +- .../routes/workspace/[id]/usage-section.tsx | 34 +- .../app/src/routes/workspace/common.tsx | 2 +- packages/console/app/src/routes/zen/index.css | 1 + packages/console/app/src/routes/zen/index.tsx | 132 ++-- 75 files changed, 9868 insertions(+), 726 deletions(-) create mode 100644 packages/console/app/src/component/language-picker.css create mode 100644 packages/console/app/src/component/language-picker.tsx create mode 100644 packages/console/app/src/context/i18n.tsx create mode 100644 packages/console/app/src/context/language.tsx create mode 100644 packages/console/app/src/i18n/ar.ts create mode 100644 packages/console/app/src/i18n/br.ts create mode 100644 packages/console/app/src/i18n/da.ts create mode 100644 packages/console/app/src/i18n/de.ts create mode 100644 packages/console/app/src/i18n/en.ts create mode 100644 packages/console/app/src/i18n/es.ts create mode 100644 packages/console/app/src/i18n/fr.ts create mode 100644 packages/console/app/src/i18n/index.ts create mode 100644 packages/console/app/src/i18n/it.ts create mode 100644 packages/console/app/src/i18n/ja.ts create mode 100644 packages/console/app/src/i18n/ko.ts create mode 100644 packages/console/app/src/i18n/no.ts create mode 100644 packages/console/app/src/i18n/pl.ts create mode 100644 packages/console/app/src/i18n/ru.ts create mode 100644 packages/console/app/src/i18n/th.ts create mode 100644 packages/console/app/src/i18n/tr.ts create mode 100644 packages/console/app/src/i18n/zh.ts create mode 100644 packages/console/app/src/i18n/zht.ts create mode 100644 packages/console/app/src/lib/form-error.ts create mode 100644 packages/console/app/src/lib/language.ts diff --git a/packages/console/app/src/app.tsx b/packages/console/app/src/app.tsx index cde2f0187..3d16a64ab 100644 --- a/packages/console/app/src/app.tsx +++ b/packages/console/app/src/app.tsx @@ -6,19 +6,25 @@ import { Favicon } from "@opencode-ai/ui/favicon" import { Font } from "@opencode-ai/ui/font" import "@ibm/plex/css/ibm-plex.css" import "./app.css" +import { LanguageProvider } from "~/context/language" +import { I18nProvider } from "~/context/i18n" export default function App() { return ( ( - - opencode - - - - {props.children} - + + + + opencode + + + + {props.children} + + + )} > diff --git a/packages/console/app/src/component/email-signup.tsx b/packages/console/app/src/component/email-signup.tsx index 65f81b5fc..bd33e9200 100644 --- a/packages/console/app/src/component/email-signup.tsx +++ b/packages/console/app/src/component/email-signup.tsx @@ -2,6 +2,7 @@ import { action, useSubmission } from "@solidjs/router" import dock from "../asset/lander/dock.png" import { Resource } from "@opencode-ai/console-resource" import { Show } from "solid-js" +import { useI18n } from "~/context/i18n" const emailSignup = action(async (formData: FormData) => { "use server" @@ -23,22 +24,21 @@ const emailSignup = action(async (formData: FormData) => { export function EmailSignup() { const submission = useSubmission(emailSignup) + const i18n = useI18n() return (
-

Be the first to know when we release new products

-

Join the waitlist for early access.

+

{i18n.t("email.title")}

+

{i18n.t("email.subtitle")}

- +
-
- Almost done, check your inbox and confirm your email address -
+
{i18n.t("email.success")}
{submission.error}
diff --git a/packages/console/app/src/component/footer.tsx b/packages/console/app/src/component/footer.tsx index 27f8ddd65..45dae87ec 100644 --- a/packages/console/app/src/component/footer.tsx +++ b/packages/console/app/src/component/footer.tsx @@ -2,12 +2,16 @@ import { createAsync } from "@solidjs/router" import { createMemo } from "solid-js" import { github } from "~/lib/github" import { config } from "~/config" +import { useLanguage } from "~/context/language" +import { useI18n } from "~/context/i18n" export function Footer() { + const language = useLanguage() + const i18n = useI18n() const githubData = createAsync(() => github()) const starCount = createMemo(() => githubData()?.stars - ? new Intl.NumberFormat("en-US", { + ? new Intl.NumberFormat(language.tag(language.locale()), { notation: "compact", compactDisplay: "short", }).format(githubData()!.stars!) @@ -18,20 +22,20 @@ export function Footer() { ) diff --git a/packages/console/app/src/component/header.tsx b/packages/console/app/src/component/header.tsx index 72e9d0418..3eca8b88c 100644 --- a/packages/console/app/src/component/header.tsx +++ b/packages/console/app/src/component/header.tsx @@ -19,6 +19,7 @@ import { createStore } from "solid-js/store" import { github } from "~/lib/github" import { createEffect, onCleanup } from "solid-js" import { config } from "~/config" +import { useI18n } from "~/context/i18n" import "./header-context-menu.css" const isDarkMode = () => window.matchMedia("(prefers-color-scheme: dark)").matches @@ -36,12 +37,14 @@ const fetchSvgContent = async (svgPath: string): Promise => { export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) { const navigate = useNavigate() + const i18n = useI18n() const githubData = createAsync(() => github()) const starCount = createMemo(() => githubData()?.stars ? new Intl.NumberFormat("en-US", { notation: "compact", compactDisplay: "short", + maximumFractionDigits: 0, }).format(githubData()?.stars!) : config.github.starsFormatted.compact, ) @@ -119,8 +122,8 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
@@ -130,49 +133,56 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) { style={`left: ${store.contextMenuPosition.x}px; top: ${store.contextMenuPosition.y}px;`} >