From 1798af72b04b82c1a93ceec251e29188f09d5f63 Mon Sep 17 00:00:00 2001 From: Axel Sarmiento Mrak <96851183+AxelMrak@users.noreply.github.com> Date: Sun, 1 Feb 2026 15:19:37 -0300 Subject: [PATCH 01/44] fix(ecosystem): fix link Daytona (#11621) --- packages/web/src/content/docs/ecosystem.mdx | 64 ++++++++++----------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 07110dc1b5..9f84c6af17 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -15,38 +15,38 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw ## Plugins -| Name | Description | -| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| [opencode-daytona](https://github.com/jamesmurdza/daytona/tree/main/libs/opencode-plugin) | Automatically run OpenCode sessions in isolated Daytona sandboxes with git sync and live previews | -| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping | -| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools | -| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits | -| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing | -| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing | -| [opencode-devcontainers](https://github.com/athal7/opencode-devcontainers) | Multi-branch devcontainer isolation with shallow clones and auto-assigned ports | -| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Google Antigravity OAuth Plugin, with support for Google Search, and more robust API handling | -| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs | -| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style | -| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. | -| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations | -| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime | -| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs | -| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers | -| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible | -| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions | -| [opencode-notifier](https://github.com/mohak34/opencode-notifier) | Desktop notifications and sound alerts for permission, completion, and error events | -| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context | -| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful) | Allow OpenCode agents to lazy load prompts on demand with skill discovery and injection | -| [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory) | Persistent memory across sessions using Supermemory | -| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Interactive plan review with visual annotation and private/offline sharing | -| [@openspoon/subtask2](https://github.com/spoons-and-mirrors/subtask2) | Extend opencode /commands into a powerful orchestration system with granular flow control | -| [opencode-scheduler](https://github.com/different-ai/opencode-scheduler) | Schedule recurring jobs using launchd (Mac) or systemd (Linux) with cron syntax | -| [micode](https://github.com/vtemian/micode) | Structured Brainstorm → Plan → Implement workflow with session continuity | -| [octto](https://github.com/vtemian/octto) | Interactive browser UI for AI brainstorming with multi-question forms | -| [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents) | Claude Code-style background agents with async delegation and context persistence | -| [opencode-notify](https://github.com/kdcokenny/opencode-notify) | Native OS notifications for OpenCode – know when tasks complete | -| [opencode-workspace](https://github.com/kdcokenny/opencode-workspace) | Bundled multi-agent orchestration harness – 16 components, one install | -| [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | Zero-friction git worktrees for OpenCode | +| Name | Description | +| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| [opencode-daytona](https://github.com/jamesmurdza/daytona/blob/main/guides/typescript/opencode/README.md) | Automatically run OpenCode sessions in isolated Daytona sandboxes with git sync and live previews | +| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping | +| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools | +| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits | +| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing | +| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing | +| [opencode-devcontainers](https://github.com/athal7/opencode-devcontainers) | Multi-branch devcontainer isolation with shallow clones and auto-assigned ports | +| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Google Antigravity OAuth Plugin, with support for Google Search, and more robust API handling | +| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs | +| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style | +| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. | +| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations | +| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime | +| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs | +| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers | +| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible | +| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions | +| [opencode-notifier](https://github.com/mohak34/opencode-notifier) | Desktop notifications and sound alerts for permission, completion, and error events | +| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context | +| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful) | Allow OpenCode agents to lazy load prompts on demand with skill discovery and injection | +| [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory) | Persistent memory across sessions using Supermemory | +| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Interactive plan review with visual annotation and private/offline sharing | +| [@openspoon/subtask2](https://github.com/spoons-and-mirrors/subtask2) | Extend opencode /commands into a powerful orchestration system with granular flow control | +| [opencode-scheduler](https://github.com/different-ai/opencode-scheduler) | Schedule recurring jobs using launchd (Mac) or systemd (Linux) with cron syntax | +| [micode](https://github.com/vtemian/micode) | Structured Brainstorm → Plan → Implement workflow with session continuity | +| [octto](https://github.com/vtemian/octto) | Interactive browser UI for AI brainstorming with multi-question forms | +| [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents) | Claude Code-style background agents with async delegation and context persistence | +| [opencode-notify](https://github.com/kdcokenny/opencode-notify) | Native OS notifications for OpenCode – know when tasks complete | +| [opencode-workspace](https://github.com/kdcokenny/opencode-workspace) | Bundled multi-agent orchestration harness – 16 components, one install | +| [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | Zero-friction git worktrees for OpenCode | --- From cc1d3732bc610b7dcd9a16ef98be1bd7738b3811 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Mon, 2 Feb 2026 03:11:18 +0800 Subject: [PATCH 02/44] fix(tui): remove outer backtick wrapper in session transcript tool formatting (#11566) Co-authored-by: Claude --- .../src/cli/cmd/tui/util/transcript.ts | 10 +++---- .../opencode/test/cli/tui/transcript.test.ts | 29 +++++++++++++++++-- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/util/transcript.ts b/packages/opencode/src/cli/cmd/tui/util/transcript.ts index 8f986c3379..420c9dde1b 100644 --- a/packages/opencode/src/cli/cmd/tui/util/transcript.ts +++ b/packages/opencode/src/cli/cmd/tui/util/transcript.ts @@ -80,17 +80,17 @@ export function formatPart(part: Part, options: TranscriptOptions): string { } if (part.type === "tool") { - let result = `\`\`\`\nTool: ${part.tool}\n` + let result = `**Tool: ${part.tool}**\n` if (options.toolDetails && part.state.input) { - result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`` + result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`\n` } if (options.toolDetails && part.state.status === "completed" && part.state.output) { - result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`` + result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`\n` } if (options.toolDetails && part.state.status === "error" && part.state.error) { - result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`` + result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`\n` } - result += `\n\`\`\`\n\n` + result += `\n` return result } diff --git a/packages/opencode/test/cli/tui/transcript.test.ts b/packages/opencode/test/cli/tui/transcript.test.ts index 2cb29e1a89..7a5fa6b8f1 100644 --- a/packages/opencode/test/cli/tui/transcript.test.ts +++ b/packages/opencode/test/cli/tui/transcript.test.ts @@ -119,13 +119,38 @@ describe("transcript", () => { }, } const result = formatPart(part, options) - expect(result).toContain("Tool: bash") + expect(result).toContain("**Tool: bash**") expect(result).toContain("**Input:**") expect(result).toContain('"command": "ls"') expect(result).toContain("**Output:**") expect(result).toContain("file1.txt") }) + test("formats tool output containing triple backticks without breaking markdown", () => { + const part: Part = { + id: "part_1", + sessionID: "ses_123", + messageID: "msg_123", + type: "tool", + callID: "call_1", + tool: "bash", + state: { + status: "completed", + input: { command: "echo '```hello```'" }, + output: "```hello```", + title: "Echo backticks", + metadata: {}, + time: { start: 1000, end: 1100 }, + }, + } + const result = formatPart(part, options) + // The tool header should not be inside a code block + expect(result).toStartWith("**Tool: bash**\n") + // Input and output should each be in their own code blocks + expect(result).toContain("**Input:**\n```json") + expect(result).toContain("**Output:**\n```\n```hello```\n```") + }) + test("formats tool part without details when disabled", () => { const part: Part = { id: "part_1", @@ -144,7 +169,7 @@ describe("transcript", () => { }, } const result = formatPart(part, { ...options, toolDetails: false }) - expect(result).toContain("Tool: bash") + expect(result).toContain("**Tool: bash**") expect(result).not.toContain("**Input:**") expect(result).not.toContain("**Output:**") }) From eace76e525d6552eee6378912172d18a36a8f09a Mon Sep 17 00:00:00 2001 From: Desmond Sow Date: Mon, 2 Feb 2026 03:14:34 +0800 Subject: [PATCH 03/44] fix: opencode hanging when using client.app.log() during initialization (#11642) --- packages/opencode/src/server/server.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 1d832d77a2..f6dd0d122f 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -185,12 +185,15 @@ export namespace Server { }, ) .use(async (c, next) => { - let directory = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() - try { - directory = decodeURIComponent(directory) - } catch { - // fallback to original value - } + if (c.req.path === "/log") return next() + const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() + const directory = (() => { + try { + return decodeURIComponent(raw) + } catch { + return raw + } + })() return Instance.provide({ directory, init: InstanceBootstrap, From 16145af480a520906b0659a85e9a871781b69089 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 2 Feb 2026 04:46:08 +0900 Subject: [PATCH 04/44] fix: prevent duplicate AGENTS.md injection when reading instruction files (#11581) Co-authored-by: Aiden Cline --- packages/opencode/src/session/instruction.ts | 17 ++++++++++------ .../opencode/test/session/instruction.test.ts | 20 +++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 723439a3fd..65ca1e9bb2 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -75,7 +75,9 @@ export namespace InstructionPrompt { for (const file of FILES) { const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree) if (matches.length > 0) { - matches.forEach((p) => paths.add(path.resolve(p))) + matches.forEach((p) => { + paths.add(path.resolve(p)) + }) break } } @@ -103,7 +105,9 @@ export namespace InstructionPrompt { }), ).catch(() => []) : await resolveRelative(instruction) - matches.forEach((p) => paths.add(path.resolve(p))) + matches.forEach((p) => { + paths.add(path.resolve(p)) + }) } } @@ -168,12 +172,14 @@ export namespace InstructionPrompt { const already = loaded(messages) const results: { filepath: string; content: string }[] = [] - let current = path.dirname(path.resolve(filepath)) + const target = path.resolve(filepath) + let current = path.dirname(target) const root = path.resolve(Instance.directory) - while (current.startsWith(root)) { + while (current.startsWith(root) && current !== root) { const found = await find(current) - if (found && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) { + + if (found && found !== target && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) { claim(messageID, found) const content = await Bun.file(found) .text() @@ -182,7 +188,6 @@ export namespace InstructionPrompt { results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content }) } } - if (current === root) break current = path.dirname(current) } diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index 67719fa339..4d57e92a25 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -47,4 +47,24 @@ describe("InstructionPrompt.resolve", () => { }, }) }) + + test("doesn't reload AGENTS.md when reading it directly", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions") + await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const filepath = path.join(tmp.path, "subdir", "AGENTS.md") + const system = await InstructionPrompt.systemPaths() + expect(system.has(filepath)).toBe(false) + + const results = await InstructionPrompt.resolve([], filepath, "test-message-2") + expect(results).toEqual([]) + }, + }) + }) }) From f15755684f6de98f9953b5c1a04bac3202cb0e20 Mon Sep 17 00:00:00 2001 From: neavo Date: Mon, 2 Feb 2026 04:12:30 +0800 Subject: [PATCH 05/44] fix(opencode): scope agent variant to model (#11410) --- packages/opencode/src/agent/agent.ts | 2 + packages/opencode/src/config/config.ts | 5 ++ packages/opencode/src/session/prompt.ts | 15 ++++- packages/opencode/test/config/config.test.ts | 31 ++++++++++ .../test/session/prompt-variant.test.ts | 60 +++++++++++++++++++ 5 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/session/prompt-variant.test.ts diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 1d90a4c365..72e7f8985d 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -37,6 +37,7 @@ export namespace Agent { providerID: z.string(), }) .optional(), + variant: z.string().optional(), prompt: z.string().optional(), options: z.record(z.string(), z.any()), steps: z.number().int().positive().optional(), @@ -214,6 +215,7 @@ export namespace Agent { native: false, } if (value.model) item.model = Provider.parseModel(value.model) + item.variant = value.variant ?? item.variant item.prompt = value.prompt ?? item.prompt item.description = value.description ?? item.description item.temperature = value.temperature ?? item.temperature diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7969e30795..98970ba392 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -593,6 +593,10 @@ export namespace Config { export const Agent = z .object({ model: z.string().optional(), + variant: z + .string() + .optional() + .describe("Default model variant for this agent (applies only when using the agent's configured model)."), temperature: z.number().optional(), top_p: z.number().optional(), prompt: z.string().optional(), @@ -624,6 +628,7 @@ export namespace Config { const knownKeys = new Set([ "name", "model", + "variant", "prompt", "description", "temperature", diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 94eabdef7f..ba77cd7ca3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -827,6 +827,17 @@ export namespace SessionPrompt { async function createUserMessage(input: PromptInput) { const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent())) + + const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) + const variant = + input.variant ?? + (agent.variant && + agent.model && + model.providerID === agent.model.providerID && + model.modelID === agent.model.modelID + ? agent.variant + : undefined) + const info: MessageV2.Info = { id: input.messageID ?? Identifier.ascending("message"), role: "user", @@ -836,9 +847,9 @@ export namespace SessionPrompt { }, tools: input.tools, agent: agent.name, - model: input.model ?? agent.model ?? (await lastModel(input.sessionID)), + model, system: input.system, - variant: input.variant, + variant, } using _ = defer(() => InstructionPrompt.clear(info.id)) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 1752e22e01..8611d82969 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -255,6 +255,37 @@ test("handles agent configuration", async () => { }) }) +test("treats agent variant as model-scoped setting (not provider option)", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + agent: { + test_agent: { + model: "openai/gpt-5.2", + variant: "xhigh", + max_tokens: 123, + }, + }, + }) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + const agent = config.agent?.["test_agent"] + + expect(agent?.variant).toBe("xhigh") + expect(agent?.options).toMatchObject({ + max_tokens: 123, + }) + expect(agent?.options).not.toHaveProperty("variant") + }, + }) +}) + test("handles command configuration", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/session/prompt-variant.test.ts b/packages/opencode/test/session/prompt-variant.test.ts new file mode 100644 index 0000000000..16e8a22444 --- /dev/null +++ b/packages/opencode/test/session/prompt-variant.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { tmpdir } from "../fixture/fixture" + +describe("session.prompt agent variant", () => { + test("applies agent variant only when using agent model", async () => { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + build: { + model: "openai/gpt-5.2", + variant: "xhigh", + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + + const other = await SessionPrompt.prompt({ + sessionID: session.id, + agent: "build", + model: { providerID: "opencode", modelID: "kimi-k2.5-free" }, + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + if (other.info.role !== "user") throw new Error("expected user message") + expect(other.info.variant).toBeUndefined() + + const match = await SessionPrompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello again" }], + }) + if (match.info.role !== "user") throw new Error("expected user message") + expect(match.info.model).toEqual({ providerID: "openai", modelID: "gpt-5.2" }) + expect(match.info.variant).toBe("xhigh") + + const override = await SessionPrompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + variant: "high", + parts: [{ type: "text", text: "hello third" }], + }) + if (override.info.role !== "user") throw new Error("expected user message") + expect(override.info.variant).toBe("high") + + await Session.remove(session.id) + }, + }) + }) +}) From d29dfe31e4e3577b1ad9644d834f1377b82691db Mon Sep 17 00:00:00 2001 From: Caleb Norton Date: Sun, 1 Feb 2026 14:12:54 -0600 Subject: [PATCH 06/44] chore: reduce nix fetching (#11660) --- nix/hashes.json | 4 ++-- nix/node_modules.nix | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index b48e4cb0da..654f0296e0 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-3wRTDLo5FZoUc2Bwm1aAJZ4dNsekX8XoY6TwTmohgYo=", + "x86_64-linux": "sha256-06Otz3loT4vn0578VDxUqVudtzQvV7oM3EIzjZnsejo=", "aarch64-linux": "sha256-CKiuc6c52UV9cLEtccYEYS4QN0jYzNJv1fHSayqbHKo=", - "aarch64-darwin": "sha256-jGr2udrVeseioMWpIzpjYFfS1CN8GvNFwo6o92Aa5Oc=", + "aarch64-darwin": "sha256-x8dgCF0CJBWi2dZLDHMGdlTqys1X755ok0PM6x0HAGo=", "x86_64-darwin": "sha256-k5384Uun7tLjKkfJXXPcaZSXQ5jf/tMv21xi5cJU1rM=" } } diff --git a/nix/node_modules.nix b/nix/node_modules.nix index 6d75b9e750..836ef02a56 100644 --- a/nix/node_modules.nix +++ b/nix/node_modules.nix @@ -46,15 +46,16 @@ stdenvNoCC.mkDerivation { buildPhase = '' runHook preBuild - export HOME=$(mktemp -d) export BUN_INSTALL_CACHE_DIR=$(mktemp -d) bun install \ --cpu="${bunCpu}" \ --os="${bunOs}" \ + --filter '!./' \ + --filter './packages/opencode' \ + --filter './packages/desktop' \ --frozen-lockfile \ --ignore-scripts \ - --no-progress \ - --linker=isolated + --no-progress bun --bun ${./scripts/canonicalize-node-modules.ts} bun --bun ${./scripts/normalize-bun-binaries.ts} runHook postBuild From e62a15d421e6bfafac6151f152e018a75015a3f0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 1 Feb 2026 20:13:33 +0000 Subject: [PATCH 07/44] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 5 +++++ packages/sdk/openapi.json | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e992b27bd5..0556e1ad94 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1371,6 +1371,10 @@ export type PermissionConfig = export type AgentConfig = { model?: string + /** + * Default model variant for this agent (applies only when using the agent's configured model). + */ + variant?: string temperature?: number top_p?: number prompt?: string @@ -2136,6 +2140,7 @@ export type Agent = { modelID: string providerID: string } + variant?: string prompt?: string options: { [key: string]: unknown diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index f383ffbba8..95bca32303 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9017,6 +9017,10 @@ "model": { "type": "string" }, + "variant": { + "description": "Default model variant for this agent (applies only when using the agent's configured model).", + "type": "string" + }, "temperature": { "type": "number" }, @@ -10842,6 +10846,9 @@ }, "required": ["modelID", "providerID"] }, + "variant": { + "type": "string" + }, "prompt": { "type": "string" }, From 01cec84789235c4cc9db65953bfa358f43b13888 Mon Sep 17 00:00:00 2001 From: Caleb Norton Date: Sun, 1 Feb 2026 14:24:09 -0600 Subject: [PATCH 08/44] fix(desktop): nix - add missing dep (#11656) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- nix/desktop.nix | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nix/desktop.nix b/nix/desktop.nix index 9625f75c27..efdc2bd72e 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -45,8 +45,7 @@ rustPlatform.buildRustPackage (finalAttrs: { rustc jq makeWrapper - ] - ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ]; + ] ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ]; buildInputs = lib.optionals stdenv.isLinux [ dbus @@ -61,6 +60,7 @@ rustPlatform.buildRustPackage (finalAttrs: { gst_all_1.gstreamer gst_all_1.gst-plugins-base gst_all_1.gst-plugins-good + gst_all_1.gst-plugins-bad ]; strictDeps = true; @@ -97,4 +97,4 @@ rustPlatform.buildRustPackage (finalAttrs: { mainProgram = "opencode-desktop"; inherit (opencode.meta) platforms; }; -}) \ No newline at end of file +}) From ca5e85d6eab37e8f82ece19dcc482752acbe2ad5 Mon Sep 17 00:00:00 2001 From: Rohan Godha Date: Sun, 1 Feb 2026 15:42:42 -0500 Subject: [PATCH 09/44] fix: prompt caching for opus on bedrock (#11664) --- packages/opencode/src/provider/transform.ts | 5 +++-- packages/opencode/test/provider/transform.test.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index f5fe419db9..68220b90c1 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -179,7 +179,7 @@ export namespace ProviderTransform { cacheControl: { type: "ephemeral" }, }, bedrock: { - cachePoint: { type: "ephemeral" }, + cachePoint: { type: "default" }, }, openaiCompatible: { cache_control: { type: "ephemeral" }, @@ -190,7 +190,8 @@ export namespace ProviderTransform { } for (const msg of unique([...system, ...final])) { - const shouldUseContentOptions = providerID !== "anthropic" && Array.isArray(msg.content) && msg.content.length > 0 + const useMessageLevelOptions = providerID === "anthropic" || providerID.includes("bedrock") + const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0 if (shouldUseContentOptions) { const lastContent = msg.content[msg.content.length - 1] diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 0973e61585..cbb9ddbc41 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1166,7 +1166,7 @@ describe("ProviderTransform.message - claude w/bedrock custom inference profile" expect(result[0].providerOptions?.bedrock).toEqual( expect.objectContaining({ cachePoint: { - type: "ephemeral", + type: "default", }, }), ) From d52ee41b3aa2148c58d94bda75e83654dd38892a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 1 Feb 2026 20:48:33 +0000 Subject: [PATCH 10/44] chore: update nix node_modules hashes --- nix/hashes.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 654f0296e0..6fe8f61d3d 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { "x86_64-linux": "sha256-06Otz3loT4vn0578VDxUqVudtzQvV7oM3EIzjZnsejo=", - "aarch64-linux": "sha256-CKiuc6c52UV9cLEtccYEYS4QN0jYzNJv1fHSayqbHKo=", + "aarch64-linux": "sha256-88Qai5RkSenCZkakOg52b6xU2ok+h/Ns4/5L3+55sFY=", "aarch64-darwin": "sha256-x8dgCF0CJBWi2dZLDHMGdlTqys1X755ok0PM6x0HAGo=", - "x86_64-darwin": "sha256-k5384Uun7tLjKkfJXXPcaZSXQ5jf/tMv21xi5cJU1rM=" + "x86_64-darwin": "sha256-FkLDqorfIfOw+tB7SW5vgyhOIoI0IV9lqPW1iEmvUiI=" } } From f6948d0ffaa4b9d7e727bf2963c943563d72788f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 1 Feb 2026 14:50:49 -0600 Subject: [PATCH 11/44] fix: variant logic for anthropic models through openai compat endpoint (#11665) --- packages/opencode/src/provider/transform.ts | 41 +----- .../opencode/test/provider/transform.test.ts | 131 ------------------ 2 files changed, 2 insertions(+), 170 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 68220b90c1..ded416e66d 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -395,31 +395,6 @@ export namespace ProviderTransform { case "@ai-sdk/deepinfra": // https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepinfra case "@ai-sdk/openai-compatible": - // When using openai-compatible SDK with Claude/Anthropic models, - // we must use snake_case (budget_tokens) as the SDK doesn't convert parameter names - // and the OpenAI-compatible API spec uses snake_case - if ( - model.providerID === "anthropic" || - model.api.id.includes("anthropic") || - model.api.id.includes("claude") || - model.id.includes("anthropic") || - model.id.includes("claude") - ) { - return { - high: { - thinking: { - type: "enabled", - budget_tokens: 16000, - }, - }, - max: { - thinking: { - type: "enabled", - budget_tokens: 31999, - }, - }, - } - } return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) case "@ai-sdk/azure": @@ -719,21 +694,9 @@ export namespace ProviderTransform { const modelCap = modelLimit || globalLimit const standardLimit = Math.min(modelCap, globalLimit) - // Handle thinking mode for @ai-sdk/anthropic, @ai-sdk/google-vertex/anthropic (budgetTokens) - // and @ai-sdk/openai-compatible with Claude (budget_tokens) - if ( - npm === "@ai-sdk/anthropic" || - npm === "@ai-sdk/google-vertex/anthropic" || - npm === "@ai-sdk/openai-compatible" - ) { + if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") { const thinking = options?.["thinking"] - // Support both camelCase (for @ai-sdk/anthropic) and snake_case (for openai-compatible) - const budgetTokens = - typeof thinking?.["budgetTokens"] === "number" - ? thinking["budgetTokens"] - : typeof thinking?.["budget_tokens"] === "number" - ? thinking["budget_tokens"] - : 0 + const budgetTokens = typeof thinking?.["budgetTokens"] === "number" ? thinking["budgetTokens"] : 0 const enabled = thinking?.["type"] === "enabled" if (enabled && budgetTokens > 0) { // Return text tokens so that text + thinking <= model cap, preferring 32k text when possible. diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index cbb9ddbc41..8e28f1209e 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -267,76 +267,6 @@ describe("ProviderTransform.maxOutputTokens", () => { expect(result).toBe(OUTPUT_TOKEN_MAX) }) }) - - describe("openai-compatible with thinking options (snake_case)", () => { - test("returns 32k when budget_tokens + 32k <= modelLimit", () => { - const modelLimit = 100000 - const options = { - thinking: { - type: "enabled", - budget_tokens: 10000, - }, - } - const result = ProviderTransform.maxOutputTokens( - "@ai-sdk/openai-compatible", - options, - modelLimit, - OUTPUT_TOKEN_MAX, - ) - expect(result).toBe(OUTPUT_TOKEN_MAX) - }) - - test("returns modelLimit - budget_tokens when budget_tokens + 32k > modelLimit", () => { - const modelLimit = 50000 - const options = { - thinking: { - type: "enabled", - budget_tokens: 30000, - }, - } - const result = ProviderTransform.maxOutputTokens( - "@ai-sdk/openai-compatible", - options, - modelLimit, - OUTPUT_TOKEN_MAX, - ) - expect(result).toBe(20000) - }) - - test("returns 32k when thinking type is not enabled", () => { - const modelLimit = 100000 - const options = { - thinking: { - type: "disabled", - budget_tokens: 10000, - }, - } - const result = ProviderTransform.maxOutputTokens( - "@ai-sdk/openai-compatible", - options, - modelLimit, - OUTPUT_TOKEN_MAX, - ) - expect(result).toBe(OUTPUT_TOKEN_MAX) - }) - - test("returns 32k when budget_tokens is 0", () => { - const modelLimit = 100000 - const options = { - thinking: { - type: "enabled", - budget_tokens: 0, - }, - } - const result = ProviderTransform.maxOutputTokens( - "@ai-sdk/openai-compatible", - options, - modelLimit, - OUTPUT_TOKEN_MAX, - ) - expect(result).toBe(OUTPUT_TOKEN_MAX) - }) - }) }) describe("ProviderTransform.schema - gemini array items", () => { @@ -1564,67 +1494,6 @@ describe("ProviderTransform.variants", () => { expect(result.low).toEqual({ reasoningEffort: "low" }) expect(result.high).toEqual({ reasoningEffort: "high" }) }) - - test("Claude via LiteLLM returns thinking with snake_case budget_tokens", () => { - const model = createMockModel({ - id: "anthropic/claude-sonnet-4-5", - providerID: "anthropic", - api: { - id: "claude-sonnet-4-5-20250929", - url: "http://localhost:4000", - npm: "@ai-sdk/openai-compatible", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["high", "max"]) - expect(result.high).toEqual({ - thinking: { - type: "enabled", - budget_tokens: 16000, - }, - }) - expect(result.max).toEqual({ - thinking: { - type: "enabled", - budget_tokens: 31999, - }, - }) - }) - - test("Claude model (by model.id) via openai-compatible uses snake_case", () => { - const model = createMockModel({ - id: "litellm/claude-3-opus", - providerID: "litellm", - api: { - id: "claude-3-opus-20240229", - url: "http://localhost:4000", - npm: "@ai-sdk/openai-compatible", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["high", "max"]) - expect(result.high).toEqual({ - thinking: { - type: "enabled", - budget_tokens: 16000, - }, - }) - }) - - test("Anthropic model (by model.api.id) via openai-compatible uses snake_case", () => { - const model = createMockModel({ - id: "custom/my-model", - providerID: "custom", - api: { - id: "anthropic.claude-sonnet", - url: "http://localhost:4000", - npm: "@ai-sdk/openai-compatible", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["high", "max"]) - expect(result.high.thinking.budget_tokens).toBe(16000) - }) }) describe("@ai-sdk/azure", () => { From ec720145fafdcd927e4aa4baa5521798f7f5e99d Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 1 Feb 2026 14:57:47 -0600 Subject: [PATCH 12/44] fix: when using codex sub, send the custom agent prompts as a separate developer message (previously sent as user message but api allows for instructions AND developer messages) (#11667) Co-authored-by: Carlos --- packages/opencode/src/session/llm.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index befa46fe4a..4be6e2538f 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -233,19 +233,12 @@ export namespace LLM { }, maxRetries: input.retries ?? 0, messages: [ - ...(isCodex - ? [ - { - role: "user", - content: system.join("\n\n"), - } as ModelMessage, - ] - : system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - )), + ...system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + ), ...input.messages, ], model: wrapLanguageModel({ From 91f2ac3cb27c209532ef104cfcab139279881ace Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Sun, 1 Feb 2026 22:39:50 +0100 Subject: [PATCH 13/44] test(app): workspace tests (#11659) --- packages/app/e2e/actions.ts | 70 ++++ packages/app/e2e/projects/workspaces.spec.ts | 391 +++++++++++++++++++ packages/app/e2e/selectors.ts | 9 + packages/app/playwright.config.ts | 4 +- packages/app/src/pages/layout.tsx | 16 +- packages/opencode/src/session/index.ts | 7 +- packages/opencode/src/worktree/index.ts | 30 +- 7 files changed, 517 insertions(+), 10 deletions(-) create mode 100644 packages/app/e2e/projects/workspaces.spec.ts diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 1eb2da1db7..5f80d67c24 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -8,11 +8,15 @@ import { sessionItemSelector, dropdownMenuTriggerSelector, dropdownMenuContentSelector, + projectMenuTriggerSelector, + projectWorkspacesToggleSelector, titlebarRightSelector, popoverBodySelector, listItemSelector, listItemKeySelector, listItemKeyStartsWithSelector, + workspaceItemSelector, + workspaceMenuTriggerSelector, } from "./selectors" import type { createSdk } from "./utils" @@ -291,3 +295,69 @@ export async function openStatusPopover(page: Page) { return { rightSection, popoverBody } } + +export async function openProjectMenu(page: Page, projectSlug: string) { + const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first() + await expect(trigger).toHaveCount(1) + + await trigger.focus() + await page.keyboard.press("Enter") + + const menu = page.locator(dropdownMenuContentSelector).first() + const opened = await menu + .waitFor({ state: "visible", timeout: 1500 }) + .then(() => true) + .catch(() => false) + + if (opened) { + const viewport = page.viewportSize() + const x = viewport ? Math.max(viewport.width - 5, 0) : 1200 + const y = viewport ? Math.max(viewport.height - 5, 0) : 800 + await page.mouse.move(x, y) + return menu + } + + await trigger.click({ force: true }) + + await expect(menu).toBeVisible() + + const viewport = page.viewportSize() + const x = viewport ? Math.max(viewport.width - 5, 0) : 1200 + const y = viewport ? Math.max(viewport.height - 5, 0) : 800 + await page.mouse.move(x, y) + return menu +} + +export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) { + const current = await page + .getByRole("button", { name: "New workspace" }) + .first() + .isVisible() + .then((x) => x) + .catch(() => false) + + if (current === enabled) return + + await openProjectMenu(page, projectSlug) + + const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first() + await expect(toggle).toBeVisible() + await toggle.click({ force: true }) + + const expected = enabled ? "New workspace" : "New session" + await expect(page.getByRole("button", { name: expected }).first()).toBeVisible() +} + +export async function openWorkspaceMenu(page: Page, workspaceSlug: string) { + const item = page.locator(workspaceItemSelector(workspaceSlug)).first() + await expect(item).toBeVisible() + await item.hover() + + const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first() + await expect(trigger).toBeVisible() + await trigger.click({ force: true }) + + const menu = page.locator(dropdownMenuContentSelector).first() + await expect(menu).toBeVisible() + return menu +} diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts new file mode 100644 index 0000000000..80cd63aa2a --- /dev/null +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -0,0 +1,391 @@ +import { base64Decode } from "@opencode-ai/util/encode" +import fs from "node:fs/promises" +import path from "node:path" +import type { Page } from "@playwright/test" + +import { test, expect } from "../fixtures" + +test.describe.configure({ mode: "serial" }) +import { + cleanupTestProject, + clickMenuItem, + confirmDialog, + createTestProject, + openSidebar, + openWorkspaceMenu, + seedProjects, + setWorkspacesEnabled, +} from "../actions" +import { inlineInputSelector, projectSwitchSelector, workspaceItemSelector } from "../selectors" +import { dirSlug } from "../utils" + +function slugFromUrl(url: string) { + return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" +} + +async function setupWorkspaceTest(page: Page, directory: string, gotoSession: () => Promise) { + const project = await createTestProject() + const rootSlug = dirSlug(project) + await seedProjects(page, { directory, extra: [project] }) + + await gotoSession() + await openSidebar(page) + + const target = page.locator(projectSwitchSelector(rootSlug)).first() + await expect(target).toBeVisible() + await target.click() + await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) + + await openSidebar(page) + await setWorkspacesEnabled(page, rootSlug, true) + + await page.getByRole("button", { name: "New workspace" }).first().click() + await expect + .poll( + () => { + const slug = slugFromUrl(page.url()) + return slug.length > 0 && slug !== rootSlug + }, + { timeout: 45_000 }, + ) + .toBe(true) + + const slug = slugFromUrl(page.url()) + const dir = base64Decode(slug) + + await openSidebar(page) + + await expect + .poll( + async () => { + const item = page.locator(workspaceItemSelector(slug)).first() + try { + await item.hover({ timeout: 500 }) + return true + } catch { + return false + } + }, + { timeout: 60_000 }, + ) + .toBe(true) + + return { project, rootSlug, slug, directory: dir } +} + +test("can enable and disable workspaces from project menu", async ({ page, directory, gotoSession }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const project = await createTestProject() + const slug = dirSlug(project) + await seedProjects(page, { directory, extra: [project] }) + + try { + await gotoSession() + await openSidebar(page) + + const target = page.locator(projectSwitchSelector(slug)).first() + await expect(target).toBeVisible() + await target.click() + await expect(page).toHaveURL(new RegExp(`/${slug}/session`)) + + await openSidebar(page) + + await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() + await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) + + await setWorkspacesEnabled(page, slug, true) + await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() + await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible() + + await setWorkspacesEnabled(page, slug, false) + await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() + await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0) + } finally { + await cleanupTestProject(project) + } +}) + +test("can create a workspace", async ({ page, directory, gotoSession }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const project = await createTestProject() + const slug = dirSlug(project) + await seedProjects(page, { directory, extra: [project] }) + + try { + await gotoSession() + await openSidebar(page) + + const target = page.locator(projectSwitchSelector(slug)).first() + await expect(target).toBeVisible() + await target.click() + await expect(page).toHaveURL(new RegExp(`/${slug}/session`)) + + await openSidebar(page) + await setWorkspacesEnabled(page, slug, true) + + await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() + + await page.getByRole("button", { name: "New workspace" }).first().click() + + await expect + .poll( + () => { + const currentSlug = slugFromUrl(page.url()) + return currentSlug.length > 0 && currentSlug !== slug + }, + { timeout: 45_000 }, + ) + .toBe(true) + + const workspaceSlug = slugFromUrl(page.url()) + const workspaceDir = base64Decode(workspaceSlug) + + await openSidebar(page) + + await expect + .poll( + async () => { + const item = page.locator(workspaceItemSelector(workspaceSlug)).first() + try { + await item.hover({ timeout: 500 }) + return true + } catch { + return false + } + }, + { timeout: 60_000 }, + ) + .toBe(true) + + await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible() + + await cleanupTestProject(workspaceDir) + } finally { + await cleanupTestProject(project) + } +}) + +test("can rename a workspace", async ({ page, directory, gotoSession }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const { project, slug } = await setupWorkspaceTest(page, directory, gotoSession) + + try { + const rename = `e2e workspace ${Date.now()}` + const menu = await openWorkspaceMenu(page, slug) + await clickMenuItem(menu, /^Rename$/i, { force: true }) + + await expect(menu).toHaveCount(0) + + const item = page.locator(workspaceItemSelector(slug)).first() + await expect(item).toBeVisible() + const input = item.locator(inlineInputSelector).first() + await expect(input).toBeVisible() + await input.fill(rename) + await input.press("Enter") + await expect(item).toContainText(rename) + } finally { + await cleanupTestProject(project) + } +}) + +test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const { project, slug, directory: createdDir } = await setupWorkspaceTest(page, directory, gotoSession) + + try { + const readme = path.join(createdDir, "README.md") + const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`) + const original = await fs.readFile(readme, "utf8") + const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n` + await fs.writeFile(readme, dirty, "utf8") + await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8") + + await expect + .poll(async () => { + return await fs + .stat(extra) + .then(() => true) + .catch(() => false) + }) + .toBe(true) + + await expect + .poll(async () => { + const files = await sdk.file + .status({ directory: createdDir }) + .then((r) => r.data ?? []) + .catch(() => []) + return files.length + }) + .toBeGreaterThan(0) + + const menu = await openWorkspaceMenu(page, slug) + await clickMenuItem(menu, /^Reset$/i, { force: true }) + await confirmDialog(page, /^Reset workspace$/i) + + await expect + .poll( + async () => { + const files = await sdk.file + .status({ directory: createdDir }) + .then((r) => r.data ?? []) + .catch(() => []) + return files.length + }, + { timeout: 60_000 }, + ) + .toBe(0) + + await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original) + + await expect + .poll(async () => { + return await fs + .stat(extra) + .then(() => true) + .catch(() => false) + }) + .toBe(false) + } finally { + await cleanupTestProject(project) + } +}) + +test("can delete a workspace", async ({ page, directory, gotoSession }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const { project, rootSlug, slug } = await setupWorkspaceTest(page, directory, gotoSession) + + try { + const menu = await openWorkspaceMenu(page, slug) + await clickMenuItem(menu, /^Delete$/i, { force: true }) + await confirmDialog(page, /^Delete workspace$/i) + + await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) + await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0) + await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible() + } finally { + await cleanupTestProject(project) + } +}) + +test("can reorder workspaces by drag and drop", async ({ page, directory, gotoSession }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const project = await createTestProject() + const rootSlug = dirSlug(project) + await seedProjects(page, { directory, extra: [project] }) + + const workspaces = [] as { directory: string; slug: string }[] + + const listSlugs = async () => { + const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') + const slugs = await nodes.evaluateAll((els) => { + return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) + }) + return slugs + } + + const waitReady = async (slug: string) => { + await expect + .poll( + async () => { + const item = page.locator(workspaceItemSelector(slug)).first() + try { + await item.hover({ timeout: 500 }) + return true + } catch { + return false + } + }, + { timeout: 60_000 }, + ) + .toBe(true) + } + + const drag = async (from: string, to: string) => { + const src = page.locator(workspaceItemSelector(from)).first() + const dst = page.locator(workspaceItemSelector(to)).first() + + await src.scrollIntoViewIfNeeded() + await dst.scrollIntoViewIfNeeded() + + const a = await src.boundingBox() + const b = await dst.boundingBox() + if (!a || !b) throw new Error("Failed to resolve workspace drag bounds") + + await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2) + await page.mouse.down() + await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 }) + await page.mouse.up() + } + + try { + await gotoSession() + await openSidebar(page) + + const target = page.locator(projectSwitchSelector(rootSlug)).first() + await expect(target).toBeVisible() + await target.click() + await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) + + await openSidebar(page) + await setWorkspacesEnabled(page, rootSlug, true) + + for (const _ of [0, 1]) { + const prev = slugFromUrl(page.url()) + await page.getByRole("button", { name: "New workspace" }).first().click() + await expect + .poll( + () => { + const slug = slugFromUrl(page.url()) + return slug.length > 0 && slug !== rootSlug && slug !== prev + }, + { timeout: 45_000 }, + ) + .toBe(true) + + const slug = slugFromUrl(page.url()) + const dir = base64Decode(slug) + workspaces.push({ slug, directory: dir }) + + await openSidebar(page) + } + + if (workspaces.length !== 2) throw new Error("Expected two created workspaces") + + const a = workspaces[0].slug + const b = workspaces[1].slug + + await waitReady(a) + await waitReady(b) + + const list = async () => { + const slugs = await listSlugs() + return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2) + } + + await expect + .poll(async () => { + const slugs = await list() + return slugs.length === 2 + }) + .toBe(true) + + const before = await list() + const from = before[1] + const to = before[0] + if (!from || !to) throw new Error("Failed to resolve initial workspace order") + + await drag(from, to) + + await expect.poll(async () => await list()).toEqual([from, to]) + } finally { + await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory))) + await cleanupTestProject(project) + } +}) diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 90cfef8db9..317c70969d 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -27,6 +27,9 @@ export const projectMenuTriggerSelector = (slug: string) => export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]` +export const projectWorkspacesToggleSelector = (slug: string) => + `[data-action="project-workspaces-toggle"][data-project="${slug}"]` + export const titlebarRightSelector = "#opencode-titlebar-right" export const popoverBodySelector = '[data-slot="popover-body"]' @@ -39,6 +42,12 @@ export const inlineInputSelector = '[data-component="inline-input"]' export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]` +export const workspaceItemSelector = (slug: string) => + `${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]` + +export const workspaceMenuTriggerSelector = (slug: string) => + `${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]` + export const listItemSelector = '[data-slot="list-item"]' export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]` diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 10819e69ff..57bf86b5a8 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -6,6 +6,7 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" const command = `bun run dev -- --host 0.0.0.0 --port ${port}` const reuse = !process.env.CI +const win = process.platform === "win32" export default defineConfig({ testDir: "./e2e", @@ -14,7 +15,8 @@ export default defineConfig({ expect: { timeout: 10_000, }, - fullyParallel: true, + fullyParallel: !win, + workers: win ? 1 : undefined, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index f049dc3bcc..845a4fc834 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2114,12 +2114,20 @@ export default function Layout(props: ParentProps) { >
-
+
+ {header()} } @@ -2146,6 +2154,8 @@ export default function Layout(props: ParentProps) { icon="dot-grid" variant="ghost" class="size-6 rounded-md" + data-action="workspace-menu" + data-workspace={base64Encode(props.directory)} aria-label={language.t("common.moreOptions")} /> @@ -2592,6 +2602,8 @@ export default function Layout(props: ParentProps) { {language.t("common.edit")} { const enabled = layout.sidebar.workspaces(p.worktree)() diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 87cf3a0820..556fad01f5 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -332,7 +332,9 @@ export namespace Session { export async function* list() { const project = Instance.project for (const item of await Storage.list(["session", project.id])) { - yield Storage.read(item) + const session = await Storage.read(item).catch(() => undefined) + if (!session) continue + yield session } } @@ -340,7 +342,8 @@ export namespace Session { const project = Instance.project const result = [] as Session.Info[] for (const item of await Storage.list(["session", project.id])) { - const session = await Storage.read(item) + const session = await Storage.read(item).catch(() => undefined) + if (!session) continue if (session.parentID !== parentID) continue result.push(session) } diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 0f2e2f4a06..b0dfd57dd2 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -219,6 +219,13 @@ export namespace Worktree { return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).join("\n") } + async function canonical(input: string) { + const abs = path.resolve(input) + const real = await fs.realpath(abs).catch(() => abs) + const normalized = path.normalize(real) + return process.platform === "win32" ? normalized.toLowerCase() : normalized + } + async function candidate(root: string, base?: string) { for (const attempt of Array.from({ length: 26 }, (_, i) => i)) { const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName() @@ -374,7 +381,7 @@ export namespace Worktree { throw new NotGitError({ message: "Worktrees are only supported for git projects" }) } - const directory = path.resolve(input.directory) + const directory = await canonical(input.directory) const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree) if (list.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" }) @@ -397,7 +404,13 @@ export namespace Worktree { return acc }, []) - const entry = entries.find((item) => item.path && path.resolve(item.path) === directory) + const entry = await (async () => { + for (const item of entries) { + if (!item.path) continue + const key = await canonical(item.path) + if (key === directory) return item + } + })() if (!entry?.path) { throw new RemoveFailedError({ message: "Worktree not found" }) } @@ -423,8 +436,9 @@ export namespace Worktree { throw new NotGitError({ message: "Worktrees are only supported for git projects" }) } - const directory = path.resolve(input.directory) - if (directory === path.resolve(Instance.worktree)) { + const directory = await canonical(input.directory) + const primary = await canonical(Instance.worktree) + if (directory === primary) { throw new ResetFailedError({ message: "Cannot reset the primary workspace" }) } @@ -450,7 +464,13 @@ export namespace Worktree { return acc }, []) - const entry = entries.find((item) => item.path && path.resolve(item.path) === directory) + const entry = await (async () => { + for (const item of entries) { + if (!item.path) continue + const key = await canonical(item.path) + if (key === directory) return item + } + })() if (!entry?.path) { throw new ResetFailedError({ message: "Worktree not found" }) } From f23d8d343be94c1af2f67ddeaaddc556c9617420 Mon Sep 17 00:00:00 2001 From: Sumit Srivastava Date: Mon, 2 Feb 2026 03:43:53 +0530 Subject: [PATCH 14/44] docs (web): Update incorrect Kimi model name in zen.mdx (#11677) --- packages/web/src/content/docs/zen.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index ddaabbef09..27f4c229c5 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -156,7 +156,7 @@ Credit card fees are passed along at cost (4.4% + $0.30 per transaction); we don The free models: - GLM 4.7 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. -- Kimi M2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. +- Kimi K2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - MiniMax M2.1 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. From b39c1f158f5e78b41edd944dce792a8b602819c1 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 1 Feb 2026 17:50:15 -0500 Subject: [PATCH 15/44] zen: add minimax logo (#11682) --- packages/console/app/src/routes/zen/index.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/console/app/src/routes/zen/index.tsx b/packages/console/app/src/routes/zen/index.tsx index 5708c238cd..accc8d67c9 100644 --- a/packages/console/app/src/routes/zen/index.tsx +++ b/packages/console/app/src/routes/zen/index.tsx @@ -18,7 +18,7 @@ import { Legal } from "~/component/legal" import { Footer } from "~/component/footer" import { Header } from "~/component/header" import { getLastSeenWorkspaceID } from "../workspace/common" -import { IconGemini, IconZai } from "~/component/icon" +import { IconGemini, IconMiniMax, IconZai } from "~/component/icon" const checkLoggedIn = query(async () => { "use server" @@ -98,14 +98,7 @@ export default function Home() {
- - - +
@@ -118,6 +111,16 @@ export default function Home() {
+
+ + + +
Get started with Zen From 377bf7ff21a4f05807c38675ac70cd08fe67b516 Mon Sep 17 00:00:00 2001 From: Aaron Iker Date: Mon, 2 Feb 2026 01:17:14 +0100 Subject: [PATCH 16/44] feat(ui): Select, dropdown, popover styles & transitions (#11675) --- .../src/components/dialog-select-model.tsx | 9 +- packages/app/src/components/prompt-input.tsx | 153 +++++++++++------- .../app/src/components/settings-general.tsx | 34 ++-- packages/ui/src/components/button.css | 29 ++-- packages/ui/src/components/button.tsx | 2 +- packages/ui/src/components/cycle-label.css | 49 ++++++ packages/ui/src/components/cycle-label.tsx | 132 +++++++++++++++ packages/ui/src/components/dropdown-menu.css | 45 +++--- packages/ui/src/components/icon.tsx | 4 +- packages/ui/src/components/message-part.tsx | 6 +- packages/ui/src/components/morph-chevron.css | 10 ++ packages/ui/src/components/morph-chevron.tsx | 73 +++++++++ packages/ui/src/components/popover.css | 58 +++++-- packages/ui/src/components/reasoning-icon.css | 9 ++ packages/ui/src/components/reasoning-icon.tsx | 32 ++++ packages/ui/src/components/select.css | 87 ++++++---- packages/ui/src/components/select.tsx | 20 ++- packages/ui/src/styles/index.css | 2 + packages/ui/src/styles/utilities.css | 42 +++++ 19 files changed, 631 insertions(+), 165 deletions(-) create mode 100644 packages/ui/src/components/cycle-label.css create mode 100644 packages/ui/src/components/cycle-label.tsx create mode 100644 packages/ui/src/components/morph-chevron.css create mode 100644 packages/ui/src/components/morph-chevron.tsx create mode 100644 packages/ui/src/components/reasoning-icon.css create mode 100644 packages/ui/src/components/reasoning-icon.tsx diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 4f0dcc3ee6..00e654d8ef 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -90,9 +90,10 @@ const ModelList: Component<{ export function ModelSelectorPopover(props: { provider?: string - children?: JSX.Element + children?: JSX.Element | ((open: boolean) => JSX.Element) triggerAs?: T - triggerProps?: ComponentProps + triggerProps?: ComponentProps, + gutter?: number }) { const [store, setStore] = createStore<{ open: boolean @@ -175,14 +176,14 @@ export function ModelSelectorPopover(props: { }} modal={false} placement="top-start" - gutter={8} + gutter={props.gutter ?? 8} > setStore("trigger", el)} as={props.triggerAs ?? "div"} {...(props.triggerProps as any)} > - {props.children} + {typeof props.children === "function" ? props.children(store.open) : props.children} = (props) => { .abort({ sessionID, }) - .catch(() => {}) + .catch(() => { }) } const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { @@ -1252,7 +1255,7 @@ export const PromptInput: Component = (props) => { clearInput() client.session .shell({ - sessionID: session.id, + sessionID: session?.id || "", agent, model, command: text, @@ -1275,7 +1278,7 @@ export const PromptInput: Component = (props) => { clearInput() client.session .command({ - sessionID: session.id, + sessionID: session?.id || "", command: commandName, arguments: args.join(" "), agent, @@ -1348,18 +1351,18 @@ export const PromptInput: Component = (props) => { const contextParts: Array< | { - id: string - type: "text" - text: string - synthetic?: boolean - } + id: string + type: "text" + text: string + synthetic?: boolean + } | { - id: string - type: "file" - mime: string - url: string - filename?: string - } + id: string + type: "file" + mime: string + url: string + filename?: string + } > = [] const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => { @@ -1431,13 +1434,13 @@ export const PromptInput: Component = (props) => { const optimisticParts = requestParts.map((part) => ({ ...part, - sessionID: session.id, + sessionID: session?.id || "", messageID, })) as unknown as Part[] const optimisticMessage: Message = { id: messageID, - sessionID: session.id, + sessionID: session?.id || "", role: "user", time: { created: Date.now() }, agent, @@ -1448,9 +1451,9 @@ export const PromptInput: Component = (props) => { if (sessionDirectory === projectDirectory) { sync.set( produce((draft) => { - const messages = draft.message[session.id] + const messages = draft.message[session?.id || ""] if (!messages) { - draft.message[session.id] = [optimisticMessage] + draft.message[session?.id || ""] = [optimisticMessage] } else { const result = Binary.search(messages, messageID, (m) => m.id) messages.splice(result.index, 0, optimisticMessage) @@ -1466,9 +1469,9 @@ export const PromptInput: Component = (props) => { globalSync.child(sessionDirectory)[1]( produce((draft) => { - const messages = draft.message[session.id] + const messages = draft.message[session?.id || ""] if (!messages) { - draft.message[session.id] = [optimisticMessage] + draft.message[session?.id || ""] = [optimisticMessage] } else { const result = Binary.search(messages, messageID, (m) => m.id) messages.splice(result.index, 0, optimisticMessage) @@ -1485,7 +1488,7 @@ export const PromptInput: Component = (props) => { if (sessionDirectory === projectDirectory) { sync.set( produce((draft) => { - const messages = draft.message[session.id] + const messages = draft.message[session?.id || ""] if (messages) { const result = Binary.search(messages, messageID, (m) => m.id) if (result.found) messages.splice(result.index, 1) @@ -1498,7 +1501,7 @@ export const PromptInput: Component = (props) => { globalSync.child(sessionDirectory)[1]( produce((draft) => { - const messages = draft.message[session.id] + const messages = draft.message[session?.id || ""] if (messages) { const result = Binary.search(messages, messageID, (m) => m.id) if (result.found) messages.splice(result.index, 1) @@ -1519,15 +1522,15 @@ export const PromptInput: Component = (props) => { const worktree = WorktreeState.get(sessionDirectory) if (!worktree || worktree.status !== "pending") return true - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "busy" }) + if (sessionDirectory === projectDirectory && session?.id) { + sync.set("session_status", session?.id, { type: "busy" }) } const controller = new AbortController() const cleanup = () => { - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "idle" }) + if (sessionDirectory === projectDirectory && session?.id) { + sync.set("session_status", session?.id, { type: "idle" }) } removeOptimisticMessage() for (const item of commentItems) { @@ -1544,7 +1547,7 @@ export const PromptInput: Component = (props) => { restoreInput() } - pending.set(session.id, { abort: controller, cleanup }) + pending.set(session?.id || "", { abort: controller, cleanup }) const abort = new Promise>>((resolve) => { if (controller.signal.aborted) { @@ -1572,7 +1575,7 @@ export const PromptInput: Component = (props) => { if (timer.id === undefined) return clearTimeout(timer.id) }) - pending.delete(session.id) + pending.delete(session?.id || "") if (controller.signal.aborted) return false if (result.status === "failed") throw new Error(result.message) return true @@ -1582,7 +1585,7 @@ export const PromptInput: Component = (props) => { const ok = await waitForWorktree() if (!ok) return await client.session.prompt({ - sessionID: session.id, + sessionID: session?.id || "", agent, model, messageID, @@ -1592,9 +1595,9 @@ export const PromptInput: Component = (props) => { } void send().catch((err) => { - pending.delete(session.id) - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "idle" }) + pending.delete(session?.id || "") + if (sessionDirectory === projectDirectory && session?.id) { + sync.set("session_status", session?.id, { type: "idle" }) } showToast({ title: language.t("prompt.toast.promptSendFailed.title"), @@ -1616,6 +1619,28 @@ export const PromptInput: Component = (props) => { }) } + const currrentModelVariant = createMemo(() => { + const modelVariant = local.model.variant.current() ?? "" + return modelVariant === "xhigh" + ? "xHigh" + : modelVariant.length > 0 + ? modelVariant[0].toUpperCase() + modelVariant.slice(1) + : "Default" + }) + + const reasoningPercentage = createMemo(() => { + const variants = local.model.variant.list() + const current = local.model.variant.current() + const totalEntries = variants.length + 1 + + if (totalEntries <= 2 || current === "Default") { + return 0 + } + + const currentIndex = current ? variants.indexOf(current) + 1 : 0 + return ((currentIndex + 1) / totalEntries) * 100 + }, [local.model.variant]) + return (
@@ -1668,7 +1693,7 @@ export const PromptInput: Component = (props) => { } > - + @{(item as { type: "agent"; name: string }).name} @@ -1729,9 +1754,9 @@ export const PromptInput: Component = (props) => { }} > -
+
- + {language.t("prompt.dropzone.label")}
@@ -1770,7 +1795,7 @@ export const PromptInput: Component = (props) => { }} >
- +
{getFilenameTruncated(item.path, 14)} @@ -1787,7 +1812,7 @@ export const PromptInput: Component = (props) => { type="button" icon="close-small" variant="ghost" - class="ml-auto h-5 w-5 opacity-0 group-hover:opacity-100 transition-all" + class="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-all" onClick={(e) => { e.stopPropagation() if (item.commentID) comments.remove(item.path, item.commentID) @@ -1817,7 +1842,7 @@ export const PromptInput: Component = (props) => { when={attachment.mime.startsWith("image/")} fallback={
- +
} > @@ -1891,7 +1916,7 @@ export const PromptInput: Component = (props) => {
-
+
@@ -1912,6 +1937,7 @@ export const PromptInput: Component = (props) => { onSelect={local.agent.set} class="capitalize" variant="ghost" + gutter={12} /> = (props) => { title={language.t("command.model.choose")} keybind={command.keybind("model.choose")} > - } @@ -1937,12 +1968,16 @@ export const PromptInput: Component = (props) => { title={language.t("command.model.choose")} keybind={command.keybind("model.choose")} > - - - - - {local.model.current()?.name ?? language.t("dialog.model.select.title")} - + + {(open) => ( + <> + + + + {local.model.current()?.name ?? language.t("dialog.model.select.title")} + + + )} @@ -1955,10 +1990,13 @@ export const PromptInput: Component = (props) => { @@ -1972,7 +2010,7 @@ export const PromptInput: Component = (props) => { variant="ghost" onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)} classList={{ - "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true, + "_hidden group-hover/prompt-input:flex items-center justify-center": true, "text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory), "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory), }} @@ -1994,7 +2032,7 @@ export const PromptInput: Component = (props) => {
-
+
= (props) => { e.currentTarget.value = "" }} /> -
+
@@ -2036,7 +2075,7 @@ export const PromptInput: Component = (props) => {
{language.t("prompt.action.send")} - +
@@ -2047,7 +2086,7 @@ export const PromptInput: Component = (props) => { disabled={!prompt.dirty() && !working()} icon={working() ? "stop" : "arrow-up"} variant="primary" - class="h-6 w-4.5" + class="h-6 w-5.5" aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} /> @@ -2162,4 +2201,4 @@ function setCursorPosition(parent: HTMLElement, position: number) { fallbackRange.collapse(false) fallbackSelection?.removeAllRanges() fallbackSelection?.addRange(fallbackRange) -} +} \ No newline at end of file diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index b31cfb6cc7..e43b82e4a8 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -60,24 +60,24 @@ export const SettingsGeneral: Component = () => { const actions = platform.update && platform.restart ? [ - { - label: language.t("toast.update.action.installRestart"), - onClick: async () => { - await platform.update!() - await platform.restart!() - }, + { + label: language.t("toast.update.action.installRestart"), + onClick: async () => { + await platform.update!() + await platform.restart!() }, - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] + }, + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] : [ - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] showToast({ persistent: true, @@ -226,7 +226,7 @@ export const SettingsGeneral: Component = () => { variant="secondary" size="small" triggerVariant="settings" - triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }} + triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "field-sizing": "content" }} > {(option) => ( diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index d9b3459230..3e5d21d1de 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -9,7 +9,13 @@ user-select: none; cursor: default; outline: none; + padding: 4px 8px; white-space: nowrap; + transition-property: background-color, border-color, color, box-shadow, opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); + outline: none; + line-height: 20px; &[data-variant="primary"] { background-color: var(--button-primary-base); @@ -94,7 +100,6 @@ &:active:not(:disabled) { background-color: var(--button-secondary-base); scale: 0.99; - transition: all 150ms ease-out; } &:disabled { border-color: var(--border-disabled); @@ -109,34 +114,31 @@ } &[data-size="small"] { - height: 22px; - padding: 0 8px; + padding: 4px 8px; &[data-icon] { - padding: 0 12px 0 4px; + padding: 4px 12px 4px 4px; } - font-size: var(--font-size-small); - line-height: var(--line-height-large); gap: 4px; /* text-12-medium */ font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: var(--font-size-base); font-style: normal; font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); } &[data-size="normal"] { - height: 24px; - line-height: 24px; - padding: 0 6px; + padding: 4px 6px; &[data-icon] { - padding: 0 12px 0 4px; + padding: 4px 12px 4px 4px; + } + + &[aria-haspopup] { + padding: 4px 6px 4px 8px; } - font-size: var(--font-size-small); gap: 6px; /* text-12-medium */ @@ -148,7 +150,6 @@ } &[data-size="large"] { - height: 32px; padding: 6px 12px; &[data-icon] { diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index 7f974b2f76..1b110ca8af 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -4,7 +4,7 @@ import { Icon, IconProps } from "./icon" export interface ButtonProps extends ComponentProps, - Pick, "class" | "classList" | "children"> { + Pick, "class" | "classList" | "children" | "style"> { size?: "small" | "normal" | "large" variant?: "primary" | "secondary" | "ghost" icon?: IconProps["name"] diff --git a/packages/ui/src/components/cycle-label.css b/packages/ui/src/components/cycle-label.css new file mode 100644 index 0000000000..3c98fcd261 --- /dev/null +++ b/packages/ui/src/components/cycle-label.css @@ -0,0 +1,49 @@ +.cycle-label { + --c-duration: 200ms; + --c-stagger: 30ms; + --c-opacity-start: 0; + --c-opacity-end: 1; + --c-blur-start: 0px; + --c-blur-end: 0px; + --c-skew: 10deg; + + display: inline-flex; + position: relative; + + transform-style: preserve-3d; + perspective: 500px; + transition: width var(--transition-duration) var(--transition-easing); + will-change: width; + overflow: hidden; + + .cycle-char { + display: inline-block; + transform-style: preserve-3d; + min-width: 0.25em; + backface-visibility: hidden; + + transition-property: transform, opacity, filter; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); + transition-delay: calc(var(--i, 0) * var(--c-stagger)); + + &.enter { + opacity: var(--c-opacity-end); + filter: blur(var(--c-blur-end)); + transform: translateY(0) rotateX(0) skewX(0); + } + + &.exit { + opacity: var(--c-opacity-start); + filter: blur(var(--c-blur-start)); + transform: translateY(50%) rotateX(90deg) skewX(var(--c-skew)); + } + + &.pre { + opacity: var(--c-opacity-start); + filter: blur(var(--c-blur-start)); + transition: none; + transform: translateY(-50%) rotateX(-90deg) skewX(calc(var(--c-skew) * -1)); + } + } +} diff --git a/packages/ui/src/components/cycle-label.tsx b/packages/ui/src/components/cycle-label.tsx new file mode 100644 index 0000000000..f1eaa88f31 --- /dev/null +++ b/packages/ui/src/components/cycle-label.tsx @@ -0,0 +1,132 @@ +import "./cycle-label.css" +import { createEffect, createSignal, JSX, on } from "solid-js" + +export interface CycleLabelProps extends JSX.HTMLAttributes { + value: string + onValueChange?: (value: string) => void + duration?: number | ((value: string) => number) + stagger?: number + opacity?: [number, number] + blur?: [number, number] + skewX?: number + onAnimationStart?: () => void + onAnimationEnd?: () => void +} + +const segmenter = + typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter("en", { granularity: "grapheme" }) : null + +const getChars = (text: string): string[] => + segmenter ? Array.from(segmenter.segment(text), (s) => s.segment) : text.split("") + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +export function CycleLabel(props: CycleLabelProps) { + const getDuration = (text: string) => { + const d = props.duration ?? Number(getComputedStyle(document.documentElement).getPropertyValue("--transition-duration")) ?? 200 + return typeof d === "function" ? d(text) : d + } + const stagger = () => props?.stagger ?? 30 + const opacity = () => props?.opacity ?? [0, 1] + const blur = () => props?.blur ?? [0, 0] + const skewX = () => props?.skewX ?? 10 + + let containerRef: HTMLSpanElement | undefined + let isAnimating = false + const [currentText, setCurrentText] = createSignal(props.value) + + const setChars = (el: HTMLElement, text: string, state: "enter" | "exit" | "pre" = "enter") => { + el.innerHTML = "" + const chars = getChars(text) + chars.forEach((char, i) => { + const span = document.createElement("span") + span.textContent = char === " " ? "\u00A0" : char + span.className = `cycle-char ${state}` + span.style.setProperty("--i", String(i)) + el.appendChild(span) + }) + } + + const animateToText = async (newText: string) => { + if (!containerRef || isAnimating) return + if (newText === currentText()) return + + isAnimating = true + props.onAnimationStart?.() + + const dur = getDuration(newText) + const stag = stagger() + + containerRef.style.width = containerRef.offsetWidth + "px" + + const oldChars = containerRef.querySelectorAll(".cycle-char") + oldChars.forEach((c) => c.classList.replace("enter", "exit")) + + const clone = containerRef.cloneNode(false) as HTMLElement + Object.assign(clone.style, { + position: "absolute", + visibility: "hidden", + width: "auto", + transition: "none", + }) + setChars(clone, newText) + document.body.appendChild(clone) + const nextWidth = clone.offsetWidth + clone.remove() + + const exitTime = oldChars.length * stag + dur + await wait(exitTime * 0.3) + + containerRef.style.width = nextWidth + "px" + + const widthDur = 200 + await wait(widthDur * 0.3) + + setChars(containerRef, newText, "pre") + containerRef.offsetWidth + + Array.from(containerRef.children).forEach((c) => (c.className = "cycle-char enter")) + setCurrentText(newText) + props.onValueChange?.(newText) + + const enterTime = getChars(newText).length * stag + dur + await wait(enterTime) + + containerRef.style.width = "" + isAnimating = false + props.onAnimationEnd?.() + } + + createEffect( + on( + () => props.value, + (newValue) => { + if (newValue !== currentText()) { + animateToText(newValue) + } + }, + ), + ) + + const initRef = (el: HTMLSpanElement) => { + containerRef = el + setChars(el, props.value) + } + + return ( + + ) +} \ No newline at end of file diff --git a/packages/ui/src/components/dropdown-menu.css b/packages/ui/src/components/dropdown-menu.css index cba041613e..18266ac1a1 100644 --- a/packages/ui/src/components/dropdown-menu.css +++ b/packages/ui/src/components/dropdown-menu.css @@ -2,26 +2,29 @@ [data-component="dropdown-menu-sub-content"] { min-width: 8rem; overflow: hidden; + border: none; border-radius: var(--radius-md); - border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent); + box-shadow: var(--shadow-xs-border); background-clip: padding-box; background-color: var(--surface-raised-stronger-non-alpha); padding: 4px; - box-shadow: var(--shadow-md); - z-index: 50; + z-index: 100; transform-origin: var(--kb-menu-content-transform-origin); - &:focus, - &:focus-visible { + &:focus-within, + &:focus { outline: none; } - &[data-closed] { - animation: dropdown-menu-close 0.15s ease-out; + animation: dropdownMenuContentHide var(--transition-duration) var(--transition-easing) forwards; + + @starting-style { + animation: none; } &[data-expanded] { - animation: dropdown-menu-open 0.15s ease-out; + pointer-events: auto; + animation: dropdownMenuContentShow var(--transition-duration) var(--transition-easing) forwards; } } @@ -38,18 +41,22 @@ padding: 4px 8px; border-radius: var(--radius-sm); cursor: default; - user-select: none; outline: none; font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: var(--font-size-base); font-weight: var(--font-weight-medium); line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); color: var(--text-strong); - &[data-highlighted] { - background: var(--surface-raised-base-hover); + transition-property: background-color, color; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); + user-select: none; + + &:hover { + background-color: var(--surface-raised-base-hover); } &[data-disabled] { @@ -61,6 +68,8 @@ [data-slot="dropdown-menu-sub-trigger"] { &[data-expanded] { background: var(--surface-raised-base-hover); + outline: none; + border: none; } } @@ -102,24 +111,24 @@ } } -@keyframes dropdown-menu-open { +@keyframes dropdownMenuContentShow { from { opacity: 0; - transform: scale(0.96); + transform: scaleY(0.95); } to { opacity: 1; - transform: scale(1); + transform: scaleY(1); } } -@keyframes dropdown-menu-close { +@keyframes dropdownMenuContentHide { from { opacity: 1; - transform: scale(1); + transform: scaleY(1); } to { opacity: 0; - transform: scale(0.96); + transform: scaleY(0.95); } } diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 544c6abdd2..f23357293e 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -80,13 +80,13 @@ const icons = { export interface IconProps extends ComponentProps<"svg"> { name: keyof typeof icons - size?: "small" | "normal" | "medium" | "large" + size?: "small" | "normal" | "medium" | "large" | number } export function Icon(props: IconProps) { const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) return ( -
+
- +
m.id === perm.tool!.messageID) + const message = messages.findLast((m) => m.id === perm.tool!.messageID) if (!message) return undefined const parts = data.store.part[message.id] ?? [] for (const part of parts) { diff --git a/packages/ui/src/components/morph-chevron.css b/packages/ui/src/components/morph-chevron.css new file mode 100644 index 0000000000..f6edb3f649 --- /dev/null +++ b/packages/ui/src/components/morph-chevron.css @@ -0,0 +1,10 @@ +[data-slot="morph-chevron-svg"] { + width: 16px; + height: 16px; + display: block; + fill: none; + stroke-width: 1.5; + stroke: currentcolor; + stroke-linecap: round; + stroke-linejoin: round; +} diff --git a/packages/ui/src/components/morph-chevron.tsx b/packages/ui/src/components/morph-chevron.tsx new file mode 100644 index 0000000000..a14a2252b6 --- /dev/null +++ b/packages/ui/src/components/morph-chevron.tsx @@ -0,0 +1,73 @@ +import { createEffect, createUniqueId, on } from "solid-js" + +export interface MorphChevronProps { + expanded: boolean + class?: string +} + +const COLLAPSED = "M4 6L8 10L12 6" +const EXPANDED = "M4 10L8 6L12 10" + +export function MorphChevron(props: MorphChevronProps) { + const id = createUniqueId() + let path: SVGPathElement | undefined + let expandAnim: SVGAnimateElement | undefined + let collapseAnim: SVGAnimateElement | undefined + + createEffect( + on( + () => props.expanded, + (expanded, prev) => { + if (prev === undefined) { + // Set initial state without animation + path?.setAttribute("d", expanded ? EXPANDED : COLLAPSED) + return + } + if (expanded) { + expandAnim?.beginElement() + } else { + collapseAnim?.beginElement() + } + }, + ), + ) + + return ( + + ) +} \ No newline at end of file diff --git a/packages/ui/src/components/popover.css b/packages/ui/src/components/popover.css index b49542afd9..d200fe8b24 100644 --- a/packages/ui/src/components/popover.css +++ b/packages/ui/src/components/popover.css @@ -15,16 +15,35 @@ transform-origin: var(--kb-popover-content-transform-origin); - &:focus-within { - outline: none; - } + animation: popoverContentHide var(--transition-duration) var(--transition-easing) forwards; - &[data-closed] { - animation: popover-close 0.15s ease-out; + @starting-style { + animation: none; } &[data-expanded] { - animation: popover-open 0.15s ease-out; + pointer-events: auto; + animation: popoverContentShow var(--transition-duration) var(--transition-easing) forwards; + } + + [data-origin-top-right] { + transform-origin: top right; + } + + [data-origin-top-left] { + transform-origin: top left; + } + + [data-origin-bottom-right] { + transform-origin: bottom right; + } + + [data-origin-bottom-left] { + transform-origin: bottom left; + } + + &:focus-within { + outline: none; } [data-slot="popover-header"] { @@ -75,24 +94,39 @@ } } -@keyframes popover-open { +@keyframes popoverContentShow { from { opacity: 0; - transform: scale(0.96); + transform: scaleY(0.95); } to { opacity: 1; - transform: scale(1); + transform: scaleY(1); } } -@keyframes popover-close { +@keyframes popoverContentHide { from { opacity: 1; - transform: scale(1); + transform: scaleY(1); } to { opacity: 0; - transform: scale(0.96); + transform: scaleY(0.95); + } +} + +[data-component="model-popover-content"] { + transform-origin: var(--kb-popper-content-transform-origin); + pointer-events: none; + animation: popoverContentHide var(--transition-duration) var(--transition-easing) forwards; + + @starting-style { + animation: none; + } + + &[data-expanded] { + pointer-events: auto; + animation: popoverContentShow var(--transition-duration) var(--transition-easing) forwards; } } diff --git a/packages/ui/src/components/reasoning-icon.css b/packages/ui/src/components/reasoning-icon.css new file mode 100644 index 0000000000..26fbc01448 --- /dev/null +++ b/packages/ui/src/components/reasoning-icon.css @@ -0,0 +1,9 @@ +[data-component="reasoning-icon"] { + color: var(--icon-strong-base); + + [data-slot="reasoning-icon-percentage"] { + transition: clip-path 200ms cubic-bezier(0.25, 0, 0.5, 1); + clip-path: inset(calc(100% - var(--reasoning-icon-percentage) * 100%) 0 0 0); + opacity: calc(var(--reasoning-icon-percentage) * 0.75); + } +} diff --git a/packages/ui/src/components/reasoning-icon.tsx b/packages/ui/src/components/reasoning-icon.tsx new file mode 100644 index 0000000000..ba753b3b84 --- /dev/null +++ b/packages/ui/src/components/reasoning-icon.tsx @@ -0,0 +1,32 @@ +import { type ComponentProps, splitProps } from "solid-js" + +export interface ReasoningIconProps extends Pick, "class" | "classList"> { + percentage: number + size?: number + strokeWidth?: number +} + +export function ReasoningIcon(props: ReasoningIconProps) { + const [split, rest] = splitProps(props, ["percentage", "size", "strokeWidth", "class", "classList"]) + + const size = () => split.size || 16 + const strokeWidth = () => split.strokeWidth || 1.25 + + return ( + + + + + ) +} \ No newline at end of file diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css index 25dd2eb40b..eaba6fd6d2 100644 --- a/packages/ui/src/components/select.css +++ b/packages/ui/src/components/select.css @@ -1,7 +1,13 @@ [data-component="select"] { [data-slot="select-select-trigger"] { - padding: 0 4px 0 8px; + display: flex; + padding: 4px 8px !important; + align-items: center; + justify-content: space-between; box-shadow: none; + transition-property: background-color; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); [data-slot="select-select-trigger-value"] { overflow: hidden; @@ -15,10 +21,10 @@ align-items: center; justify-content: center; flex-shrink: 0; - color: var(--text-weak); - transition: transform 0.1s ease-in-out; + color: var(--icon-base); } + &:hover, &[data-expanded] { &[data-variant="secondary"] { background-color: var(--button-secondary-hover); @@ -30,13 +36,13 @@ background-color: var(--icon-strong-active); } } - + &:not([data-expanded]):focus, &:not([data-expanded]):focus-visible { &[data-variant="secondary"] { background-color: var(--button-secondary-base); } &[data-variant="ghost"] { - background-color: var(--surface-raised-base-hover); + background-color: transparent; } &[data-variant="primary"] { background-color: var(--icon-strong-base); @@ -46,10 +52,10 @@ &[data-trigger-style="settings"] { [data-slot="select-select-trigger"] { - padding: 6px 6px 6px 12px; + padding: 6px 6px 6px 10px; box-shadow: none; border-radius: 6px; - min-width: 160px; + field-sizing: content; height: 32px; justify-content: flex-end; gap: 12px; @@ -61,6 +67,7 @@ white-space: nowrap; font-size: var(--font-size-base); font-weight: var(--font-weight-regular); + padding: 4px 8px 4px 4px; } [data-slot="select-select-trigger-icon"] { width: 16px; @@ -91,17 +98,26 @@ } [data-component="select-content"] { - min-width: 104px; + min-width: 8rem; max-width: 23rem; overflow: hidden; border-radius: var(--radius-md); background-color: var(--surface-raised-stronger-non-alpha); padding: 4px; box-shadow: var(--shadow-xs-border); - z-index: 60; + z-index: 50; + transform-origin: var(--kb-popper-content-transform-origin); + pointer-events: none; + + animation: selectContentHide var(--transition-duration) var(--transition-easing) forwards; + + @starting-style { + animation: none; + } &[data-expanded] { - animation: select-open 0.15s ease-out; + pointer-events: auto; + animation: selectContentShow var(--transition-duration) var(--transition-easing) forwards; } [data-slot="select-select-content-list"] { @@ -111,43 +127,38 @@ overflow-x: hidden; display: flex; flex-direction: column; - &:focus { outline: none; } - > *:not([role="presentation"]) + *:not([role="presentation"]) { margin-top: 2px; } } - [data-slot="select-select-item"] { position: relative; display: flex; align-items: center; - padding: 2px 8px; + padding: 4px 8px; gap: 12px; - border-radius: 4px; - cursor: default; + border-radius: var(--radius-sm); /* text-12-medium */ font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: var(--font-size-base); font-style: normal; font-weight: var(--font-weight-medium); line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); - color: var(--text-strong); - transition: - background-color 0.2s ease-in-out, - color 0.2s ease-in-out; + transition-property: background-color, color; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); outline: none; user-select: none; - &[data-highlighted] { - background: var(--surface-raised-base-hover); + &:hover { + background-color: var(--surface-raised-base-hover); } &[data-disabled] { background-color: var(--surface-raised-base); @@ -160,6 +171,11 @@ margin-left: auto; width: 16px; height: 16px; + color: var(--icon-strong-base); + + svg { + color: var(--icon-strong-base); + } } &:focus { outline: none; @@ -171,13 +187,9 @@ } [data-component="select-content"][data-trigger-style="settings"] { - min-width: 160px; + field-sizing: content; border-radius: 8px; - padding: 0; - - [data-slot="select-select-content-list"] { - padding: 4px; - } + padding: 0 0 0 4px; [data-slot="select-select-item"] { /* text-14-regular */ @@ -190,13 +202,24 @@ } } -@keyframes select-open { +@keyframes selectContentShow { from { opacity: 0; - transform: scale(0.95); + transform: scaleY(0.95); } to { opacity: 1; - transform: scale(1); + transform: scaleY(1); + } +} + +@keyframes selectContentHide { + from { + opacity: 1; + transform: scaleY(1); + } + to { + opacity: 0; + transform: scaleY(0.95); } } diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 0386c329ec..66f48e69b1 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -1,8 +1,10 @@ import { Select as Kobalte } from "@kobalte/core/select" -import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" +import { createMemo, createSignal, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" import { pipe, groupBy, entries, map } from "remeda" +import { Show } from "solid-js" import { Button, ButtonProps } from "./button" import { Icon } from "./icon" +import { MorphChevron } from "./morph-chevron" export type SelectProps = Omit>, "value" | "onSelect" | "children"> & { placeholder?: string @@ -38,6 +40,8 @@ export function Select(props: SelectProps & Omit) "triggerVariant", ]) + const [isOpen, setIsOpen] = createSignal(false) + const state = { key: undefined as string | undefined, cleanup: undefined as (() => void) | void, @@ -85,7 +89,7 @@ export function Select(props: SelectProps & Omit) data-component="select" data-trigger-style={local.triggerVariant} placement={local.triggerVariant === "settings" ? "bottom-end" : "bottom-start"} - gutter={4} + gutter={8} value={local.current} options={grouped()} optionValue={(x) => (local.value ? local.value(x) : (x as string))} @@ -115,7 +119,7 @@ export function Select(props: SelectProps & Omit) : (itemProps.item.rawValue as string)} - + )} @@ -124,6 +128,7 @@ export function Select(props: SelectProps & Omit) stop() }} onOpenChange={(open) => { + setIsOpen(open) local.onOpenChange?.(open) if (!open) stop() }} @@ -149,7 +154,12 @@ export function Select(props: SelectProps & Omit) }} - + + + + + + @@ -166,4 +176,4 @@ export function Select(props: SelectProps & Omit) ) -} +} \ No newline at end of file diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 3ed0310ef2..cae48137ba 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -48,6 +48,8 @@ @import "../components/toast.css" layer(components); @import "../components/tooltip.css" layer(components); @import "../components/typewriter.css" layer(components); +@import "../components/morph-chevron.css" layer(components); +@import "../components/reasoning-icon.css" layer(components); @import "./utilities.css" layer(utilities); @import "./animations.css" layer(utilities); diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css index 8c954f1fe4..82a913c883 100644 --- a/packages/ui/src/styles/utilities.css +++ b/packages/ui/src/styles/utilities.css @@ -1,6 +1,17 @@ :root { interpolate-size: allow-keywords; + /* Transition tokens */ + --transition-duration: 200ms; + --transition-easing: cubic-bezier(0.25, 0, 0.5, 1); + --transition-fast: 150ms; + --transition-slow: 300ms; + + /* Allow height transitions from 0 to auto */ + @supports (interpolate-size: allow-keywords) { + interpolate-size: allow-keywords; + } + [data-popper-positioner] { pointer-events: none; } @@ -129,3 +140,34 @@ line-height: var(--line-height-x-large); /* 120% */ letter-spacing: var(--letter-spacing-tightest); } + +/* Transition utility classes */ +.transition-colors { + transition-property: background-color, border-color, color, fill, stroke; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); +} + +.transition-opacity { + transition-property: opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); +} + +.transition-transform { + transition-property: transform; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); +} + +.transition-shadow { + transition-property: box-shadow; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); +} + +.transition-interactive { + transition-property: background-color, border-color, color, box-shadow, opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); +} From e84d441b823463162cada18e1a8b5383820c695a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 00:17:54 +0000 Subject: [PATCH 17/44] chore: generate --- .../src/components/dialog-select-model.tsx | 2 +- packages/app/src/components/prompt-input.tsx | 30 +++++++++-------- .../app/src/components/settings-general.tsx | 32 +++++++++---------- packages/ui/src/components/button.tsx | 2 +- packages/ui/src/components/cycle-label.tsx | 7 ++-- packages/ui/src/components/icon.tsx | 5 ++- packages/ui/src/components/morph-chevron.tsx | 2 +- packages/ui/src/components/reasoning-icon.tsx | 20 ++++++++++-- packages/ui/src/components/select.tsx | 2 +- 9 files changed, 62 insertions(+), 40 deletions(-) diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 00e654d8ef..2135b1edf4 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -92,7 +92,7 @@ export function ModelSelectorPopover(props: { provider?: string children?: JSX.Element | ((open: boolean) => JSX.Element) triggerAs?: T - triggerProps?: ComponentProps, + triggerProps?: ComponentProps gutter?: number }) { const [store, setStore] = createStore<{ diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 0c16bc8935..c175824deb 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -925,7 +925,7 @@ export const PromptInput: Component = (props) => { .abort({ sessionID, }) - .catch(() => { }) + .catch(() => {}) } const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { @@ -1351,18 +1351,18 @@ export const PromptInput: Component = (props) => { const contextParts: Array< | { - id: string - type: "text" - text: string - synthetic?: boolean - } + id: string + type: "text" + text: string + synthetic?: boolean + } | { - id: string - type: "file" - mime: string - url: string - filename?: string - } + id: string + type: "file" + mime: string + url: string + filename?: string + } > = [] const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => { @@ -1958,7 +1958,9 @@ export const PromptInput: Component = (props) => { {local.model.current()?.name ?? language.t("dialog.model.select.title")} - + } @@ -2201,4 +2203,4 @@ function setCursorPosition(parent: HTMLElement, position: number) { fallbackRange.collapse(false) fallbackSelection?.removeAllRanges() fallbackSelection?.addRange(fallbackRange) -} \ No newline at end of file +} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index e43b82e4a8..94813871e4 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -60,24 +60,24 @@ export const SettingsGeneral: Component = () => { const actions = platform.update && platform.restart ? [ - { - label: language.t("toast.update.action.installRestart"), - onClick: async () => { - await platform.update!() - await platform.restart!() + { + label: language.t("toast.update.action.installRestart"), + onClick: async () => { + await platform.update!() + await platform.restart!() + }, }, - }, - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] : [ - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] showToast({ persistent: true, diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index 1b110ca8af..b2d2004d3c 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -4,7 +4,7 @@ import { Icon, IconProps } from "./icon" export interface ButtonProps extends ComponentProps, - Pick, "class" | "classList" | "children" | "style"> { + Pick, "class" | "classList" | "children" | "style"> { size?: "small" | "normal" | "large" variant?: "primary" | "secondary" | "ghost" icon?: IconProps["name"] diff --git a/packages/ui/src/components/cycle-label.tsx b/packages/ui/src/components/cycle-label.tsx index f1eaa88f31..dc12bd75c8 100644 --- a/packages/ui/src/components/cycle-label.tsx +++ b/packages/ui/src/components/cycle-label.tsx @@ -23,7 +23,10 @@ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) export function CycleLabel(props: CycleLabelProps) { const getDuration = (text: string) => { - const d = props.duration ?? Number(getComputedStyle(document.documentElement).getPropertyValue("--transition-duration")) ?? 200 + const d = + props.duration ?? + Number(getComputedStyle(document.documentElement).getPropertyValue("--transition-duration")) ?? + 200 return typeof d === "function" ? d(text) : d } const stagger = () => props?.stagger ?? 30 @@ -129,4 +132,4 @@ export function CycleLabel(props: CycleLabelProps) { }} /> ) -} \ No newline at end of file +} diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index f23357293e..97488a42f0 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -86,7 +86,10 @@ export interface IconProps extends ComponentProps<"svg"> { export function Icon(props: IconProps) { const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) return ( -
+
) -} \ No newline at end of file +} diff --git a/packages/ui/src/components/reasoning-icon.tsx b/packages/ui/src/components/reasoning-icon.tsx index ba753b3b84..7bac49ffd2 100644 --- a/packages/ui/src/components/reasoning-icon.tsx +++ b/packages/ui/src/components/reasoning-icon.tsx @@ -25,8 +25,22 @@ export function ReasoningIcon(props: ReasoningIconProps) { [split.class ?? ""]: !!split.class, }} > - - + + ) -} \ No newline at end of file +} diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 66f48e69b1..fef00500a7 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -176,4 +176,4 @@ export function Select(props: SelectProps & Omit) ) -} \ No newline at end of file +} From e445dc07464d75c893756f6e256c1755d9e2285e Mon Sep 17 00:00:00 2001 From: Aaron Iker Date: Mon, 2 Feb 2026 01:18:06 +0100 Subject: [PATCH 18/44] feat(ui): Smooth fading out on scroll, style fixes (#11683) --- .../app/src/components/settings-general.tsx | 37 ++-- .../app/src/components/settings-keybinds.tsx | 10 +- .../app/src/components/settings-models.tsx | 5 +- .../app/src/components/settings-providers.tsx | 5 +- packages/ui/src/components/list.css | 50 ++--- packages/ui/src/components/list.tsx | 13 +- packages/ui/src/components/scroll-fade.css | 82 +++++++ packages/ui/src/components/scroll-fade.tsx | 206 ++++++++++++++++++ packages/ui/src/components/scroll-reveal.tsx | 141 ++++++++++++ packages/ui/src/styles/index.css | 1 + 10 files changed, 490 insertions(+), 60 deletions(-) create mode 100644 packages/ui/src/components/scroll-fade.css create mode 100644 packages/ui/src/components/scroll-fade.tsx create mode 100644 packages/ui/src/components/scroll-reveal.tsx diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 94813871e4..1d82a24c30 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -5,6 +5,7 @@ import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { showToast } from "@opencode-ai/ui/toast" +import { ScrollFade } from "@opencode-ai/ui/scroll-fade" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings, monoFontFamily } from "@/context/settings" @@ -60,24 +61,24 @@ export const SettingsGeneral: Component = () => { const actions = platform.update && platform.restart ? [ - { - label: language.t("toast.update.action.installRestart"), - onClick: async () => { - await platform.update!() - await platform.restart!() - }, + { + label: language.t("toast.update.action.installRestart"), + onClick: async () => { + await platform.update!() + await platform.restart!() }, - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] + }, + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] : [ - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] showToast({ persistent: true, @@ -130,7 +131,7 @@ export const SettingsGeneral: Component = () => { const soundOptions = [...SOUND_OPTIONS] return ( -
+

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

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

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

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

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

@@ -261,6 +262,6 @@ export const SettingsProviders: Component = () => {
-
+ ) } diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index b12d304151..7b365c288a 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -1,25 +1,7 @@ -@property --bottom-fade { - syntax: ""; - inherits: false; - initial-value: 0px; -} - -@keyframes scroll { - 0% { - --bottom-fade: 20px; - } - 90% { - --bottom-fade: 20px; - } - 100% { - --bottom-fade: 0; - } -} - [data-component="list"] { display: flex; flex-direction: column; - gap: 12px; + gap: 8px; overflow: hidden; padding: 0 12px; @@ -37,7 +19,9 @@ flex-shrink: 0; background-color: transparent; opacity: 0.5; - transition: opacity 0.15s ease; + transition-property: opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); &:hover:not(:disabled), &:focus-visible:not(:disabled), @@ -88,7 +72,9 @@ height: 20px; background-color: transparent; opacity: 0.5; - transition: opacity 0.15s ease; + transition-property: opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); &:hover:not(:disabled), &:focus-visible:not(:disabled), @@ -131,15 +117,6 @@ gap: 12px; overflow-y: auto; overscroll-behavior: contain; - mask: linear-gradient(to bottom, #ffff calc(100% - var(--bottom-fade)), #0000); - animation: scroll; - animation-timeline: --scroll; - scroll-timeline: --scroll y; - scrollbar-width: none; - -ms-overflow-style: none; - &::-webkit-scrollbar { - display: none; - } [data-slot="list-empty-state"] { display: flex; @@ -215,7 +192,9 @@ background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent); pointer-events: none; opacity: 0; - transition: opacity 0.15s ease; + transition-property: opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); } &[data-stuck="true"]::after { @@ -251,17 +230,22 @@ align-items: center; justify-content: center; flex-shrink: 0; - aspect-ratio: 1/1; + aspect-ratio: 1 / 1; [data-component="icon"] { color: var(--icon-strong-base); } } + + [name="check"] { + color: var(--icon-strong-base); + } + [data-slot="list-item-active-icon"] { display: none; align-items: center; justify-content: center; flex-shrink: 0; - aspect-ratio: 1/1; + aspect-ratio: 1 / 1; [data-component="icon"] { color: var(--icon-strong-base); } diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 2132897f7c..950ad3d203 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -5,6 +5,7 @@ import { useI18n } from "../context/i18n" import { Icon, type IconProps } from "./icon" import { IconButton } from "./icon-button" import { TextField } from "./text-field" +import { ScrollFade } from "./scroll-fade" function findByKey(container: HTMLElement, key: string) { const nodes = container.querySelectorAll('[data-slot="list-item"][data-key]') @@ -267,7 +268,13 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) {searchAction()}
-
+ 0 || showAdd()} fallback={ @@ -339,7 +346,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
-
+
) -} +} \ No newline at end of file diff --git a/packages/ui/src/components/scroll-fade.css b/packages/ui/src/components/scroll-fade.css new file mode 100644 index 0000000000..ede5fabec4 --- /dev/null +++ b/packages/ui/src/components/scroll-fade.css @@ -0,0 +1,82 @@ +[data-component="scroll-fade"] { + overflow: auto; + overscroll-behavior: contain; + scrollbar-width: none; + box-sizing: border-box; + color: inherit; + font: inherit; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } + + &[data-direction="horizontal"] { + overflow-x: auto; + overflow-y: hidden; + + /* Both fades */ + &[data-fade-start][data-fade-end] { + mask-image: linear-gradient( + to right, + transparent, + black var(--scroll-fade-start), + black calc(100% - var(--scroll-fade-end)), + transparent + ); + -webkit-mask-image: linear-gradient( + to right, + transparent, + black var(--scroll-fade-start), + black calc(100% - var(--scroll-fade-end)), + transparent + ); + } + + /* Only start fade */ + &[data-fade-start]:not([data-fade-end]) { + mask-image: linear-gradient(to right, transparent, black var(--scroll-fade-start), black 100%); + -webkit-mask-image: linear-gradient(to right, transparent, black var(--scroll-fade-start), black 100%); + } + + /* Only end fade */ + &:not([data-fade-start])[data-fade-end] { + mask-image: linear-gradient(to right, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); + -webkit-mask-image: linear-gradient(to right, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); + } + } + + &[data-direction="vertical"] { + overflow-y: auto; + overflow-x: hidden; + + &[data-fade-start][data-fade-end] { + mask-image: linear-gradient( + to bottom, + transparent, + black var(--scroll-fade-start), + black calc(100% - var(--scroll-fade-end)), + transparent + ); + -webkit-mask-image: linear-gradient( + to bottom, + transparent, + black var(--scroll-fade-start), + black calc(100% - var(--scroll-fade-end)), + transparent + ); + } + + /* Only start fade */ + &[data-fade-start]:not([data-fade-end]) { + mask-image: linear-gradient(to bottom, transparent, black var(--scroll-fade-start), black 100%); + -webkit-mask-image: linear-gradient(to bottom, transparent, black var(--scroll-fade-start), black 100%); + } + + /* Only end fade */ + &:not([data-fade-start])[data-fade-end] { + mask-image: linear-gradient(to bottom, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); + -webkit-mask-image: linear-gradient(to bottom, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); + } + } +} diff --git a/packages/ui/src/components/scroll-fade.tsx b/packages/ui/src/components/scroll-fade.tsx new file mode 100644 index 0000000000..d190d67c03 --- /dev/null +++ b/packages/ui/src/components/scroll-fade.tsx @@ -0,0 +1,206 @@ +import { type JSX, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" + +export interface ScrollFadeProps extends JSX.HTMLAttributes { + direction?: "horizontal" | "vertical" + fadeStartSize?: number + fadeEndSize?: number + trackTransformSelector?: string + ref?: (el: HTMLDivElement) => void +} + +export function ScrollFade(props: ScrollFadeProps) { + const [local, others] = splitProps(props, [ + "children", + "direction", + "fadeStartSize", + "fadeEndSize", + "trackTransformSelector", + "class", + "style", + "ref", + ]) + + const direction = () => local.direction ?? "vertical" + const fadeStartSize = () => local.fadeStartSize ?? 20 + const fadeEndSize = () => local.fadeEndSize ?? 20 + + const getTransformOffset = (element: Element): number => { + const style = getComputedStyle(element) + const transform = style.transform + if (!transform || transform === "none") return 0 + + const match = transform.match(/matrix(?:3d)?\(([^)]+)\)/) + if (!match) return 0 + + const values = match[1].split(",").map((v) => parseFloat(v.trim())) + const isHorizontal = direction() === "horizontal" + + if (transform.startsWith("matrix3d")) { + return isHorizontal ? -(values[12] || 0) : -(values[13] || 0) + } else { + return isHorizontal ? -(values[4] || 0) : -(values[5] || 0) + } + } + + let containerRef: HTMLDivElement | undefined + + const [fadeStart, setFadeStart] = createSignal(0) + const [fadeEnd, setFadeEnd] = createSignal(0) + const [isScrollable, setIsScrollable] = createSignal(false) + + let lastScrollPos = 0 + let lastTransformPos = 0 + let lastScrollSize = 0 + let lastClientSize = 0 + + const updateFade = () => { + if (!containerRef) return + + const isHorizontal = direction() === "horizontal" + const scrollPos = isHorizontal ? containerRef.scrollLeft : containerRef.scrollTop + const scrollSize = isHorizontal ? containerRef.scrollWidth : containerRef.scrollHeight + const clientSize = isHorizontal ? containerRef.clientWidth : containerRef.clientHeight + + let transformPos = 0 + if (local.trackTransformSelector) { + const transformElement = containerRef.querySelector(local.trackTransformSelector) + if (transformElement) { + transformPos = getTransformOffset(transformElement) + } + } + + const effectiveScrollPos = Math.max(scrollPos, transformPos) + + if ( + effectiveScrollPos === lastScrollPos && + transformPos === lastTransformPos && + scrollSize === lastScrollSize && + clientSize === lastClientSize + ) { + return + } + + lastScrollPos = effectiveScrollPos + lastTransformPos = transformPos + lastScrollSize = scrollSize + lastClientSize = clientSize + + const maxScroll = scrollSize - clientSize + const canScroll = maxScroll > 1 + + setIsScrollable(canScroll) + + if (!canScroll) { + setFadeStart(0) + setFadeEnd(0) + return + } + + const progress = maxScroll > 0 ? effectiveScrollPos / maxScroll : 0 + + const startProgress = Math.min(progress / 0.1, 1) + setFadeStart(startProgress * fadeStartSize()) + + const endProgress = progress > 0.9 ? (1 - progress) / 0.1 : 1 + setFadeEnd(Math.max(0, endProgress) * fadeEndSize()) + } + + onMount(() => { + if (!containerRef) return + + updateFade() + + let rafId: number | undefined + let isPolling = false + let pollTimeout: ReturnType | undefined + + const startPolling = () => { + if (isPolling) return + isPolling = true + + const pollScroll = () => { + updateFade() + rafId = requestAnimationFrame(pollScroll) + } + rafId = requestAnimationFrame(pollScroll) + } + + const stopPolling = () => { + if (!isPolling) return + isPolling = false + if (rafId !== undefined) { + cancelAnimationFrame(rafId) + rafId = undefined + } + } + + const schedulePollingStop = () => { + if (pollTimeout !== undefined) clearTimeout(pollTimeout) + pollTimeout = setTimeout(stopPolling, 1000) + } + + const onActivity = () => { + updateFade() + if (local.trackTransformSelector) { + startPolling() + schedulePollingStop() + } + } + + containerRef.addEventListener("scroll", onActivity, { passive: true }) + + const resizeObserver = new ResizeObserver(() => { + lastScrollSize = 0 + lastClientSize = 0 + onActivity() + }) + resizeObserver.observe(containerRef) + + const mutationObserver = new MutationObserver(() => { + lastScrollSize = 0 + lastClientSize = 0 + requestAnimationFrame(onActivity) + }) + mutationObserver.observe(containerRef, { + childList: true, + subtree: true, + characterData: true, + }) + + onCleanup(() => { + containerRef?.removeEventListener("scroll", onActivity) + resizeObserver.disconnect() + mutationObserver.disconnect() + stopPolling() + if (pollTimeout !== undefined) clearTimeout(pollTimeout) + }) + }) + + createEffect(() => { + local.children + requestAnimationFrame(updateFade) + }) + + return ( +
{ + containerRef = el + local.ref?.(el) + }} + data-component="scroll-fade" + data-direction={direction()} + data-scrollable={isScrollable() || undefined} + data-fade-start={fadeStart() > 0 || undefined} + data-fade-end={fadeEnd() > 0 || undefined} + class={local.class} + style={{ + ...(typeof local.style === "object" ? local.style : {}), + "--scroll-fade-start": `${fadeStart()}px`, + "--scroll-fade-end": `${fadeEnd()}px`, + }} + {...others} + > + {local.children} +
+ ) +} \ No newline at end of file diff --git a/packages/ui/src/components/scroll-reveal.tsx b/packages/ui/src/components/scroll-reveal.tsx new file mode 100644 index 0000000000..7eb7ff37fa --- /dev/null +++ b/packages/ui/src/components/scroll-reveal.tsx @@ -0,0 +1,141 @@ +import { type JSX, onCleanup, splitProps } from "solid-js" +import { ScrollFade, type ScrollFadeProps } from './scroll-fade' + +const SCROLL_SPEED = 60 +const PAUSE_DURATION = 800 + +type ScrollAnimationState = { + rafId: number | null + startTime: number + running: boolean +} + +const startScrollAnimation = (containerEl: HTMLElement): ScrollAnimationState | null => { + containerEl.offsetHeight + + const extraWidth = containerEl.scrollWidth - containerEl.clientWidth + + if (extraWidth <= 0) { + return null + } + + const scrollDuration = (extraWidth / SCROLL_SPEED) * 1000 + const totalDuration = PAUSE_DURATION + scrollDuration + PAUSE_DURATION + scrollDuration + PAUSE_DURATION + + const state: ScrollAnimationState = { + rafId: null, + startTime: performance.now(), + running: true, + } + + const animate = (currentTime: number) => { + if (!state.running) return + + const elapsed = currentTime - state.startTime + const progress = (elapsed % totalDuration) / totalDuration + + const pausePercent = PAUSE_DURATION / totalDuration + const scrollPercent = scrollDuration / totalDuration + + const pauseEnd1 = pausePercent + const scrollEnd1 = pauseEnd1 + scrollPercent + const pauseEnd2 = scrollEnd1 + pausePercent + const scrollEnd2 = pauseEnd2 + scrollPercent + + let scrollPos = 0 + + if (progress < pauseEnd1) { + scrollPos = 0 + } else if (progress < scrollEnd1) { + const scrollProgress = (progress - pauseEnd1) / scrollPercent + scrollPos = scrollProgress * extraWidth + } else if (progress < pauseEnd2) { + scrollPos = extraWidth + } else if (progress < scrollEnd2) { + const scrollProgress = (progress - pauseEnd2) / scrollPercent + scrollPos = extraWidth * (1 - scrollProgress) + } else { + scrollPos = 0 + } + + containerEl.scrollLeft = scrollPos + state.rafId = requestAnimationFrame(animate) + } + + state.rafId = requestAnimationFrame(animate) + return state +} + +const stopScrollAnimation = (state: ScrollAnimationState | null, containerEl?: HTMLElement) => { + if (state) { + state.running = false + if (state.rafId !== null) { + cancelAnimationFrame(state.rafId) + } + } + if (containerEl) { + containerEl.scrollLeft = 0 + } +} + +export interface ScrollRevealProps extends Omit { + hoverDelay?: number +} + +export function ScrollReveal(props: ScrollRevealProps) { + const [local, others] = splitProps(props, ["children", "hoverDelay", "ref"]) + + const hoverDelay = () => local.hoverDelay ?? 300 + + let containerRef: HTMLDivElement | undefined + let hoverTimeout: ReturnType | undefined + let scrollAnimationState: ScrollAnimationState | null = null + + const handleMouseEnter: JSX.EventHandler = () => { + hoverTimeout = setTimeout(() => { + if (!containerRef) return + + containerRef.offsetHeight + + const isScrollable = containerRef.scrollWidth > containerRef.clientWidth + 1 + + if (isScrollable) { + stopScrollAnimation(scrollAnimationState, containerRef) + scrollAnimationState = startScrollAnimation(containerRef) + } + }, hoverDelay()) + } + + const handleMouseLeave: JSX.EventHandler = () => { + if (hoverTimeout) { + clearTimeout(hoverTimeout) + hoverTimeout = undefined + } + stopScrollAnimation(scrollAnimationState, containerRef) + scrollAnimationState = null + } + + onCleanup(() => { + if (hoverTimeout) { + clearTimeout(hoverTimeout) + } + stopScrollAnimation(scrollAnimationState, containerRef) + }) + + return ( + { + containerRef = el + local.ref?.(el) + }} + fadeStartSize={8} + fadeEndSize={8} + direction="horizontal" + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + {...others} + > + {local.children} + + ) +} \ No newline at end of file diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index cae48137ba..2a8171f98c 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -40,6 +40,7 @@ @import "../components/select.css" layer(components); @import "../components/spinner.css" layer(components); @import "../components/switch.css" layer(components); +@import "../components/scroll-fade.css" layer(components); @import "../components/session-review.css" layer(components); @import "../components/session-turn.css" layer(components); @import "../components/sticky-accordion-header.css" layer(components); From fa75d922ed9e9c7e11ae43baec933674d5d3012d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 00:18:42 +0000 Subject: [PATCH 19/44] chore: generate --- .../app/src/components/settings-general.tsx | 39 +++++++++++-------- .../app/src/components/settings-models.tsx | 7 +++- .../app/src/components/settings-providers.tsx | 7 +++- packages/ui/src/components/list.tsx | 10 +---- packages/ui/src/components/scroll-fade.tsx | 2 +- packages/ui/src/components/scroll-reveal.tsx | 4 +- 6 files changed, 39 insertions(+), 30 deletions(-) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 1d82a24c30..a0251ed41b 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -61,24 +61,24 @@ export const SettingsGeneral: Component = () => { const actions = platform.update && platform.restart ? [ - { - label: language.t("toast.update.action.installRestart"), - onClick: async () => { - await platform.update!() - await platform.restart!() + { + label: language.t("toast.update.action.installRestart"), + onClick: async () => { + await platform.update!() + await platform.restart!() + }, }, - }, - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] : [ - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] showToast({ persistent: true, @@ -131,7 +131,12 @@ export const SettingsGeneral: Component = () => { const soundOptions = [...SOUND_OPTIONS] return ( - +

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

diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index c9453ddf1b..0ee5caf73d 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -40,7 +40,12 @@ export const SettingsModels: Component = () => { }) return ( - +

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

diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 71a0bb6ad8..2460534c05 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -116,7 +116,12 @@ export const SettingsProviders: Component = () => { } return ( - +

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

diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 950ad3d203..15854180e4 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -268,13 +268,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) {searchAction()}
- + 0 || showAdd()} fallback={ @@ -349,4 +343,4 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
) -} \ No newline at end of file +} diff --git a/packages/ui/src/components/scroll-fade.tsx b/packages/ui/src/components/scroll-fade.tsx index d190d67c03..97f0339e82 100644 --- a/packages/ui/src/components/scroll-fade.tsx +++ b/packages/ui/src/components/scroll-fade.tsx @@ -203,4 +203,4 @@ export function ScrollFade(props: ScrollFadeProps) { {local.children}
) -} \ No newline at end of file +} diff --git a/packages/ui/src/components/scroll-reveal.tsx b/packages/ui/src/components/scroll-reveal.tsx index 7eb7ff37fa..6e5072dc81 100644 --- a/packages/ui/src/components/scroll-reveal.tsx +++ b/packages/ui/src/components/scroll-reveal.tsx @@ -1,5 +1,5 @@ import { type JSX, onCleanup, splitProps } from "solid-js" -import { ScrollFade, type ScrollFadeProps } from './scroll-fade' +import { ScrollFade, type ScrollFadeProps } from "./scroll-fade" const SCROLL_SPEED = 60 const PAUSE_DURATION = 800 @@ -138,4 +138,4 @@ export function ScrollReveal(props: ScrollRevealProps) { {local.children} ) -} \ No newline at end of file +} From 12b8c4238768d4fc3aa57c7023c9724c4f705db2 Mon Sep 17 00:00:00 2001 From: R44VC0RP Date: Sat, 31 Jan 2026 10:27:27 -0500 Subject: [PATCH 20/44] feat(app): show skill/mcp badges for slash commands Display 'skill' or 'mcp' badge instead of 'custom' for slash commands based on their source type. This provides better clarity to users about where each command comes from. --- packages/app/src/components/prompt-input.tsx | 8 +++++++- packages/app/src/i18n/ar.ts | 2 ++ packages/app/src/i18n/br.ts | 2 ++ packages/app/src/i18n/da.ts | 2 ++ packages/app/src/i18n/de.ts | 2 ++ packages/app/src/i18n/en.ts | 2 ++ packages/app/src/i18n/es.ts | 2 ++ packages/app/src/i18n/fr.ts | 2 ++ packages/app/src/i18n/ja.ts | 2 ++ packages/app/src/i18n/ko.ts | 2 ++ packages/app/src/i18n/no.ts | 2 ++ packages/app/src/i18n/pl.ts | 2 ++ packages/app/src/i18n/ru.ts | 2 ++ packages/app/src/i18n/th.ts | 2 ++ packages/app/src/i18n/zh.ts | 2 ++ packages/app/src/i18n/zht.ts | 2 ++ 16 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index c175824deb..9a9d589a7e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -115,6 +115,7 @@ interface SlashCommand { description?: string keybind?: string type: "builtin" | "custom" + source?: "command" | "mcp" | "skill" } export const PromptInput: Component = (props) => { @@ -520,6 +521,7 @@ export const PromptInput: Component = (props) => { title: cmd.name, description: cmd.description, type: "custom" as const, + source: cmd.source, })) return [...custom, ...builtin] @@ -1728,7 +1730,11 @@ export const PromptInput: Component = (props) => {
- {language.t("prompt.slash.badge.custom")} + {cmd.source === "skill" + ? language.t("prompt.slash.badge.skill") + : cmd.source === "mcp" + ? language.t("prompt.slash.badge.mcp") + : language.t("prompt.slash.badge.custom")} diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 80179144a8..e3831e23c4 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "لا توجد أوامر مطابقة", "prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا", "prompt.slash.badge.custom": "مخصص", + "prompt.slash.badge.skill": "مهارة", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "نشط", "prompt.context.includeActiveFile": "تضمين الملف النشط", "prompt.context.removeActiveFile": "إزالة الملف النشط من السياق", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index c874a4376b..f930a66aff 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Nenhum comando correspondente", "prompt.dropzone.label": "Solte imagens ou PDFs aqui", "prompt.slash.badge.custom": "personalizado", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "ativo", "prompt.context.includeActiveFile": "Incluir arquivo ativo", "prompt.context.removeActiveFile": "Remover arquivo ativo do contexto", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 555990a9c4..2b7d77456d 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Ingen matchende kommandoer", "prompt.dropzone.label": "Slip billeder eller PDF'er her", "prompt.slash.badge.custom": "brugerdefineret", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "aktiv", "prompt.context.includeActiveFile": "Inkluder aktiv fil", "prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index e56081c908..4648ad9c41 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -214,6 +214,8 @@ export const dict = { "prompt.popover.emptyCommands": "Keine passenden Befehle", "prompt.dropzone.label": "Bilder oder PDFs hier ablegen", "prompt.slash.badge.custom": "benutzerdefiniert", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "aktiv", "prompt.context.includeActiveFile": "Aktive Datei einbeziehen", "prompt.context.removeActiveFile": "Aktive Datei aus dem Kontext entfernen", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 4254860ac9..12ddcb4cd8 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -216,6 +216,8 @@ export const dict = { "prompt.popover.emptyCommands": "No matching commands", "prompt.dropzone.label": "Drop images or PDFs here", "prompt.slash.badge.custom": "custom", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "active", "prompt.context.includeActiveFile": "Include active file", "prompt.context.removeActiveFile": "Remove active file from context", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index e928f03cec..5d396f0b4f 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Sin comandos coincidentes", "prompt.dropzone.label": "Suelta imágenes o PDFs aquí", "prompt.slash.badge.custom": "personalizado", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "activo", "prompt.context.includeActiveFile": "Incluir archivo activo", "prompt.context.removeActiveFile": "Eliminar archivo activo del contexto", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 31000cd17a..4226d0c7e2 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Aucune commande correspondante", "prompt.dropzone.label": "Déposez des images ou des PDF ici", "prompt.slash.badge.custom": "personnalisé", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "actif", "prompt.context.includeActiveFile": "Inclure le fichier actif", "prompt.context.removeActiveFile": "Retirer le fichier actif du contexte", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 80efc5c2aa..28a925a0d3 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -209,6 +209,8 @@ export const dict = { "prompt.popover.emptyCommands": "一致するコマンドがありません", "prompt.dropzone.label": "画像またはPDFをここにドロップ", "prompt.slash.badge.custom": "カスタム", + "prompt.slash.badge.skill": "スキル", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "アクティブ", "prompt.context.includeActiveFile": "アクティブなファイルを含める", "prompt.context.removeActiveFile": "コンテキストからアクティブなファイルを削除", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 014092d07f..1be4e1eb4b 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -213,6 +213,8 @@ export const dict = { "prompt.popover.emptyCommands": "일치하는 명령어 없음", "prompt.dropzone.label": "이미지나 PDF를 여기에 드롭하세요", "prompt.slash.badge.custom": "사용자 지정", + "prompt.slash.badge.skill": "스킬", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "활성", "prompt.context.includeActiveFile": "활성 파일 포함", "prompt.context.removeActiveFile": "컨텍스트에서 활성 파일 제거", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 400ce37d35..0a3b398856 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -213,6 +213,8 @@ export const dict = { "prompt.popover.emptyCommands": "Ingen matchende kommandoer", "prompt.dropzone.label": "Slipp bilder eller PDF-er her", "prompt.slash.badge.custom": "egendefinert", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "aktiv", "prompt.context.includeActiveFile": "Inkluder aktiv fil", "prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 5a05809829..f4457c6acf 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Brak pasujących poleceń", "prompt.dropzone.label": "Upuść obrazy lub pliki PDF tutaj", "prompt.slash.badge.custom": "własne", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "aktywny", "prompt.context.includeActiveFile": "Dołącz aktywny plik", "prompt.context.removeActiveFile": "Usuń aktywny plik z kontekstu", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 4277368f5d..d5a4014d36 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Нет совпадающих команд", "prompt.dropzone.label": "Перетащите изображения или PDF сюда", "prompt.slash.badge.custom": "своё", + "prompt.slash.badge.skill": "навык", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "активно", "prompt.context.includeActiveFile": "Включить активный файл", "prompt.context.removeActiveFile": "Удалить активный файл из контекста", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index e2eabd7ad8..1914b8e5bd 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -215,6 +215,8 @@ export const dict = { "prompt.popover.emptyCommands": "ไม่พบคำสั่งที่ตรงกัน", "prompt.dropzone.label": "วางรูปภาพหรือ PDF ที่นี่", "prompt.slash.badge.custom": "กำหนดเอง", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "ใช้งานอยู่", "prompt.context.includeActiveFile": "รวมไฟล์ที่ใช้งานอยู่", "prompt.context.removeActiveFile": "เอาไฟล์ที่ใช้งานอยู่ออกจากบริบท", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 118e03ce47..b9d5395730 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -214,6 +214,8 @@ export const dict = { "prompt.popover.emptyCommands": "没有匹配的命令", "prompt.dropzone.label": "将图片或 PDF 拖到这里", "prompt.slash.badge.custom": "自定义", + "prompt.slash.badge.skill": "技能", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "当前", "prompt.context.includeActiveFile": "包含当前文件", "prompt.context.removeActiveFile": "从上下文移除活动文件", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 45a789df4c..23d3d80e13 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -211,6 +211,8 @@ export const dict = { "prompt.popover.emptyCommands": "沒有符合的命令", "prompt.dropzone.label": "將圖片或 PDF 拖到這裡", "prompt.slash.badge.custom": "自訂", + "prompt.slash.badge.skill": "技能", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "作用中", "prompt.context.includeActiveFile": "包含作用中檔案", "prompt.context.removeActiveFile": "從上下文移除目前檔案", From cb6ec0a564cfc332e7af3c844157e145c1d86a60 Mon Sep 17 00:00:00 2001 From: R44VC0RP Date: Sat, 31 Jan 2026 12:11:30 -0500 Subject: [PATCH 21/44] fix(app): hide badge for builtin slash commands Add source: 'command' to builtin and config-defined commands so they don't show a 'custom' badge. Only MCP and skill commands show badges. --- packages/app/src/components/prompt-input.tsx | 2 +- packages/opencode/src/command/index.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 9a9d589a7e..2b63b6f5fd 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1728,7 +1728,7 @@ export const PromptInput: Component = (props) => {
- + {cmd.source === "skill" ? language.t("prompt.slash.badge.skill") diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 14dbeb6794..dce7ac8bbc 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -63,6 +63,7 @@ export namespace Command { [Default.INIT]: { name: Default.INIT, description: "create/update AGENTS.md", + source: "command", get template() { return PROMPT_INITIALIZE.replace("${path}", Instance.worktree) }, @@ -71,6 +72,7 @@ export namespace Command { [Default.REVIEW]: { name: Default.REVIEW, description: "review changes [commit|branch|pr], defaults to uncommitted", + source: "command", get template() { return PROMPT_REVIEW.replace("${path}", Instance.worktree) }, @@ -85,6 +87,7 @@ export namespace Command { agent: command.agent, model: command.model, description: command.description, + source: "command", get template() { return command.template }, From 612b656d3670f252541be79f96bfda31d78dcf73 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 30 Jan 2026 15:55:00 -0600 Subject: [PATCH 22/44] fix: adjust resolve parts so that when messages with multiple @ references occur, the tool calls are properly ordered --- packages/opencode/src/session/processor.ts | 10 +++- packages/opencode/src/session/prompt.ts | 69 ++++++++++++++-------- packages/opencode/src/tool/batch.ts | 8 ++- packages/opencode/src/tool/read.ts | 4 -- packages/opencode/src/tool/tool.ts | 2 +- 5 files changed, 60 insertions(+), 33 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 2707105618..ada6f83145 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -172,6 +172,14 @@ export namespace SessionProcessor { case "tool-result": { const match = toolcalls[value.toolCallId] if (match && match.state.status === "running") { + const attachments = value.output.attachments?.map( + (attachment: Omit) => ({ + ...attachment, + id: Identifier.ascending("part"), + messageID: match.messageID, + sessionID: match.sessionID, + }), + ) await Session.updatePart({ ...match, state: { @@ -184,7 +192,7 @@ export namespace SessionProcessor { start: match.state.time.start, end: Date.now(), }, - attachments: value.output.attachments, + attachments, }, }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ba77cd7ca3..c6d040f2fa 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -187,13 +187,17 @@ export namespace SessionPrompt { text: template, }, ] - const files = ConfigMarkdown.files(template) + const matches = ConfigMarkdown.files(template) const seen = new Set() - await Promise.all( - files.map(async (match) => { - const name = match[1] - if (seen.has(name)) return + const names = matches + .map((match) => match[1]) + .filter((name) => { + if (seen.has(name)) return false seen.add(name) + return true + }) + const resolved = await Promise.all( + names.map(async (name) => { const filepath = name.startsWith("~/") ? path.join(os.homedir(), name.slice(2)) : path.resolve(Instance.worktree, name) @@ -201,33 +205,34 @@ export namespace SessionPrompt { const stats = await fs.stat(filepath).catch(() => undefined) if (!stats) { const agent = await Agent.get(name) - if (agent) { - parts.push({ - type: "agent", - name: agent.name, - }) - } - return + if (!agent) return undefined + return { + type: "agent", + name: agent.name, + } satisfies PromptInput["parts"][number] } if (stats.isDirectory()) { - parts.push({ + return { type: "file", url: `file://${filepath}`, filename: name, mime: "application/x-directory", - }) - return + } satisfies PromptInput["parts"][number] } - parts.push({ + return { type: "file", url: `file://${filepath}`, filename: name, mime: "text/plain", - }) + } satisfies PromptInput["parts"][number] }), ) + for (const item of resolved) { + if (!item) continue + parts.push(item) + } return parts } @@ -424,6 +429,12 @@ export namespace SessionPrompt { assistantMessage.time.completed = Date.now() await Session.updateMessage(assistantMessage) if (result && part.state.status === "running") { + const attachments = result.attachments?.map((attachment) => ({ + ...attachment, + id: Identifier.ascending("part"), + messageID: assistantMessage.id, + sessionID: assistantMessage.sessionID, + })) await Session.updatePart({ ...part, state: { @@ -432,7 +443,7 @@ export namespace SessionPrompt { title: result.title, metadata: result.metadata, output: result.output, - attachments: result.attachments, + attachments, time: { ...part.state.time, end: Date.now(), @@ -771,16 +782,13 @@ export namespace SessionPrompt { ) const textParts: string[] = [] - const attachments: MessageV2.FilePart[] = [] + const attachments: Omit[] = [] for (const contentItem of result.content) { if (contentItem.type === "text") { textParts.push(contentItem.text) } else if (contentItem.type === "image") { attachments.push({ - id: Identifier.ascending("part"), - sessionID: input.session.id, - messageID: input.processor.message.id, type: "file", mime: contentItem.mimeType, url: `data:${contentItem.mimeType};base64,${contentItem.data}`, @@ -792,9 +800,6 @@ export namespace SessionPrompt { } if (resource.blob) { attachments.push({ - id: Identifier.ascending("part"), - sessionID: input.session.id, - messageID: input.processor.message.id, type: "file", mime: resource.mimeType ?? "application/octet-stream", url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`, @@ -1043,6 +1048,7 @@ export namespace SessionPrompt { pieces.push( ...result.attachments.map((attachment) => ({ ...attachment, + id: Identifier.ascending("part"), synthetic: true, filename: attachment.filename ?? part.filename, messageID: info.id, @@ -1180,7 +1186,18 @@ export namespace SessionPrompt { }, ] }), - ).then((x) => x.flat()) + ) + .then((x) => x.flat()) + .then((drafts) => + drafts.map( + (part): MessageV2.Part => ({ + ...part, + id: Identifier.ascending("part"), + messageID: info.id, + sessionID: input.sessionID, + }), + ), + ) await Plugin.trigger( "chat.message", diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts index ba34eb48f5..b5c3ad0a12 100644 --- a/packages/opencode/src/tool/batch.ts +++ b/packages/opencode/src/tool/batch.ts @@ -77,6 +77,12 @@ export const BatchTool = Tool.define("batch", async () => { }) const result = await tool.execute(validatedParams, { ...ctx, callID: partID }) + const attachments = result.attachments?.map((attachment) => ({ + ...attachment, + id: Identifier.ascending("part"), + messageID: ctx.messageID, + sessionID: ctx.sessionID, + })) await Session.updatePart({ id: partID, @@ -91,7 +97,7 @@ export const BatchTool = Tool.define("batch", async () => { output: result.output, title: result.title, metadata: result.metadata, - attachments: result.attachments, + attachments, time: { start: callStartTime, end: Date.now(), diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index f230cdf44c..13236d44dd 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -6,7 +6,6 @@ import { LSP } from "../lsp" import { FileTime } from "../file/time" import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" -import { Identifier } from "../id/id" import { assertExternalDirectory } from "./external-directory" import { InstructionPrompt } from "../session/instruction" @@ -79,9 +78,6 @@ export const ReadTool = Tool.define("read", { }, attachments: [ { - id: Identifier.ascending("part"), - sessionID: ctx.sessionID, - messageID: ctx.messageID, type: "file", mime, url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`, diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 3d17ea192d..0e78ba665c 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -36,7 +36,7 @@ export namespace Tool { title: string metadata: M output: string - attachments?: MessageV2.FilePart[] + attachments?: Omit[] }> formatValidationError?(error: z.ZodError): string }> From 5db089070a24d66063f55d4f5baf0da20883daf9 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 30 Jan 2026 16:04:07 -0600 Subject: [PATCH 23/44] test: add unit test --- packages/opencode/test/session/prompt.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 packages/opencode/test/session/prompt.test.ts diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts new file mode 100644 index 0000000000..e778bfe514 --- /dev/null +++ b/packages/opencode/test/session/prompt.test.ts @@ -0,0 +1,62 @@ +import path from "path" +import { describe, expect, test } from "bun:test" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { MessageV2 } from "../../src/session/message-v2" +import { Instance } from "../../src/project/instance" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +describe("SessionPrompt ordering", () => { + test("keeps @file order with read output parts", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "a.txt"), "28\n") + await Bun.write(path.join(dir, "b.txt"), "42\n") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const template = "What numbers are written in files @a.txt and @b.txt ?" + const parts = await SessionPrompt.resolvePromptParts(template) + const fileParts = parts.filter((part) => part.type === "file") + + expect(fileParts.map((part) => part.filename)).toStrictEqual(["a.txt", "b.txt"]) + + const message = await SessionPrompt.prompt({ + sessionID: session.id, + parts, + noReply: true, + }) + const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + const items = stored.parts + const aPath = path.join(tmp.path, "a.txt") + const bPath = path.join(tmp.path, "b.txt") + const sequence = items.flatMap((part) => { + if (part.type === "text") { + if (part.text.includes(aPath)) return ["input:a"] + if (part.text.includes(bPath)) return ["input:b"] + if (part.text.includes("00001| 28")) return ["output:a"] + if (part.text.includes("00001| 42")) return ["output:b"] + return [] + } + if (part.type === "file") { + if (part.filename === "a.txt") return ["file:a"] + if (part.filename === "b.txt") return ["file:b"] + } + return [] + }) + + expect(sequence).toStrictEqual(["input:a", "output:a", "file:a", "input:b", "output:b", "file:b"]) + + await Session.remove(session.id) + }, + }) + }) +}) From 7417e6eb38e96c5e3904a4503740a305475d154a Mon Sep 17 00:00:00 2001 From: R44VC0RP Date: Fri, 30 Jan 2026 16:13:27 -0500 Subject: [PATCH 24/44] fix(plugin): correct exports to point to dist instead of src The package.json exports were pointing to ./src/*.ts but the published package only includes the dist/ folder. This caused 'Cannot find module' errors when custom tools tried to import @opencode-ai/plugin. Changed exports from: ".": "./src/index.ts" "./tool": "./src/tool.ts" To: ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } "./tool": { "types": "./dist/tool.d.ts", "import": "./dist/tool.js" } --- packages/plugin/package.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 5a4afbae43..160ce6a826 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -9,8 +9,14 @@ "build": "tsc" }, "exports": { - ".": "./src/index.ts", - "./tool": "./src/tool.ts" + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./tool": { + "types": "./dist/tool.d.ts", + "import": "./dist/tool.js" + } }, "files": [ "dist" From d35956fd9272e302201d99548a1389315a20cdaf Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:26:21 -0500 Subject: [PATCH 25/44] ci: prevent rate limit errors when fetching team PRs for beta releases --- script/beta.ts | 52 ++++++++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index 7a3dfcccf4..a17f02a091 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -1,8 +1,11 @@ #!/usr/bin/env bun +import { Script } from "@opencode-ai/script" + interface PR { number: number title: string + author: { login: string } } interface RunResult { @@ -12,15 +15,29 @@ interface RunResult { } async function main() { - console.log("Fetching open contributor PRs...") + console.log("Fetching open PRs from team members...") - const prsResult = await $`gh pr list --label contributor --state open --json number,title --limit 100`.nothrow() - if (prsResult.exitCode !== 0) { - throw new Error(`Failed to fetch PRs: ${prsResult.stderr}`) + const allPrs: PR[] = [] + for (const member of Script.team) { + const result = await $`gh pr list --state open --author ${member} --json number,title,author --limit 100`.nothrow() + if (result.exitCode !== 0) continue + const memberPrs: PR[] = JSON.parse(result.stdout) + allPrs.push(...memberPrs) } - const prs: PR[] = JSON.parse(prsResult.stdout) - console.log(`Found ${prs.length} open contributor PRs`) + const seen = new Set() + const prs = allPrs.filter((pr) => { + if (seen.has(pr.number)) return false + seen.add(pr.number) + return true + }) + + console.log(`Found ${prs.length} open PRs from team members`) + + if (prs.length === 0) { + console.log("No team PRs to merge") + return + } console.log("Fetching latest dev branch...") const fetchDev = await $`git fetch origin dev`.nothrow() @@ -35,7 +52,6 @@ async function main() { } const applied: number[] = [] - const skipped: Array<{ number: number; reason: string }> = [] for (const pr of prs) { console.log(`\nProcessing PR #${pr.number}: ${pr.title}`) @@ -43,9 +59,7 @@ async function main() { console.log(" Fetching PR head...") const fetch = await run(["git", "fetch", "origin", `pull/${pr.number}/head:pr/${pr.number}`]) if (fetch.exitCode !== 0) { - console.log(` Failed to fetch PR head: ${fetch.stderr}`) - skipped.push({ number: pr.number, reason: `Fetch failed: ${fetch.stderr}` }) - continue + throw new Error(`Failed to fetch PR #${pr.number}: ${fetch.stderr}`) } console.log(" Merging...") @@ -55,34 +69,24 @@ async function main() { await $`git merge --abort`.nothrow() await $`git checkout -- .`.nothrow() await $`git clean -fd`.nothrow() - skipped.push({ number: pr.number, reason: "Has conflicts" }) - continue + throw new Error(`Failed to merge PR #${pr.number}: Has conflicts`) } const mergeHead = await $`git rev-parse -q --verify MERGE_HEAD`.nothrow() if (mergeHead.exitCode !== 0) { console.log(" No changes, skipping") - skipped.push({ number: pr.number, reason: "No changes" }) continue } const add = await $`git add -A`.nothrow() if (add.exitCode !== 0) { - console.log(" Failed to stage") - await $`git checkout -- .`.nothrow() - await $`git clean -fd`.nothrow() - skipped.push({ number: pr.number, reason: "Failed to stage" }) - continue + throw new Error(`Failed to stage changes for PR #${pr.number}`) } const commitMsg = `Apply PR #${pr.number}: ${pr.title}` const commit = await run(["git", "commit", "-m", commitMsg]) if (commit.exitCode !== 0) { - console.log(` Failed to commit: ${commit.stderr}`) - await $`git checkout -- .`.nothrow() - await $`git clean -fd`.nothrow() - skipped.push({ number: pr.number, reason: `Commit failed: ${commit.stderr}` }) - continue + throw new Error(`Failed to commit PR #${pr.number}: ${commit.stderr}`) } console.log(" Applied successfully") @@ -92,8 +96,6 @@ async function main() { console.log("\n--- Summary ---") console.log(`Applied: ${applied.length} PRs`) applied.forEach((num) => console.log(` - PR #${num}`)) - console.log(`Skipped: ${skipped.length} PRs`) - skipped.forEach((x) => console.log(` - PR #${x.number}: ${x.reason}`)) console.log("\nForce pushing beta branch...") const push = await $`git push origin beta --force --no-verify`.nothrow() From c9891fe0718551daa9dddccf0aacec1647850786 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:36:40 -0500 Subject: [PATCH 26/44] ci: collect all failed PR merges and notify Discord --- script/beta.ts | 61 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index a17f02a091..5648c8b8a5 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -14,6 +14,42 @@ interface RunResult { stderr: string } +interface FailedPR { + number: number + title: string + reason: string +} + +async function postToDiscord(failures: FailedPR[]) { + const webhookUrl = process.env.DISCORD_ISSUES_WEBHOOK_URL + if (!webhookUrl) { + console.log("Warning: DISCORD_ISSUES_WEBHOOK_URL not set, skipping Discord notification") + return + } + + const message = `**Beta Branch Merge Failures** + +The following team PRs failed to merge into the beta branch: + +${failures.map((f) => `- **#${f.number}**: ${f.title} - ${f.reason}`).join("\n")} + +Please resolve these conflicts manually.` + + const content = JSON.stringify({ content: message }) + + const response = await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: content, + }) + + if (!response.ok) { + console.error("Failed to post to Discord:", await response.text()) + } else { + console.log("Posted failures to Discord") + } +} + async function main() { console.log("Fetching open PRs from team members...") @@ -52,6 +88,7 @@ async function main() { } const applied: number[] = [] + const failed: FailedPR[] = [] for (const pr of prs) { console.log(`\nProcessing PR #${pr.number}: ${pr.title}`) @@ -59,7 +96,9 @@ async function main() { console.log(" Fetching PR head...") const fetch = await run(["git", "fetch", "origin", `pull/${pr.number}/head:pr/${pr.number}`]) if (fetch.exitCode !== 0) { - throw new Error(`Failed to fetch PR #${pr.number}: ${fetch.stderr}`) + console.log(` Failed to fetch: ${fetch.stderr}`) + failed.push({ number: pr.number, title: pr.title, reason: "Fetch failed" }) + continue } console.log(" Merging...") @@ -69,7 +108,8 @@ async function main() { await $`git merge --abort`.nothrow() await $`git checkout -- .`.nothrow() await $`git clean -fd`.nothrow() - throw new Error(`Failed to merge PR #${pr.number}: Has conflicts`) + failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" }) + continue } const mergeHead = await $`git rev-parse -q --verify MERGE_HEAD`.nothrow() @@ -80,13 +120,17 @@ async function main() { const add = await $`git add -A`.nothrow() if (add.exitCode !== 0) { - throw new Error(`Failed to stage changes for PR #${pr.number}`) + console.log(" Failed to stage changes") + failed.push({ number: pr.number, title: pr.title, reason: "Staging failed" }) + continue } const commitMsg = `Apply PR #${pr.number}: ${pr.title}` const commit = await run(["git", "commit", "-m", commitMsg]) if (commit.exitCode !== 0) { - throw new Error(`Failed to commit PR #${pr.number}: ${commit.stderr}`) + console.log(` Failed to commit: ${commit.stderr}`) + failed.push({ number: pr.number, title: pr.title, reason: "Commit failed" }) + continue } console.log(" Applied successfully") @@ -97,6 +141,15 @@ async function main() { console.log(`Applied: ${applied.length} PRs`) applied.forEach((num) => console.log(` - PR #${num}`)) + if (failed.length > 0) { + console.log(`Failed: ${failed.length} PRs`) + failed.forEach((f) => console.log(` - PR #${f.number}: ${f.reason}`)) + + await postToDiscord(failed) + + throw new Error(`${failed.length} PR(s) failed to merge. Check Discord for details.`) + } + console.log("\nForce pushing beta branch...") const push = await $`git push origin beta --force --no-verify`.nothrow() if (push.exitCode !== 0) { From 2f63152af302ed41ab769f12f79c264ee872f063 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:37:09 -0500 Subject: [PATCH 27/44] ci: add DISCORD_ISSUES_WEBHOOK_URL secret to beta workflow --- .github/workflows/beta.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index a793bfb037..6edbb4326c 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -29,4 +29,5 @@ jobs: - name: Sync beta branch env: GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }} + DISCORD_ISSUES_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }} run: bun script/beta.ts From 5dee3328d466708cfd42b2cf5ef77bd40d574d17 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:37:43 -0500 Subject: [PATCH 28/44] ci: add --discord-webhook / -d CLI option for custom Discord webhook URL --- script/beta.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index 5648c8b8a5..fff1e91e0d 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { Script } from "@opencode-ai/script" +import { parseArgs } from "util" interface PR { number: number @@ -20,10 +21,10 @@ interface FailedPR { reason: string } -async function postToDiscord(failures: FailedPR[]) { - const webhookUrl = process.env.DISCORD_ISSUES_WEBHOOK_URL - if (!webhookUrl) { - console.log("Warning: DISCORD_ISSUES_WEBHOOK_URL not set, skipping Discord notification") +async function postToDiscord(failures: FailedPR[], webhookUrl?: string) { + const url = webhookUrl || process.env.DISCORD_ISSUES_WEBHOOK_URL + if (!url) { + console.log("Warning: No Discord webhook URL provided, skipping notification") return } @@ -37,7 +38,7 @@ Please resolve these conflicts manually.` const content = JSON.stringify({ content: message }) - const response = await fetch(webhookUrl, { + const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: content, @@ -51,6 +52,15 @@ Please resolve these conflicts manually.` } async function main() { + const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + "discord-webhook": { type: "string", short: "d" }, + }, + }) + + const discordWebhook = values["discord-webhook"] as string | undefined + console.log("Fetching open PRs from team members...") const allPrs: PR[] = [] @@ -145,7 +155,7 @@ async function main() { console.log(`Failed: ${failed.length} PRs`) failed.forEach((f) => console.log(` - PR #${f.number}: ${f.reason}`)) - await postToDiscord(failed) + await postToDiscord(failed, discordWebhook) throw new Error(`${failed.length} PR(s) failed to merge. Check Discord for details.`) } From e9f8e6aeecbe9d993ddb77129e285b3d31cb4c4a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:38:20 -0500 Subject: [PATCH 29/44] ci: remove parseArgs CLI option and use environment variable directly --- script/beta.ts | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index fff1e91e0d..5648c8b8a5 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -1,7 +1,6 @@ #!/usr/bin/env bun import { Script } from "@opencode-ai/script" -import { parseArgs } from "util" interface PR { number: number @@ -21,10 +20,10 @@ interface FailedPR { reason: string } -async function postToDiscord(failures: FailedPR[], webhookUrl?: string) { - const url = webhookUrl || process.env.DISCORD_ISSUES_WEBHOOK_URL - if (!url) { - console.log("Warning: No Discord webhook URL provided, skipping notification") +async function postToDiscord(failures: FailedPR[]) { + const webhookUrl = process.env.DISCORD_ISSUES_WEBHOOK_URL + if (!webhookUrl) { + console.log("Warning: DISCORD_ISSUES_WEBHOOK_URL not set, skipping Discord notification") return } @@ -38,7 +37,7 @@ Please resolve these conflicts manually.` const content = JSON.stringify({ content: message }) - const response = await fetch(url, { + const response = await fetch(webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: content, @@ -52,15 +51,6 @@ Please resolve these conflicts manually.` } async function main() { - const { values } = parseArgs({ - args: Bun.argv.slice(2), - options: { - "discord-webhook": { type: "string", short: "d" }, - }, - }) - - const discordWebhook = values["discord-webhook"] as string | undefined - console.log("Fetching open PRs from team members...") const allPrs: PR[] = [] @@ -155,7 +145,7 @@ async function main() { console.log(`Failed: ${failed.length} PRs`) failed.forEach((f) => console.log(` - PR #${f.number}: ${f.reason}`)) - await postToDiscord(failed, discordWebhook) + await postToDiscord(failed) throw new Error(`${failed.length} PR(s) failed to merge. Check Discord for details.`) } From 744fb6aed0c29712a3e058ae12cde144fcf026db Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:39:38 -0500 Subject: [PATCH 30/44] ci: rewrite beta script to use proper Bun shell patterns --- script/beta.ts | 102 ++++++++++++++++++------------------------------- 1 file changed, 37 insertions(+), 65 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index 5648c8b8a5..d2d6e9ed41 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun +import { $ } from "bun" import { Script } from "@opencode-ai/script" interface PR { @@ -8,12 +9,6 @@ interface PR { author: { login: string } } -interface RunResult { - exitCode: number - stdout: string - stderr: string -} - interface FailedPR { number: number title: string @@ -55,10 +50,13 @@ async function main() { const allPrs: PR[] = [] for (const member of Script.team) { - const result = await $`gh pr list --state open --author ${member} --json number,title,author --limit 100`.nothrow() - if (result.exitCode !== 0) continue - const memberPrs: PR[] = JSON.parse(result.stdout) - allPrs.push(...memberPrs) + try { + const stdout = await $`gh pr list --state open --author ${member} --json number,title,author --limit 100`.text() + const memberPrs: PR[] = JSON.parse(stdout) + allPrs.push(...memberPrs) + } catch { + // Skip member on error + } } const seen = new Set() @@ -76,16 +74,10 @@ async function main() { } console.log("Fetching latest dev branch...") - const fetchDev = await $`git fetch origin dev`.nothrow() - if (fetchDev.exitCode !== 0) { - throw new Error(`Failed to fetch dev branch: ${fetchDev.stderr}`) - } + await $`git fetch origin dev` console.log("Checking out beta branch...") - const checkoutBeta = await $`git checkout -B beta origin/dev`.nothrow() - if (checkoutBeta.exitCode !== 0) { - throw new Error(`Failed to checkout beta branch: ${checkoutBeta.stderr}`) - } + await $`git checkout -B beta origin/dev` const applied: number[] = [] const failed: FailedPR[] = [] @@ -94,41 +86,52 @@ async function main() { console.log(`\nProcessing PR #${pr.number}: ${pr.title}`) console.log(" Fetching PR head...") - const fetch = await run(["git", "fetch", "origin", `pull/${pr.number}/head:pr/${pr.number}`]) - if (fetch.exitCode !== 0) { - console.log(` Failed to fetch: ${fetch.stderr}`) + try { + await $`git fetch origin pull/${pr.number}/head:pr/${pr.number}` + } catch (err) { + console.log(` Failed to fetch: ${err}`) failed.push({ number: pr.number, title: pr.title, reason: "Fetch failed" }) continue } console.log(" Merging...") - const merge = await run(["git", "merge", "--no-commit", "--no-ff", `pr/${pr.number}`]) - if (merge.exitCode !== 0) { + try { + await $`git merge --no-commit --no-ff pr/${pr.number}` + } catch { console.log(" Failed to merge (conflicts)") - await $`git merge --abort`.nothrow() - await $`git checkout -- .`.nothrow() - await $`git clean -fd`.nothrow() + try { + await $`git merge --abort` + } catch {} + try { + await $`git checkout -- .` + } catch {} + try { + await $`git clean -fd` + } catch {} failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" }) continue } - const mergeHead = await $`git rev-parse -q --verify MERGE_HEAD`.nothrow() - if (mergeHead.exitCode !== 0) { + try { + await $`git rev-parse -q --verify MERGE_HEAD`.text() + } catch { console.log(" No changes, skipping") continue } - const add = await $`git add -A`.nothrow() - if (add.exitCode !== 0) { + try { + await $`git add -A` + } catch { console.log(" Failed to stage changes") failed.push({ number: pr.number, title: pr.title, reason: "Staging failed" }) continue } const commitMsg = `Apply PR #${pr.number}: ${pr.title}` - const commit = await run(["git", "commit", "-m", commitMsg]) - if (commit.exitCode !== 0) { - console.log(` Failed to commit: ${commit.stderr}`) + try { + await $`git commit -m ${commitMsg}` + } catch (err) { + console.log(` Failed to commit: ${err}`) failed.push({ number: pr.number, title: pr.title, reason: "Commit failed" }) continue } @@ -151,10 +154,7 @@ async function main() { } console.log("\nForce pushing beta branch...") - const push = await $`git push origin beta --force --no-verify`.nothrow() - if (push.exitCode !== 0) { - throw new Error(`Failed to push beta branch: ${push.stderr}`) - } + await $`git push origin beta --force --no-verify` console.log("Successfully synced beta branch") } @@ -163,31 +163,3 @@ main().catch((err) => { console.error("Error:", err) process.exit(1) }) - -async function run(args: string[], stdin?: Uint8Array): Promise { - const proc = Bun.spawn(args, { - stdin: stdin ?? "inherit", - stdout: "pipe", - stderr: "pipe", - }) - const exitCode = await proc.exited - const stdout = await new Response(proc.stdout).text() - const stderr = await new Response(proc.stderr).text() - return { exitCode, stdout, stderr } -} - -function $(strings: TemplateStringsArray, ...values: unknown[]) { - const cmd = strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "") - return { - async nothrow() { - const proc = Bun.spawn(cmd.split(" "), { - stdout: "pipe", - stderr: "pipe", - }) - const exitCode = await proc.exited - const stdout = await new Response(proc.stdout).text() - const stderr = await new Response(proc.stderr).text() - return { exitCode, stdout, stderr } - }, - } -} From 425abe2fbfc1a432f6b4c19dd89e59f2d7981fff Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:55:40 -0500 Subject: [PATCH 31/44] ci: post PR comments when beta merge fails instead of Discord notifications --- script/beta.ts | 42 ++++++++++++++---------------------------- 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index d2d6e9ed41..264fa9bc61 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -15,33 +15,18 @@ interface FailedPR { reason: string } -async function postToDiscord(failures: FailedPR[]) { - const webhookUrl = process.env.DISCORD_ISSUES_WEBHOOK_URL - if (!webhookUrl) { - console.log("Warning: DISCORD_ISSUES_WEBHOOK_URL not set, skipping Discord notification") - return - } +async function commentOnPR(prNumber: number, reason: string) { + const body = `⚠️ **Blocking Beta Release** - const message = `**Beta Branch Merge Failures** +This PR cannot be merged into the beta branch due to: **${reason}** -The following team PRs failed to merge into the beta branch: +Please resolve this issue to include this PR in the next beta release.` -${failures.map((f) => `- **#${f.number}**: ${f.title} - ${f.reason}`).join("\n")} - -Please resolve these conflicts manually.` - - const content = JSON.stringify({ content: message }) - - const response = await fetch(webhookUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: content, - }) - - if (!response.ok) { - console.error("Failed to post to Discord:", await response.text()) - } else { - console.log("Posted failures to Discord") + try { + await $`gh pr comment ${prNumber} --body ${body}` + console.log(` Posted comment on PR #${prNumber}`) + } catch (err) { + console.log(` Failed to post comment on PR #${prNumber}: ${err}`) } } @@ -91,6 +76,7 @@ async function main() { } catch (err) { console.log(` Failed to fetch: ${err}`) failed.push({ number: pr.number, title: pr.title, reason: "Fetch failed" }) + await commentOnPR(pr.number, "Fetch failed") continue } @@ -109,6 +95,7 @@ async function main() { await $`git clean -fd` } catch {} failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" }) + await commentOnPR(pr.number, "Merge conflicts with dev branch") continue } @@ -124,6 +111,7 @@ async function main() { } catch { console.log(" Failed to stage changes") failed.push({ number: pr.number, title: pr.title, reason: "Staging failed" }) + await commentOnPR(pr.number, "Failed to stage changes") continue } @@ -133,6 +121,7 @@ async function main() { } catch (err) { console.log(` Failed to commit: ${err}`) failed.push({ number: pr.number, title: pr.title, reason: "Commit failed" }) + await commentOnPR(pr.number, "Failed to commit changes") continue } @@ -147,10 +136,7 @@ async function main() { if (failed.length > 0) { console.log(`Failed: ${failed.length} PRs`) failed.forEach((f) => console.log(` - PR #${f.number}: ${f.reason}`)) - - await postToDiscord(failed) - - throw new Error(`${failed.length} PR(s) failed to merge. Check Discord for details.`) + throw new Error(`${failed.length} PR(s) failed to merge`) } console.log("\nForce pushing beta branch...") From 4158d7cda87863a9b5a4eea29a40270b8a60a68f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:57:49 -0500 Subject: [PATCH 32/44] ci: add --label beta filter to only process PRs with beta label --- script/beta.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index 264fa9bc61..50dc2ad783 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -7,6 +7,7 @@ interface PR { number: number title: string author: { login: string } + labels: Array<{ name: string }> } interface FailedPR { @@ -31,12 +32,13 @@ Please resolve this issue to include this PR in the next beta release.` } async function main() { - console.log("Fetching open PRs from team members...") + console.log("Fetching open PRs with beta label from team members...") const allPrs: PR[] = [] for (const member of Script.team) { try { - const stdout = await $`gh pr list --state open --author ${member} --json number,title,author --limit 100`.text() + const stdout = + await $`gh pr list --state open --author ${member} --label beta --json number,title,author,labels --limit 100`.text() const memberPrs: PR[] = JSON.parse(stdout) allPrs.push(...memberPrs) } catch { @@ -51,7 +53,7 @@ async function main() { return true }) - console.log(`Found ${prs.length} open PRs from team members`) + console.log(`Found ${prs.length} open PRs with beta label from team members`) if (prs.length === 0) { console.log("No team PRs to merge") From 372dcc033c71d0789db25dc7059a5dc9ea3f4cf6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:58:46 -0500 Subject: [PATCH 33/44] ci: change trigger from scheduled cron to PR labeled events with beta label condition --- .github/workflows/beta.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 6edbb4326c..4b418471f8 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -2,14 +2,16 @@ name: beta on: workflow_dispatch: - schedule: - - cron: "0 * * * *" + pull_request: + types: [labeled] jobs: sync: + if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'beta' runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: write + pull-requests: write steps: - name: Checkout repository uses: actions/checkout@v4 @@ -29,5 +31,4 @@ jobs: - name: Sync beta branch env: GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }} - DISCORD_ISSUES_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }} run: bun script/beta.ts From 7837bbc639c79d74ade3e6c397da3cb34a58b198 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:59:05 -0500 Subject: [PATCH 34/44] ci: add synchronize event and check for beta label using contains() --- .github/workflows/beta.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 4b418471f8..c0526ac676 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -3,11 +3,11 @@ name: beta on: workflow_dispatch: pull_request: - types: [labeled] + types: [labeled, synchronize] jobs: sync: - if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'beta' + if: github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'beta') runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: write From f390ac251dc2d7d64a5b1a97b9133fcf65c0a2b1 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 20:17:15 -0500 Subject: [PATCH 35/44] ci: centralize team list in @opencode-ai/script package and use beta label filter --- packages/script/src/index.ts | 17 +++++++++++++++++ script/beta.ts | 25 ++++--------------------- script/changelog.ts | 19 +++---------------- 3 files changed, 24 insertions(+), 37 deletions(-) diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts index 2d991ff0c3..496bdede2d 100644 --- a/packages/script/src/index.ts +++ b/packages/script/src/index.ts @@ -46,6 +46,20 @@ const VERSION = await (async () => { return `${major}.${minor}.${patch + 1}` })() +const team = [ + "actions-user", + "opencode", + "rekram1-node", + "thdxr", + "kommander", + "jayair", + "fwang", + "adamdotdevin", + "iamdavidhill", + "opencode-agent[bot]", + "R44VC0RP", +] + export const Script = { get channel() { return CHANNEL @@ -59,5 +73,8 @@ export const Script = { get release() { return env.OPENCODE_RELEASE }, + get team() { + return team + }, } console.log(`opencode script`, JSON.stringify(Script, null, 2)) diff --git a/script/beta.ts b/script/beta.ts index 50dc2ad783..53329e4dce 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -1,7 +1,6 @@ #!/usr/bin/env bun import { $ } from "bun" -import { Script } from "@opencode-ai/script" interface PR { number: number @@ -32,28 +31,12 @@ Please resolve this issue to include this PR in the next beta release.` } async function main() { - console.log("Fetching open PRs with beta label from team members...") + console.log("Fetching open PRs with beta label...") - const allPrs: PR[] = [] - for (const member of Script.team) { - try { - const stdout = - await $`gh pr list --state open --author ${member} --label beta --json number,title,author,labels --limit 100`.text() - const memberPrs: PR[] = JSON.parse(stdout) - allPrs.push(...memberPrs) - } catch { - // Skip member on error - } - } + const stdout = await $`gh pr list --state open --label beta --json number,title,author,labels --limit 100`.text() + const prs: PR[] = JSON.parse(stdout) - const seen = new Set() - const prs = allPrs.filter((pr) => { - if (seen.has(pr.number)) return false - seen.add(pr.number) - return true - }) - - console.log(`Found ${prs.length} open PRs with beta label from team members`) + console.log(`Found ${prs.length} open PRs with beta label`) if (prs.length === 0) { console.log("No team PRs to merge") diff --git a/script/changelog.ts b/script/changelog.ts index 0043cd3d62..5fc30a228b 100755 --- a/script/changelog.ts +++ b/script/changelog.ts @@ -3,20 +3,7 @@ import { $ } from "bun" import { createOpencode } from "@opencode-ai/sdk/v2" import { parseArgs } from "util" - -export const team = [ - "actions-user", - "opencode", - "rekram1-node", - "thdxr", - "kommander", - "jayair", - "fwang", - "adamdotdevin", - "iamdavidhill", - "opencode-agent[bot]", - "R44VC0RP", -] +import { Script } from "@opencode-ai/script" type Release = { tag_name: string @@ -191,7 +178,7 @@ export async function generateChangelog(commits: Commit[], opencode: Awaited Date: Sun, 1 Feb 2026 20:17:50 -0500 Subject: [PATCH 36/44] ci: run beta workflow on hourly schedule only --- .github/workflows/beta.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index c0526ac676..1ba25bce40 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -1,13 +1,11 @@ name: beta on: - workflow_dispatch: - pull_request: - types: [labeled, synchronize] + schedule: + - cron: "0 * * * *" jobs: sync: - if: github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'beta') runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: write From d3d783e23db89fd020430359979a8c815053a80b Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 20:18:12 -0500 Subject: [PATCH 37/44] ci: allow manual dispatch for beta workflow --- .github/workflows/beta.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 1ba25bce40..20d2bc18d8 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -1,6 +1,7 @@ name: beta on: + workflow_dispatch: schedule: - cron: "0 * * * *" From 3ab41d548f0c2352d6fd351e16d8151bc4c25f00 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 20:35:02 -0500 Subject: [PATCH 38/44] ci: skip force push when beta branch is unchanged --- script/beta.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/script/beta.ts b/script/beta.ts index 53329e4dce..4355c5879c 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -124,7 +124,18 @@ async function main() { throw new Error(`${failed.length} PR(s) failed to merge`) } - console.log("\nForce pushing beta branch...") + console.log("\nChecking if beta branch has changes...") + await $`git fetch origin beta` + + const localTree = await $`git rev-parse beta^{tree}`.text() + const remoteTree = await $`git rev-parse origin/beta^{tree}`.text() + + if (localTree.trim() === remoteTree.trim()) { + console.log("Beta branch has identical contents, no push needed") + return + } + + console.log("Force pushing beta branch...") await $`git push origin beta --force --no-verify` console.log("Successfully synced beta branch") From 6c9b2c37a5e12f0e79ce5a18af33073c9e646cc1 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 20:39:58 -0500 Subject: [PATCH 39/44] core: allow starting new sessions after errors by fixing stuck session status --- packages/opencode/src/session/processor.ts | 1 + packages/opencode/src/session/prompt.ts | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index ada6f83145..24b4a4f9fb 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -368,6 +368,7 @@ export namespace SessionProcessor { sessionID: input.assistantMessage.sessionID, error: input.assistantMessage.error, }) + SessionStatus.set(input.sessionID, { type: "idle" }) } if (snapshot) { const patch = await Snapshot.patch(snapshot) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c6d040f2fa..98dce97ba9 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -62,7 +62,7 @@ export namespace SessionPrompt { abort: AbortController callbacks: { resolve(input: MessageV2.WithParts): void - reject(): void + reject(reason?: any): void }[] } > = {} @@ -72,7 +72,7 @@ export namespace SessionPrompt { for (const item of Object.values(current)) { item.abort.abort() for (const callback of item.callbacks) { - callback.reject() + callback.reject(new DOMException("Aborted", "AbortError")) } } }, @@ -251,10 +251,13 @@ export namespace SessionPrompt { log.info("cancel", { sessionID }) const s = state() const match = s[sessionID] - if (!match) return + if (!match) { + SessionStatus.set(sessionID, { type: "idle" }) + return + } match.abort.abort() for (const item of match.callbacks) { - item.reject() + item.reject(new DOMException("Aborted", "AbortError")) } delete s[sessionID] SessionStatus.set(sessionID, { type: "idle" }) From 83d0e48e3854a4a9f8c28ec17398a8554c988d39 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 20:52:17 -0500 Subject: [PATCH 40/44] tui: fix task status to show current tool state from message store --- AGENTS.md | 1 + .../src/cli/cmd/tui/routes/session/index.tsx | 28 ++++++++++++++----- packages/opencode/src/tool/task.ts | 1 - 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8cfe768da3..3df71f6419 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ - Prefer single word variable names where possible - Use Bun APIs when possible, like `Bun.file()` - Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity +- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream ### Avoid let statements diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index ada84e487b..209469bad8 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1799,9 +1799,19 @@ function Task(props: ToolProps) { const keybind = useKeybind() const { navigate } = useRoute() const local = useLocal() + const sync = useSync() - const current = createMemo(() => props.metadata.summary?.findLast((x) => x.state.status !== "pending")) - const color = createMemo(() => local.agent.color(props.input.subagent_type ?? "unknown")) + const tools = createMemo(() => { + const sessionID = props.metadata.sessionId + const msgs = sync.data.message[sessionID ?? ""] ?? [] + return msgs.flatMap((msg) => + (sync.data.part[msg.id] ?? []) + .filter((part): part is ToolPart => part.type === "tool") + .map((part) => ({ tool: part.tool, state: part.state })), + ) + }) + + const current = createMemo(() => tools().findLast((x) => x.state.status !== "pending")) return ( @@ -1817,13 +1827,17 @@ function Task(props: ToolProps) { > - {props.input.description} ({props.metadata.summary?.length ?? 0} toolcalls) + {props.input.description} ({tools().length} toolcalls) - - └ {Locale.titlecase(current()!.tool)}{" "} - {current()!.state.status === "completed" ? current()!.state.title : ""} - + {(item) => { + const title = item().state.status === "completed" ? (item().state as any).title : "" + return ( + + └ {Locale.titlecase(item().tool)} {title} + + ) + }} diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index ad4268b7b0..ac28d9f322 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -130,7 +130,6 @@ export const TaskTool = Tool.define("task", async (ctx) => { ctx.metadata({ title: params.description, metadata: { - summary: Object.values(parts).sort((a, b) => a.id.localeCompare(b.id)), sessionId: session.id, model, }, From 0f3630d936f8bf8e53edc8050c37696f3a098d10 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 21:01:57 -0500 Subject: [PATCH 41/44] ci: skip unicode filename test due to inconsistent behavior causing CI failures --- packages/opencode/test/snapshot/snapshot.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index de58f4f85e..ef6271ed5d 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -292,7 +292,7 @@ test("unicode filenames", async () => { }) }) -test("unicode filenames modification and restore", async () => { +test.skip("unicode filenames modification and restore", async () => { await using tmp = await bootstrap() await Instance.provide({ directory: tmp.path, From d1f884033f2325ffd344da53b93d89da2ca296ca Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 21:02:26 -0500 Subject: [PATCH 42/44] ignore: switch commit model to kimi-k2.5 for improved command execution reliability --- .opencode/command/commit.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/command/commit.md b/.opencode/command/commit.md index 8260029195..d8a420b173 100644 --- a/.opencode/command/commit.md +++ b/.opencode/command/commit.md @@ -1,6 +1,6 @@ --- description: git commit and push -model: opencode/glm-4.7 +model: opencode/kimi-k2.5 subtask: true --- From 826664b559c3d42f60c8f48fa5dddce997cc0cf0 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 21:16:13 -0500 Subject: [PATCH 43/44] ci: restrict nix-hashes workflow to dev branch pushes only Remove pull_request trigger and limit push trigger to dev branch to prevent unnecessary workflow runs on feature branches and PRs. The workflow will now only execute when dependency files change on the dev branch. --- .github/workflows/nix-hashes.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index cc16d81844..5446f9212f 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -6,13 +6,7 @@ permissions: on: workflow_dispatch: push: - paths: - - "bun.lock" - - "package.json" - - "packages/*/package.json" - - "flake.lock" - - ".github/workflows/nix-hashes.yml" - pull_request: + branches: [dev] paths: - "bun.lock" - "package.json" From 8fbba8de73461b82dfa72c870b8686c9e177ead5 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 1 Feb 2026 21:39:24 -0500 Subject: [PATCH 44/44] ci: Fix Pulumi version conflict in deploy workflow Added a workaround to fix Pulumi version conflict in the deployment workflow. --- .github/workflows/deploy.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 25466a63e0..c08d7edf3b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,6 +21,15 @@ jobs: with: node-version: "24" + # Workaround for Pulumi version conflict: + # GitHub runners have Pulumi 3.212.0+ pre-installed, which removed the -root flag + # from pulumi-language-nodejs (see https://github.com/pulumi/pulumi/pull/21065). + # SST 3.17.x uses Pulumi SDK 3.210.0 which still passes -root, causing a conflict. + # Removing the system language plugin forces SST to use its bundled compatible version. + # TODO: Remove when sst supports Pulumi >3.210.0 + - name: Fix Pulumi version conflict + run: sudo rm -f /usr/local/bin/pulumi-language-nodejs + - run: bun sst deploy --stage=${{ github.ref_name }} env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}