From 8996185f3bf5da3a3dd54303015f5d6a6378d0f7 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Mon, 5 Jan 2026 20:32:16 +0100 Subject: [PATCH 01/55] Feat/clickable subtask (#6846) --- .../src/components/session/session-header.tsx | 69 +++++++++++++++---- packages/app/src/pages/directory-layout.tsx | 14 +++- packages/ui/src/components/basic-tool.css | 10 +++ packages/ui/src/components/basic-tool.tsx | 8 +++ packages/ui/src/components/message-part.tsx | 9 +++ packages/ui/src/context/data.tsx | 10 ++- 6 files changed, 105 insertions(+), 15 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 6c102e4bdfc..ded5d9d0fb7 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -35,7 +35,12 @@ export function SessionHeader() { const projectDirectory = createMemo(() => base64Decode(params.dir ?? "")) const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID)) - const currentSession = createMemo(() => sessions().find((s) => s.id === params.id)) + const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id)) + const parentSession = createMemo(() => { + const current = currentSession() + if (!current?.parentID) return undefined + return sync.data.session.find((s) => s.id === current.parentID) + }) const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same }) @@ -45,6 +50,8 @@ export function SessionHeader() { function navigateToSession(session: Session | undefined) { if (!session) return + // Only navigate if we're actually changing to a different session + if (session.id === params.id) return navigate(`/${params.dir}/session/${session.id}`) } @@ -79,18 +86,56 @@ export function SessionHeader() {
/
- x.title} + value={(x) => x.id} + onSelect={navigateToSession} + class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md" + variant="ghost" + /> + + } + > +
+ x.title} - value={(x) => x.id} - onSelect={(session) => { - // Only navigate if selecting a different session than current parent - const currentParent = parentSession() - if (session && currentParent && session.id !== currentParent.id) { - navigateToSession(session) - } - }} - class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md" - variant="ghost" - /> + options={sessions()} + current={parentSession()} + placeholder="Back to parent session" + label={(x) => x.title} + value={(x) => x.id} + onSelect={(session) => { + // Only navigate if selecting a different session than current parent + const currentParent = parentSession() + if (session && currentParent && session.id !== currentParent.id) { + navigateToSession(session) + } + }} + class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md" + variant="ghost" + />
/
diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 090935763df..12e164b2c4e 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -25,4 +25,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 1eafe6f0597..534d354285d 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -30,4 +30,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +} From 4137c66581d32672cb58c7ec656dec4e8fb5d168 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Mon, 5 Jan 2026 21:17:45 +0100 Subject: [PATCH 03/55] upgrade opentui to v0.1.69, with some text rendering performance improvements --- bun.lock | 20 ++++++++++---------- packages/opencode/package.json | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/bun.lock b/bun.lock index 08021803a09..9bb91c0b520 100644 --- a/bun.lock +++ b/bun.lock @@ -285,8 +285,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.2", - "@opentui/core": "0.1.68", - "@opentui/solid": "0.1.68", + "@opentui/core": "0.1.69", + "@opentui/solid": "0.1.69", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -1197,21 +1197,21 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "@opentui/core": ["@opentui/core@0.1.68", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.68", "@opentui/core-darwin-x64": "0.1.68", "@opentui/core-linux-arm64": "0.1.68", "@opentui/core-linux-x64": "0.1.68", "@opentui/core-win32-arm64": "0.1.68", "@opentui/core-win32-x64": "0.1.68", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-SZz5qNO+2lJ8jDEoTSieyXH23t49myu6NetLex+xzqOf67XsU6QKlDcw5oMmc3zrKvETXhgbBvlSnbyJNQoBMg=="], + "@opentui/core": ["@opentui/core@0.1.69", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.69", "@opentui/core-darwin-x64": "0.1.69", "@opentui/core-linux-arm64": "0.1.69", "@opentui/core-linux-x64": "0.1.69", "@opentui/core-win32-arm64": "0.1.69", "@opentui/core-win32-x64": "0.1.69", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-BcEFnAuMq4vgfb+zxOP/l+NO1AS3fVHkYjn+E8Wpmaxr0AzWNTi2NPAMtQf+Wqufxo0NYh0gY4c9B6n8OxTjGw=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.68", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ipPX2gavBLVtw3d8L4ZPJDLlEwIjIRNdlNlxu07rqSEGSfxD5s29yc+33wLAlYXbmnJDajOqm0Dx6HnlY1Y9Fg=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.69", "", { "os": "darwin", "cpu": "arm64" }, "sha512-d9RPAh84O2XIyMw+7+X0fEyi+4KH5sPk9AxLze8GHRBGOzkRunqagFCLBrN5VFs2e2nbhIYtjMszo7gcpWyh7g=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.68", "", { "os": "darwin", "cpu": "x64" }, "sha512-9dW0S9HINnuVjvC9QLj+S+329H7qEBQQtyJ9WHpykemokiJ5k4rnuDkfws5FxgTHIf/ddoYYTyPoGCS7WN5gsQ=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.69", "", { "os": "darwin", "cpu": "x64" }, "sha512-41K9zkL2IG0ahL+8Gd+e9ulMrnJF6lArPzG7grjWzo+FWEZwvw0WLCO1/Gn5K85G8Yx7gQXkZOUaw1BmHjxoRw=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.68", "", { "os": "linux", "cpu": "arm64" }, "sha512-/el6TbSQriBUfPhIa6SBfCCc7tjU98Bnhf2+w0zKwQFBjf3F3kmnI42++YxedMGFmL7bRt3EUawGOkQRZZzFAg=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.69", "", { "os": "linux", "cpu": "arm64" }, "sha512-IcUjwjuIpX3BBG1a9kjMqWrHYCFHAVfjh5nIRozWZZoqaczLzJb3nJeF2eg8aDeIoGhXvERWB1r1gmqPW8u3vQ=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.68", "", { "os": "linux", "cpu": "x64" }, "sha512-9NzVI3GZzmICoIu3YhWBdkEt0KvY27m++tu/MqW+xb6fnvN74jZkRWzlgjTdM70obL4eUGQdvU08sDHgZjsIJw=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.69", "", { "os": "linux", "cpu": "x64" }, "sha512-5S9vqEIq7q+MEdp4cT0HLegBWu0pWLcletHZL80bsLbJt9OT8en3sQmL5bvas9sIuyeBFru9bfCmrQ/gnVTTiA=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.68", "", { "os": "win32", "cpu": "arm64" }, "sha512-wrAeotyotOplUjQVBSxOGA8GCr9FWXSd6xCEo1PEGo/NjuAOtvHmKoENzyFEP0GzFsjvoUOyy2dZb987oFAn9A=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.69", "", { "os": "win32", "cpu": "arm64" }, "sha512-eSKcGwbcnJJPtrTFJI7STZ7inSYeedHS0swwjZhh9SADAruEz08intamunOslffv5+mnlvRp7UBGK35cMjbv/w=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.68", "", { "os": "win32", "cpu": "x64" }, "sha512-w0yBjvzs/oMIwVdWICL4XlUrfsPoVXd4+RDqiuu+Xi/zD0UgANSTRY2asXca+gPe5zPHLsxvz1bAG0Z7uGtmyw=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.69", "", { "os": "win32", "cpu": "x64" }, "sha512-OjG/0jqYXURqbbUwNgSPrBA6yuKF3OOFh8JSG7VvzoYHJFJRmwVWY0fztWv/hgGHe354ti37c7JDJBQ44HOCdA=="], - "@opentui/solid": ["@opentui/solid@0.1.68", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.68", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-S1oHvCQaY+gCQu2kiiksPIScP8i0FiDOlAlLjtfwcRlgeSjzT0wRwFkvoh4uVUPuAlyigox7vMCE3j04SYSGKg=="], + "@opentui/solid": ["@opentui/solid@0.1.69", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.69", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-ls589N8P9gvcNW8uF+Il4xisF5Uouk0RRmSaLdzmItNJSW5J9Y0nPtMELta6hBp0yIRAurWUO1wtkKXVF+eaxg=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 5805c576e19..0d2a6d2b230 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -81,8 +81,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.2", - "@opentui/core": "0.1.68", - "@opentui/solid": "0.1.68", + "@opentui/core": "0.1.69", + "@opentui/solid": "0.1.69", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", From 19123b6803d1720d83dfd2388b0396971ddb179e Mon Sep 17 00:00:00 2001 From: Github Action Date: Mon, 5 Jan 2026 20:19:14 +0000 Subject: [PATCH 04/55] Update Nix flake.lock and hashes --- nix/hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/hashes.json b/nix/hashes.json index 65b820cc430..b3f1b8e2a96 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-OJ3C4RMzfbbG1Fwa/5yru0rlISj+28UPITMNBEU5AeM=" + "nodeModules": "sha256-mZGKIkOLmesEhCpEZTLiPbBisZOxdZ1NgqnRnVHJlLU=" } From 2ca0ae77557ae759f8463f82f67df4a132f5f749 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:11:55 -0600 Subject: [PATCH 05/55] fix(app): more defensive, handle no git --- packages/app/src/pages/session.tsx | 2 +- packages/app/src/utils/prompt.ts | 41 +++++++++---- packages/opencode/src/file/watcher.ts | 1 + packages/opencode/src/project/project.ts | 59 +++++++++++++++++-- packages/opencode/src/snapshot/index.ts | 6 +- packages/opencode/src/util/filesystem.ts | 4 +- packages/ui/src/components/session-review.tsx | 4 +- 7 files changed, 94 insertions(+), 23 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 1a967759d3c..a0de9021c9d 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -502,7 +502,7 @@ export default function Page() { // Restore the prompt from the reverted message const parts = sync.data.part[message.id] if (parts) { - const restored = extractPromptFromParts(parts) + const restored = extractPromptFromParts(parts, { directory: sdk.directory }) prompt.set(restored) } // Navigate to the message before the reverted one (which will be the new last visible message) diff --git a/packages/app/src/utils/prompt.ts b/packages/app/src/utils/prompt.ts index 29a774c2a29..5d9edfed109 100644 --- a/packages/app/src/utils/prompt.ts +++ b/packages/app/src/utils/prompt.ts @@ -53,9 +53,25 @@ function textPartValue(parts: Part[]) { * Extract prompt content from message parts for restoring into the prompt input. * This is used by undo to restore the original user prompt. */ -export function extractPromptFromParts(parts: Part[]): Prompt { +export function extractPromptFromParts(parts: Part[], opts?: { directory?: string }): Prompt { const textPart = textPartValue(parts) const text = textPart?.text ?? "" + const directory = opts?.directory + + const toRelative = (path: string) => { + if (!directory) return path + + const prefix = directory.endsWith("/") ? directory : directory + "/" + if (path.startsWith(prefix)) return path.slice(prefix.length) + + if (path.startsWith(directory)) { + const next = path.slice(directory.length) + if (next.startsWith("/")) return next.slice(1) + return next + } + + return path + } const inline: Inline[] = [] const images: ImageAttachmentPart[] = [] @@ -78,7 +94,7 @@ export function extractPromptFromParts(parts: Part[]): Prompt { start, end, value, - path, + path: toRelative(path), selection: selectionFromFileUrl(filePart.url), }) continue @@ -158,20 +174,21 @@ export function extractPromptFromParts(parts: Part[]): Prompt { for (const item of inline) { if (item.start < 0 || item.end < item.start) continue - if (item.end > text.length) continue - if (item.start < cursor) continue - pushText(text.slice(cursor, item.start)) + const expected = item.value + if (!expected) continue - if (item.type === "file") { - pushFile(item) - } + const mismatch = item.end > text.length || item.start < cursor || text.slice(item.start, item.end) !== expected + const start = mismatch ? text.indexOf(expected, cursor) : item.start + if (start === -1) continue + const end = mismatch ? start + expected.length : item.end - if (item.type === "agent") { - pushAgent(item) - } + pushText(text.slice(cursor, start)) + + if (item.type === "file") pushFile(item) + if (item.type === "agent") pushAgent(item) - cursor = item.end + cursor = end } pushText(text.slice(cursor)) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 334b2d2648e..44f8a0a3a4a 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -85,6 +85,7 @@ export namespace FileWatcher { .cwd(Instance.worktree) .text() .then((x) => path.resolve(Instance.worktree, x.trim())) + .catch(() => undefined) if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { const gitDirContents = await readdir(vcsDir).catch(() => []) const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD") diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 8b78553bfd4..ea59f991e31 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -53,11 +53,22 @@ export namespace Project { if (git) { let sandbox = path.dirname(git) + const gitBinary = Bun.which("git") + // cached id calculation let id = await Bun.file(path.join(git, "opencode")) .text() .then((x) => x.trim()) - .catch(() => {}) + .catch(() => undefined) + + if (!gitBinary) { + return { + id: id ?? "global", + worktree: sandbox, + sandbox: sandbox, + vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), + } + } // generate id from root commit if (!id) { @@ -73,24 +84,53 @@ export namespace Project { .map((x) => x.trim()) .toSorted(), ) + .catch(() => undefined) + + if (!roots) { + return { + id: "global", + worktree: sandbox, + sandbox: sandbox, + vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), + } + } + id = roots[0] - if (id) Bun.file(path.join(git, "opencode")).write(id) + if (id) { + void Bun.file(path.join(git, "opencode")) + .write(id) + .catch(() => undefined) + } } - if (!id) + if (!id) { return { id: "global", worktree: sandbox, sandbox: sandbox, vcs: "git", } + } - sandbox = await $`git rev-parse --show-toplevel` + const top = await $`git rev-parse --show-toplevel` .quiet() .nothrow() .cwd(sandbox) .text() .then((x) => path.resolve(sandbox, x.trim())) + .catch(() => undefined) + + if (!top) { + return { + id, + sandbox, + worktree: sandbox, + vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), + } + } + + sandbox = top + const worktree = await $`git rev-parse --git-common-dir` .quiet() .nothrow() @@ -101,6 +141,17 @@ export namespace Project { if (dirname === ".") return sandbox return dirname }) + .catch(() => undefined) + + if (!worktree) { + return { + id, + sandbox, + worktree: sandbox, + vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), + } + } + return { id, sandbox, diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 0bbb1115e61..69f2abc7903 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -179,12 +179,14 @@ export namespace Snapshot { .quiet() .nothrow() .text() + const added = isBinaryFile ? 0 : parseInt(additions) + const deleted = isBinaryFile ? 0 : parseInt(deletions) result.push({ file, before, after, - additions: parseInt(additions), - deletions: parseInt(deletions), + additions: Number.isFinite(added) ? added : 0, + deletions: Number.isFinite(deleted) ? deleted : 0, }) } return result diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 98fbe533de3..472bff83dd3 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -31,7 +31,7 @@ export namespace Filesystem { const result = [] while (true) { const search = join(current, target) - if (await exists(search)) result.push(search) + if (await exists(search).catch(() => false)) result.push(search) if (stop === current) break const parent = dirname(current) if (parent === current) break @@ -46,7 +46,7 @@ export namespace Filesystem { while (true) { for (const target of targets) { const search = join(current, target) - if (await exists(search)) yield search + if (await exists(search).catch(() => false)) yield search } if (stop === current) break const parent = dirname(current) diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 275077a5828..e11df6c9fa4 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -123,11 +123,11 @@ export const SessionReview = (props: SessionReviewProps) => { diffStyle={diffStyle()} before={{ name: diff.file!, - contents: diff.before!, + contents: typeof diff.before === "string" ? diff.before : "", }} after={{ name: diff.file!, - contents: diff.after!, + contents: typeof diff.after === "string" ? diff.after : "", }} /> From 5c66c8b8e190af3076d0430f8ec5317888a639ea Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 5 Jan 2026 16:37:18 -0600 Subject: [PATCH 06/55] fix(core): filter dead worktrees --- packages/opencode/src/project/project.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index ea59f991e31..35fdd4717b2 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -12,6 +12,7 @@ import { fn } from "@opencode-ai/util/fn" import { BusEvent } from "@/bus/bus-event" import { iife } from "@/util/iife" import { GlobalBus } from "@/bus/global" +import { existsSync } from "fs" export namespace Project { const log = Log.create({ service: "project" }) @@ -199,6 +200,7 @@ export namespace Project { }, } if (sandbox !== result.worktree && !result.sandboxes.includes(sandbox)) result.sandboxes.push(sandbox) + result.sandboxes = result.sandboxes.filter((x) => existsSync(x)) await Storage.write(["project", id], result) GlobalBus.emit("event", { payload: { From 4dc3cb911551563a5e648e4baa58bac3e8a994df Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 5 Jan 2026 14:27:25 -0500 Subject: [PATCH 07/55] wip: zen --- packages/console/core/script/lookup-user.ts | 118 +++++++++++++++++--- 1 file changed, 105 insertions(+), 13 deletions(-) diff --git a/packages/console/core/script/lookup-user.ts b/packages/console/core/script/lookup-user.ts index 1ae18c4ddae..bb919e34dca 100644 --- a/packages/console/core/script/lookup-user.ts +++ b/packages/console/core/script/lookup-user.ts @@ -1,26 +1,118 @@ -import { Database, eq } from "../src/drizzle/index.js" -import { AuthTable } from "../src/schema/auth.sql" +import { Database, eq, sql, inArray } from "../src/drizzle/index.js" +import { AuthTable } from "../src/schema/auth.sql.js" +import { UserTable } from "../src/schema/user.sql.js" +import { BillingTable, PaymentTable } from "../src/schema/billing.sql.js" +import { WorkspaceTable } from "../src/schema/workspace.sql.js" // get input from command line -const email = process.argv[2] -if (!email) { - console.error("Usage: bun lookup-user.ts ") +const identifier = process.argv[2] +if (!identifier) { + console.error("Usage: bun lookup-user.ts ") process.exit(1) } -const authData = await printTable("Auth", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.subject, email))) -if (authData.length === 0) { - console.error("User not found") - process.exit(1) +if (identifier.startsWith("wrk_")) { + await printWorkspace(identifier) +} else { + const authData = await printTable("Email", (tx) => + tx.select().from(AuthTable).where(eq(AuthTable.subject, identifier)), + ) + if (authData.length === 0) { + console.error("Email not found") + process.exit(1) + } + if (authData.length > 1) console.warn("Multiple users found for email", identifier) + + // Get all auth records for email + const accountID = authData[0].accountID + await printTable("Auth Records", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.accountID, accountID))) + + // Get all workspaces for this account + const users = await printTable("Workspaces", (tx) => + tx + .select({ + userID: UserTable.id, + workspaceID: UserTable.workspaceID, + workspaceName: WorkspaceTable.name, + role: UserTable.role, + }) + .from(UserTable) + .innerJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID)) + .where(eq(UserTable.accountID, accountID)), + ) + + // Get all payments for these workspaces + await Promise.all(users.map((u: { workspaceID: string }) => printWorkspace(u.workspaceID))) +} + +async function printWorkspace(workspaceID: string) { + const workspace = await Database.use((tx) => + tx + .select() + .from(WorkspaceTable) + .where(eq(WorkspaceTable.id, workspaceID)) + .then((rows) => rows[0]), + ) + + printHeader(`Workspace "${workspace.name}" (${workspace.id})`) + + await printTable("Billing", (tx) => + tx + .select({ + balance: BillingTable.balance, + }) + .from(BillingTable) + .where(eq(BillingTable.workspaceID, workspace.id)) + .then( + (rows) => + rows.map((row) => ({ + ...row, + balance: `$${(row.balance / 100000000).toFixed(2)}`, + }))[0], + ), + ) + + await printTable("Payments", (tx) => + tx + .select({ + amount: PaymentTable.amount, + paymentID: PaymentTable.paymentID, + invoiceID: PaymentTable.invoiceID, + timeCreated: PaymentTable.timeCreated, + timeRefunded: PaymentTable.timeRefunded, + }) + .from(PaymentTable) + .where(eq(PaymentTable.workspaceID, workspace.id)) + .orderBy(sql`${PaymentTable.timeCreated} DESC`) + .limit(100) + .then((rows) => + rows.map((row) => ({ + ...row, + amount: `$${(row.amount / 100000000).toFixed(2)}`, + paymentID: row.paymentID + ? `https://dashboard.stripe.com/acct_1RszBH2StuRr0lbX/payments/${row.paymentID}` + : null, + })), + ), + ) } -await printTable("Auth", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.accountID, authData[0].accountID))) +function printHeader(title: string) { + console.log() + console.log("─".repeat(title.length)) + console.log(`${title}`) + console.log("─".repeat(title.length)) +} -function printTable(title: string, callback: (tx: Database.TxOrDb) => Promise): Promise { +function printTable(title: string, callback: (tx: Database.TxOrDb) => Promise): Promise { return Database.use(async (tx) => { const data = await callback(tx) - console.log(`== ${title} ==`) - console.table(data) + console.log(`\n== ${title} ==`) + if (data.length === 0) { + console.log("(no data)") + } else { + console.table(data) + } return data }) } From cf069dd046d0a91dde33e4e5c3e02771b8a19cf6 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 5 Jan 2026 18:09:37 -0500 Subject: [PATCH 08/55] wip: zen --- .../[id]/billing/payment-section.module.css | 26 +++++++++++++++-- .../[id]/billing/payment-section.tsx | 28 +++++++++++-------- .../console/core/script/credit-workspace.ts | 20 +++++++++++++ packages/console/core/script/lookup-user.ts | 28 ++++++++++++++++++- packages/console/core/src/billing.ts | 18 ++++++++++++ 5 files changed, 105 insertions(+), 15 deletions(-) create mode 100644 packages/console/core/script/credit-workspace.ts diff --git a/packages/console/app/src/routes/workspace/[id]/billing/payment-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.module.css index 2e1afe78bcc..3a3b2f7a8f5 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/payment-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.module.css @@ -45,6 +45,19 @@ text-decoration: line-through; } } + + &[data-slot="payment-receipt"] { + span { + display: inline-block; + padding: var(--space-3) var(--space-4); + font-size: var(--font-size-sm); + line-height: 1.5; + } + + button { + font-size: var(--font-size-sm); + } + } } tbody tr { @@ -54,6 +67,7 @@ } @media (max-width: 40rem) { + th, td { padding: var(--space-2) var(--space-3); @@ -61,16 +75,22 @@ } th { - &:nth-child(2) /* Payment ID */ { + &:nth-child(2) + + /* Payment ID */ + { display: none; } } td { - &:nth-child(2) /* Payment ID */ { + &:nth-child(2) + + /* Payment ID */ + { display: none; } } } } -} +} \ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx index 3712513bfe7..e51ccc9f531 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx @@ -77,6 +77,7 @@ export function PaymentSection() { {(payment) => { const date = new Date(payment.timeCreated) + const isCredit = !payment.paymentID return ( @@ -85,19 +86,24 @@ export function PaymentSection() { {payment.id} ${((payment.amount ?? 0) / 100000000).toFixed(2)} + {isCredit ? " (credit)" : ""} - + {isCredit ? ( + - + ) : ( + + )} ) diff --git a/packages/console/core/script/credit-workspace.ts b/packages/console/core/script/credit-workspace.ts new file mode 100644 index 00000000000..29fb1fa648b --- /dev/null +++ b/packages/console/core/script/credit-workspace.ts @@ -0,0 +1,20 @@ +import { Billing } from "../src/billing.js" + +// get input from command line +const workspaceID = process.argv[2] +const dollarAmount = process.argv[3] + +if (!workspaceID || !dollarAmount) { + console.error("Usage: bun credit-workspace.ts ") + process.exit(1) +} + +const amountInDollars = parseFloat(dollarAmount) +if (isNaN(amountInDollars) || amountInDollars <= 0) { + console.error("Error: dollarAmount must be a positive number") + process.exit(1) +} + +await Billing.grantCredit(workspaceID, amountInDollars) + +console.log(`Added payment of $${amountInDollars.toFixed(2)} to workspace ${workspaceID}`) diff --git a/packages/console/core/script/lookup-user.ts b/packages/console/core/script/lookup-user.ts index bb919e34dca..d0b583a1812 100644 --- a/packages/console/core/script/lookup-user.ts +++ b/packages/console/core/script/lookup-user.ts @@ -1,7 +1,7 @@ import { Database, eq, sql, inArray } from "../src/drizzle/index.js" import { AuthTable } from "../src/schema/auth.sql.js" import { UserTable } from "../src/schema/user.sql.js" -import { BillingTable, PaymentTable } from "../src/schema/billing.sql.js" +import { BillingTable, PaymentTable, UsageTable } from "../src/schema/billing.sql.js" import { WorkspaceTable } from "../src/schema/workspace.sql.js" // get input from command line @@ -95,6 +95,32 @@ async function printWorkspace(workspaceID: string) { })), ), ) + + await printTable("Usage", (tx) => + tx + .select({ + model: UsageTable.model, + provider: UsageTable.provider, + inputTokens: UsageTable.inputTokens, + outputTokens: UsageTable.outputTokens, + reasoningTokens: UsageTable.reasoningTokens, + cacheReadTokens: UsageTable.cacheReadTokens, + cacheWrite5mTokens: UsageTable.cacheWrite5mTokens, + cacheWrite1hTokens: UsageTable.cacheWrite1hTokens, + cost: UsageTable.cost, + timeCreated: UsageTable.timeCreated, + }) + .from(UsageTable) + .where(eq(UsageTable.workspaceID, workspace.id)) + .orderBy(sql`${UsageTable.timeCreated} DESC`) + .limit(1000) + .then((rows) => + rows.map((row) => ({ + ...row, + cost: `$${(row.cost / 100000000).toFixed(2)}`, + })), + ), + ) } function printHeader(title: string) { diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index 049ee29bbe3..c14df11ae7d 100644 --- a/packages/console/core/src/billing.ts +++ b/packages/console/core/src/billing.ts @@ -157,6 +157,24 @@ export namespace Billing { }) } + export const grantCredit = async (workspaceID: string, dollarAmount: number) => { + const amountInMicroCents = centsToMicroCents(dollarAmount * 100) + await Database.transaction(async (tx) => { + await tx + .update(BillingTable) + .set({ + balance: sql`${BillingTable.balance} + ${amountInMicroCents}`, + }) + .where(eq(BillingTable.workspaceID, workspaceID)) + await tx.insert(PaymentTable).values({ + workspaceID, + id: Identifier.create("payment"), + amount: amountInMicroCents, + }) + }) + return amountInMicroCents + } + export const setMonthlyLimit = fn(z.number(), async (input) => { return await Database.use((tx) => tx From 21053732e77e7f38d0862dd2b00ad1a987a9a191 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 5 Jan 2026 23:10:23 +0000 Subject: [PATCH 09/55] chore: generate --- .../workspace/[id]/billing/payment-section.module.css | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/console/app/src/routes/workspace/[id]/billing/payment-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.module.css index 3a3b2f7a8f5..6452484ed38 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/payment-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.module.css @@ -67,7 +67,6 @@ } @media (max-width: 40rem) { - th, td { padding: var(--space-2) var(--space-3); @@ -77,8 +76,7 @@ th { &:nth-child(2) - /* Payment ID */ - { + /* Payment ID */ { display: none; } } @@ -86,11 +84,10 @@ td { &:nth-child(2) - /* Payment ID */ - { + /* Payment ID */ { display: none; } } } } -} \ No newline at end of file +} From 8da890649f14d3cf78ef6c0917eae08f2b66d0c9 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 5 Jan 2026 22:48:05 -0500 Subject: [PATCH 10/55] wip: zen --- packages/console/core/script/lookup-user.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/console/core/script/lookup-user.ts b/packages/console/core/script/lookup-user.ts index d0b583a1812..0b8e864409d 100644 --- a/packages/console/core/script/lookup-user.ts +++ b/packages/console/core/script/lookup-user.ts @@ -14,7 +14,7 @@ if (!identifier) { if (identifier.startsWith("wrk_")) { await printWorkspace(identifier) } else { - const authData = await printTable("Email", (tx) => + const authData = await Database.use(async (tx) => tx.select().from(AuthTable).where(eq(AuthTable.subject, identifier)), ) if (authData.length === 0) { @@ -25,7 +25,7 @@ if (identifier.startsWith("wrk_")) { // Get all auth records for email const accountID = authData[0].accountID - await printTable("Auth Records", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.accountID, accountID))) + await printTable("Auth", (tx) => tx.select().from(AuthTable).where(eq(AuthTable.accountID, accountID))) // Get all workspaces for this account const users = await printTable("Workspaces", (tx) => @@ -60,6 +60,7 @@ async function printWorkspace(workspaceID: string) { tx .select({ balance: BillingTable.balance, + customerID: BillingTable.customerID, }) .from(BillingTable) .where(eq(BillingTable.workspaceID, workspace.id)) @@ -113,7 +114,7 @@ async function printWorkspace(workspaceID: string) { .from(UsageTable) .where(eq(UsageTable.workspaceID, workspace.id)) .orderBy(sql`${UsageTable.timeCreated} DESC`) - .limit(1000) + .limit(10) .then((rows) => rows.map((row) => ({ ...row, From 9197a2a7a169cfbee1169073c798fe8b11da9750 Mon Sep 17 00:00:00 2001 From: "Guofang.Tang" <37588782+T1mn@users.noreply.github.com> Date: Tue, 6 Jan 2026 12:45:40 +0800 Subject: [PATCH 11/55] docs: polish markdown wording and capitalization (#7019) Co-authored-by: Tang Guofang --- README.md | 3 +-- packages/app/README.md | 4 ++-- packages/desktop/README.md | 2 +- .../src/provider/sdk/openai-compatible/src/README.md | 6 +++--- specs/project.md | 3 +-- 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a5b35466c5a..15c382e638c 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,7 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash ### Agents -OpenCode includes two built-in agents you can switch between, -you can switch between these using the `Tab` key. +OpenCode includes two built-in agents you can switch between with the `Tab` key. - **build** - Default, full access agent for development work - **plan** - Read-only agent for analysis and code exploration diff --git a/packages/app/README.md b/packages/app/README.md index 6a176453668..bd10e6c8ddf 100644 --- a/packages/app/README.md +++ b/packages/app/README.md @@ -1,8 +1,8 @@ ## Usage -Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`. +Dependencies for these templates are managed with [pnpm](https://pnpm.io) using `pnpm up -Lri`. -This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template. +This is the reason you see a `pnpm-lock.yaml`. That said, any package manager will work. This file can safely be removed once you clone a template. ```bash $ npm install # or pnpm install or yarn install diff --git a/packages/desktop/README.md b/packages/desktop/README.md index b381dcf5bf4..7567e65f50e 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -1,6 +1,6 @@ # Tauri + Vanilla TS -This template should help get you started developing with Tauri in vanilla HTML, CSS and Typescript. +This template should help get you started developing with Tauri in vanilla HTML, CSS and TypeScript. ## Recommended IDE Setup diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/README.md b/packages/opencode/src/provider/sdk/openai-compatible/src/README.md index 593e644538f..8ce03d61407 100644 --- a/packages/opencode/src/provider/sdk/openai-compatible/src/README.md +++ b/packages/opencode/src/provider/sdk/openai-compatible/src/README.md @@ -1,5 +1,5 @@ -This is a temporary package used primarily for github copilot compatibility. +This is a temporary package used primarily for GitHub Copilot compatibility. -Avoid making changes to these files unless you want to only affect Copilot provider. +Avoid making changes to these files unless you only want to affect the Copilot provider. -Also this should ONLY be used for Copilot provider. +Also, this should ONLY be used for the Copilot provider. diff --git a/specs/project.md b/specs/project.md index dd51f0e7f79..97bacbbcae3 100644 --- a/specs/project.md +++ b/specs/project.md @@ -1,7 +1,6 @@ ## project -goal is to let a single instance of opencode be able to run sessions for -multiple projects and different worktrees per project +The goal is to let a single instance of OpenCode run sessions for multiple projects and different worktrees per project. ### api From d486c1c7c82ddd073225b28f639693df4af7aaea Mon Sep 17 00:00:00 2001 From: Shane Bishop <71288697+shanebishop1@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:10:50 -0800 Subject: [PATCH 12/55] docs: fix order of permissions in agents docs (permissions subsection) (#7041) --- packages/web/src/content/docs/agents.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index c6e919e4073..f3f0b52eb12 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -426,9 +426,9 @@ mode: subagent permission: edit: deny bash: + "*": ask "git diff": allow "git log*": allow - "*": ask webfetch: deny --- @@ -470,7 +470,7 @@ This can take a glob pattern. ``` And you can also use the `*` wildcard to manage permissions for all commands. -Where the specific rule can override the `*` wildcard. +Since the last matching rule takes precedence, put the `*` wildcard first and specific rules after. ```json title="opencode.json" {8} { @@ -479,8 +479,8 @@ Where the specific rule can override the `*` wildcard. "build": { "permission": { "bash": { - "git status": "allow", - "*": "ask" + "*": "ask", + "git status": "allow" } } } From a7218106826b3df00a18525d380985fd038a0c6d Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 5 Jan 2026 23:24:58 -0600 Subject: [PATCH 13/55] ci: tweak prompt --- .opencode/agent/duplicate-pr.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/agent/duplicate-pr.md b/.opencode/agent/duplicate-pr.md index 25714550890..c9c932ef790 100644 --- a/.opencode/agent/duplicate-pr.md +++ b/.opencode/agent/duplicate-pr.md @@ -21,6 +21,6 @@ If you find potential duplicates: - List them with their titles and URLs - Briefly explain why they might be related -If no duplicates are found, say so clearly. +If no duplicates are found, say so clearly. BUT ONLY SAY "No duplicate PRs found" (don't say anything else if no dups) Keep your response concise and actionable. From 45fea6587e32eb578f0cc563a15345905c619b0d Mon Sep 17 00:00:00 2001 From: Beryl <88883166+xiliumz@users.noreply.github.com> Date: Tue, 6 Jan 2026 12:26:44 +0700 Subject: [PATCH 14/55] fix: use actual version in install script (#7044) --- install | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/install b/install index 3b9e6d68c56..757694481c4 100755 --- a/install +++ b/install @@ -208,11 +208,8 @@ check_version() { if command -v opencode >/dev/null 2>&1; then opencode_path=$(which opencode) - - ## TODO: check if version is installed - # installed_version=$(opencode version) - installed_version="0.0.1" - installed_version=$(echo $installed_version | awk '{print $2}') + ## Check the installed version + installed_version=$(opencode --version 2>/dev/null || echo "") if [[ "$installed_version" != "$specific_version" ]]; then print_message info "${MUTED}Installed version: ${NC}$installed_version." From f510d17bd364dc107854ebc4bc0a0635f1bca522 Mon Sep 17 00:00:00 2001 From: Junseo5 Date: Tue, 6 Jan 2026 15:19:29 +0900 Subject: [PATCH 15/55] fix(desktop): add single-instance plugin to prevent multiple windows (#6966) --- packages/desktop/src-tauri/Cargo.toml | 1 + packages/desktop/src-tauri/src/lib.rs | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 9afeee94597..63aafe28010 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ tauri-plugin-window-state = "2" tauri-plugin-clipboard-manager = "2" tauri-plugin-http = "2" tauri-plugin-notification = "2" +tauri-plugin-single-instance = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 4012fe1a587..5d1610fa3e6 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -189,6 +189,13 @@ pub fn run() { let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some(); let mut builder = tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + // Focus existing window when another instance is launched + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_focus(); + let _ = window.unminimize(); + } + })) .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_window_state::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build()) From e5a868157e7ea89a632a43d6c15fd20128c71144 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 6 Jan 2026 17:08:34 +0800 Subject: [PATCH 16/55] update Cargo.lock --- packages/desktop/src-tauri/Cargo.lock | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index cd7a4226c24..a35db0b3af7 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2790,6 +2790,7 @@ dependencies = [ "tauri-plugin-os", "tauri-plugin-process", "tauri-plugin-shell", + "tauri-plugin-single-instance", "tauri-plugin-store", "tauri-plugin-updater", "tauri-plugin-window-state", @@ -4637,6 +4638,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd707f8c86b4e3004e2c141fa24351f1909ba40ce1b8437e30d5ed5277dd3710" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.17", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-store" version = "2.4.1" From 4ecb305820b21c753b77b3d05e6baa2aa19d0bb9 Mon Sep 17 00:00:00 2001 From: Eric Guo Date: Tue, 6 Jan 2026 19:22:47 +0800 Subject: [PATCH 17/55] Fix(app): @pierre/diffs will crash when a diff has undefined text (#7059) --- packages/ui/src/components/diff.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx index bdbf1511ec3..7620f2bb5e8 100644 --- a/packages/ui/src/components/diff.tsx +++ b/packages/ui/src/components/diff.tsx @@ -19,6 +19,8 @@ export function Diff(props: DiffProps) { const opts = options() const workerPool = getWorkerPool(props.diffStyle) const annotations = local.annotations + const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : "" + const afterContents = typeof local.after?.contents === "string" ? local.after.contents : "" instance?.cleanUp() instance = new FileDiff(opts, workerPool) @@ -27,11 +29,13 @@ export function Diff(props: DiffProps) { instance.render({ oldFile: { ...local.before, - cacheKey: checksum(local.before.contents), + contents: beforeContents, + cacheKey: checksum(beforeContents), }, newFile: { ...local.after, - cacheKey: checksum(local.after.contents), + contents: afterContents, + cacheKey: checksum(afterContents), }, lineAnnotations: annotations, containerWrapper: container, From 0b02f6d22f793387322d96f937cfc1c8eee9bfbb Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 6 Jan 2026 12:04:50 +0000 Subject: [PATCH 18/55] ignore: update download stats 2026-01-06 --- STATS.md | 385 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 193 insertions(+), 192 deletions(-) diff --git a/STATS.md b/STATS.md index d4685944238..9effacbb1f0 100644 --- a/STATS.md +++ b/STATS.md @@ -1,194 +1,195 @@ # Download Stats -| Date | GitHub Downloads | npm Downloads | Total | -| ---------- | ------------------- | ------------------- | ------------------- | -| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) | -| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) | -| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) | -| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) | -| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) | -| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) | -| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) | -| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) | -| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) | -| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) | -| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) | -| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) | -| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) | -| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) | -| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) | -| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) | -| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) | -| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) | -| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) | -| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) | -| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) | -| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) | -| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) | -| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) | -| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) | -| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) | -| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) | -| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) | -| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) | -| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) | -| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) | -| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) | -| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) | -| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) | -| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) | -| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) | -| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) | -| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) | -| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) | -| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) | -| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) | -| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) | -| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) | -| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) | -| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) | -| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) | -| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) | -| 2025-08-16 | 198,118 (+4,510) | 184,558 (+2,766) | 382,676 (+7,276) | -| 2025-08-17 | 201,299 (+3,181) | 186,269 (+1,711) | 387,568 (+4,892) | -| 2025-08-18 | 204,559 (+3,260) | 187,399 (+1,130) | 391,958 (+4,390) | -| 2025-08-19 | 209,814 (+5,255) | 189,668 (+2,269) | 399,482 (+7,524) | -| 2025-08-20 | 214,497 (+4,683) | 191,481 (+1,813) | 405,978 (+6,496) | -| 2025-08-21 | 220,465 (+5,968) | 194,784 (+3,303) | 415,249 (+9,271) | -| 2025-08-22 | 225,899 (+5,434) | 197,204 (+2,420) | 423,103 (+7,854) | -| 2025-08-23 | 229,005 (+3,106) | 199,238 (+2,034) | 428,243 (+5,140) | -| 2025-08-24 | 232,098 (+3,093) | 201,157 (+1,919) | 433,255 (+5,012) | -| 2025-08-25 | 236,607 (+4,509) | 202,650 (+1,493) | 439,257 (+6,002) | -| 2025-08-26 | 242,783 (+6,176) | 205,242 (+2,592) | 448,025 (+8,768) | -| 2025-08-27 | 248,409 (+5,626) | 205,242 (+0) | 453,651 (+5,626) | -| 2025-08-28 | 252,796 (+4,387) | 205,242 (+0) | 458,038 (+4,387) | -| 2025-08-29 | 256,045 (+3,249) | 211,075 (+5,833) | 467,120 (+9,082) | -| 2025-08-30 | 258,863 (+2,818) | 212,397 (+1,322) | 471,260 (+4,140) | -| 2025-08-31 | 262,004 (+3,141) | 213,944 (+1,547) | 475,948 (+4,688) | -| 2025-09-01 | 265,359 (+3,355) | 215,115 (+1,171) | 480,474 (+4,526) | -| 2025-09-02 | 270,483 (+5,124) | 217,075 (+1,960) | 487,558 (+7,084) | -| 2025-09-03 | 274,793 (+4,310) | 219,755 (+2,680) | 494,548 (+6,990) | -| 2025-09-04 | 280,430 (+5,637) | 222,103 (+2,348) | 502,533 (+7,985) | -| 2025-09-05 | 283,769 (+3,339) | 223,793 (+1,690) | 507,562 (+5,029) | -| 2025-09-06 | 286,245 (+2,476) | 225,036 (+1,243) | 511,281 (+3,719) | -| 2025-09-07 | 288,623 (+2,378) | 225,866 (+830) | 514,489 (+3,208) | -| 2025-09-08 | 293,341 (+4,718) | 227,073 (+1,207) | 520,414 (+5,925) | -| 2025-09-09 | 300,036 (+6,695) | 229,788 (+2,715) | 529,824 (+9,410) | -| 2025-09-10 | 307,287 (+7,251) | 233,435 (+3,647) | 540,722 (+10,898) | -| 2025-09-11 | 314,083 (+6,796) | 237,356 (+3,921) | 551,439 (+10,717) | -| 2025-09-12 | 321,046 (+6,963) | 240,728 (+3,372) | 561,774 (+10,335) | -| 2025-09-13 | 324,894 (+3,848) | 245,539 (+4,811) | 570,433 (+8,659) | -| 2025-09-14 | 328,876 (+3,982) | 248,245 (+2,706) | 577,121 (+6,688) | -| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) | -| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) | -| 2025-09-17 | 351,117 (+8,508) | 260,970 (+5,706) | 612,087 (+14,214) | -| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) | -| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) | -| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) | -| 2025-09-21 | 377,079 (+4,987) | 280,261 (+3,344) | 657,340 (+8,331) | -| 2025-09-22 | 382,492 (+5,413) | 284,009 (+3,748) | 666,501 (+9,161) | -| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) | -| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) | -| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) | -| 2025-09-26 | 404,334 (+5,455) | 306,713 (+5,050) | 711,047 (+10,505) | -| 2025-09-27 | 411,618 (+7,284) | 317,763 (+11,050) | 729,381 (+18,334) | -| 2025-09-28 | 414,910 (+3,292) | 322,522 (+4,759) | 737,432 (+8,051) | -| 2025-09-29 | 419,919 (+5,009) | 328,033 (+5,511) | 747,952 (+10,520) | -| 2025-09-30 | 427,991 (+8,072) | 336,472 (+8,439) | 764,463 (+16,511) | -| 2025-10-01 | 433,591 (+5,600) | 341,742 (+5,270) | 775,333 (+10,870) | -| 2025-10-02 | 440,852 (+7,261) | 348,099 (+6,357) | 788,951 (+13,618) | -| 2025-10-03 | 446,829 (+5,977) | 359,937 (+11,838) | 806,766 (+17,815) | -| 2025-10-04 | 452,561 (+5,732) | 370,386 (+10,449) | 822,947 (+16,181) | -| 2025-10-05 | 455,559 (+2,998) | 374,745 (+4,359) | 830,304 (+7,357) | -| 2025-10-06 | 460,927 (+5,368) | 379,489 (+4,744) | 840,416 (+10,112) | -| 2025-10-07 | 467,336 (+6,409) | 385,438 (+5,949) | 852,774 (+12,358) | -| 2025-10-08 | 474,643 (+7,307) | 394,139 (+8,701) | 868,782 (+16,008) | -| 2025-10-09 | 479,203 (+4,560) | 400,526 (+6,387) | 879,729 (+10,947) | -| 2025-10-10 | 484,374 (+5,171) | 406,015 (+5,489) | 890,389 (+10,660) | -| 2025-10-11 | 488,427 (+4,053) | 414,699 (+8,684) | 903,126 (+12,737) | -| 2025-10-12 | 492,125 (+3,698) | 418,745 (+4,046) | 910,870 (+7,744) | -| 2025-10-14 | 505,130 (+13,005) | 429,286 (+10,541) | 934,416 (+23,546) | -| 2025-10-15 | 512,717 (+7,587) | 439,290 (+10,004) | 952,007 (+17,591) | -| 2025-10-16 | 517,719 (+5,002) | 447,137 (+7,847) | 964,856 (+12,849) | -| 2025-10-17 | 526,239 (+8,520) | 457,467 (+10,330) | 983,706 (+18,850) | -| 2025-10-18 | 531,564 (+5,325) | 465,272 (+7,805) | 996,836 (+13,130) | -| 2025-10-19 | 536,209 (+4,645) | 469,078 (+3,806) | 1,005,287 (+8,451) | -| 2025-10-20 | 541,264 (+5,055) | 472,952 (+3,874) | 1,014,216 (+8,929) | -| 2025-10-21 | 548,721 (+7,457) | 479,703 (+6,751) | 1,028,424 (+14,208) | -| 2025-10-22 | 557,949 (+9,228) | 491,395 (+11,692) | 1,049,344 (+20,920) | -| 2025-10-23 | 564,716 (+6,767) | 498,736 (+7,341) | 1,063,452 (+14,108) | -| 2025-10-24 | 572,692 (+7,976) | 506,905 (+8,169) | 1,079,597 (+16,145) | -| 2025-10-25 | 578,927 (+6,235) | 516,129 (+9,224) | 1,095,056 (+15,459) | -| 2025-10-26 | 584,409 (+5,482) | 521,179 (+5,050) | 1,105,588 (+10,532) | -| 2025-10-27 | 589,999 (+5,590) | 526,001 (+4,822) | 1,116,000 (+10,412) | -| 2025-10-28 | 595,776 (+5,777) | 532,438 (+6,437) | 1,128,214 (+12,214) | -| 2025-10-29 | 606,259 (+10,483) | 542,064 (+9,626) | 1,148,323 (+20,109) | -| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) | -| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) | -| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) | -| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) | -| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) | -| 2025-11-03 | 653,130 (+9,063) | 597,139 (+7,135) | 1,250,269 (+16,198) | -| 2025-11-04 | 663,912 (+10,782) | 608,056 (+10,917) | 1,271,968 (+21,699) | -| 2025-11-05 | 675,074 (+11,162) | 619,690 (+11,634) | 1,294,764 (+22,796) | -| 2025-11-06 | 686,252 (+11,178) | 630,885 (+11,195) | 1,317,137 (+22,373) | -| 2025-11-07 | 696,646 (+10,394) | 642,146 (+11,261) | 1,338,792 (+21,655) | -| 2025-11-08 | 706,035 (+9,389) | 653,489 (+11,343) | 1,359,524 (+20,732) | -| 2025-11-09 | 713,462 (+7,427) | 660,459 (+6,970) | 1,373,921 (+14,397) | -| 2025-11-10 | 722,288 (+8,826) | 668,225 (+7,766) | 1,390,513 (+16,592) | -| 2025-11-11 | 729,769 (+7,481) | 677,501 (+9,276) | 1,407,270 (+16,757) | -| 2025-11-12 | 740,180 (+10,411) | 686,454 (+8,953) | 1,426,634 (+19,364) | -| 2025-11-13 | 749,905 (+9,725) | 696,157 (+9,703) | 1,446,062 (+19,428) | -| 2025-11-14 | 759,928 (+10,023) | 705,237 (+9,080) | 1,465,165 (+19,103) | -| 2025-11-15 | 765,955 (+6,027) | 712,870 (+7,633) | 1,478,825 (+13,660) | -| 2025-11-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) | -| 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) | -| 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205) | 1,524,107 (+20,607) | -| 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) | -| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) | -| 2025-11-21 | 826,309 (+11,689) | 769,307 (+11,400) | 1,595,616 (+23,089) | -| 2025-11-22 | 837,269 (+10,960) | 780,996 (+11,689) | 1,618,265 (+22,649) | -| 2025-11-23 | 846,609 (+9,340) | 795,069 (+14,073) | 1,641,678 (+23,413) | -| 2025-11-24 | 856,733 (+10,124) | 804,033 (+8,964) | 1,660,766 (+19,088) | -| 2025-11-25 | 869,423 (+12,690) | 817,339 (+13,306) | 1,686,762 (+25,996) | -| 2025-11-26 | 881,414 (+11,991) | 832,518 (+15,179) | 1,713,932 (+27,170) | -| 2025-11-27 | 893,960 (+12,546) | 846,180 (+13,662) | 1,740,140 (+26,208) | -| 2025-11-28 | 901,741 (+7,781) | 856,482 (+10,302) | 1,758,223 (+18,083) | -| 2025-11-29 | 908,689 (+6,948) | 863,361 (+6,879) | 1,772,050 (+13,827) | -| 2025-11-30 | 916,116 (+7,427) | 870,194 (+6,833) | 1,786,310 (+14,260) | -| 2025-12-01 | 925,898 (+9,782) | 876,500 (+6,306) | 1,802,398 (+16,088) | -| 2025-12-02 | 939,250 (+13,352) | 890,919 (+14,419) | 1,830,169 (+27,771) | -| 2025-12-03 | 952,249 (+12,999) | 903,713 (+12,794) | 1,855,962 (+25,793) | -| 2025-12-04 | 965,611 (+13,362) | 916,471 (+12,758) | 1,882,082 (+26,120) | -| 2025-12-05 | 977,996 (+12,385) | 930,616 (+14,145) | 1,908,612 (+26,530) | -| 2025-12-06 | 987,884 (+9,888) | 943,773 (+13,157) | 1,931,657 (+23,045) | -| 2025-12-07 | 994,046 (+6,162) | 951,425 (+7,652) | 1,945,471 (+13,814) | -| 2025-12-08 | 1,000,898 (+6,852) | 957,149 (+5,724) | 1,958,047 (+12,576) | -| 2025-12-09 | 1,011,488 (+10,590) | 973,922 (+16,773) | 1,985,410 (+27,363) | -| 2025-12-10 | 1,025,891 (+14,403) | 991,708 (+17,786) | 2,017,599 (+32,189) | -| 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) | -| 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) | -| 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) | -| 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) | -| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) | -| 2025-12-16 | 1,120,477 (+26,845) | 1,078,022 (+18,944) | 2,198,499 (+45,789) | -| 2025-12-17 | 1,151,067 (+30,590) | 1,097,661 (+19,639) | 2,248,728 (+50,229) | -| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) | -| 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) | -| 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) | -| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) | -| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) | -| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) | -| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) | -| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) | -| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) | -| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) | -| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) | -| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) | -| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) | -| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) | -| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) | -| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) | -| 2026-01-03 | 1,618,065 (+54,591) | 1,331,914 (+10,955) | 2,949,979 (+65,546) | -| 2026-01-04 | 1,672,656 (+39,702) | 1,339,883 (+7,969) | 3,012,539 (+62,560) | -| 2026-01-05 | 1,738,171 (+65,515) | 1,353,043 (+13,160) | 3,091,214 (+78,675) | +| Date | GitHub Downloads | npm Downloads | Total | +| ---------- | -------------------- | ------------------- | -------------------- | +| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) | +| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) | +| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) | +| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) | +| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) | +| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) | +| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) | +| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) | +| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) | +| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) | +| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) | +| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) | +| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) | +| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) | +| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) | +| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) | +| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) | +| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) | +| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) | +| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) | +| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) | +| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) | +| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) | +| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) | +| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) | +| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) | +| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) | +| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) | +| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) | +| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) | +| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) | +| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) | +| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) | +| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) | +| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) | +| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) | +| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) | +| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) | +| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) | +| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) | +| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) | +| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) | +| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) | +| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) | +| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) | +| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) | +| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) | +| 2025-08-16 | 198,118 (+4,510) | 184,558 (+2,766) | 382,676 (+7,276) | +| 2025-08-17 | 201,299 (+3,181) | 186,269 (+1,711) | 387,568 (+4,892) | +| 2025-08-18 | 204,559 (+3,260) | 187,399 (+1,130) | 391,958 (+4,390) | +| 2025-08-19 | 209,814 (+5,255) | 189,668 (+2,269) | 399,482 (+7,524) | +| 2025-08-20 | 214,497 (+4,683) | 191,481 (+1,813) | 405,978 (+6,496) | +| 2025-08-21 | 220,465 (+5,968) | 194,784 (+3,303) | 415,249 (+9,271) | +| 2025-08-22 | 225,899 (+5,434) | 197,204 (+2,420) | 423,103 (+7,854) | +| 2025-08-23 | 229,005 (+3,106) | 199,238 (+2,034) | 428,243 (+5,140) | +| 2025-08-24 | 232,098 (+3,093) | 201,157 (+1,919) | 433,255 (+5,012) | +| 2025-08-25 | 236,607 (+4,509) | 202,650 (+1,493) | 439,257 (+6,002) | +| 2025-08-26 | 242,783 (+6,176) | 205,242 (+2,592) | 448,025 (+8,768) | +| 2025-08-27 | 248,409 (+5,626) | 205,242 (+0) | 453,651 (+5,626) | +| 2025-08-28 | 252,796 (+4,387) | 205,242 (+0) | 458,038 (+4,387) | +| 2025-08-29 | 256,045 (+3,249) | 211,075 (+5,833) | 467,120 (+9,082) | +| 2025-08-30 | 258,863 (+2,818) | 212,397 (+1,322) | 471,260 (+4,140) | +| 2025-08-31 | 262,004 (+3,141) | 213,944 (+1,547) | 475,948 (+4,688) | +| 2025-09-01 | 265,359 (+3,355) | 215,115 (+1,171) | 480,474 (+4,526) | +| 2025-09-02 | 270,483 (+5,124) | 217,075 (+1,960) | 487,558 (+7,084) | +| 2025-09-03 | 274,793 (+4,310) | 219,755 (+2,680) | 494,548 (+6,990) | +| 2025-09-04 | 280,430 (+5,637) | 222,103 (+2,348) | 502,533 (+7,985) | +| 2025-09-05 | 283,769 (+3,339) | 223,793 (+1,690) | 507,562 (+5,029) | +| 2025-09-06 | 286,245 (+2,476) | 225,036 (+1,243) | 511,281 (+3,719) | +| 2025-09-07 | 288,623 (+2,378) | 225,866 (+830) | 514,489 (+3,208) | +| 2025-09-08 | 293,341 (+4,718) | 227,073 (+1,207) | 520,414 (+5,925) | +| 2025-09-09 | 300,036 (+6,695) | 229,788 (+2,715) | 529,824 (+9,410) | +| 2025-09-10 | 307,287 (+7,251) | 233,435 (+3,647) | 540,722 (+10,898) | +| 2025-09-11 | 314,083 (+6,796) | 237,356 (+3,921) | 551,439 (+10,717) | +| 2025-09-12 | 321,046 (+6,963) | 240,728 (+3,372) | 561,774 (+10,335) | +| 2025-09-13 | 324,894 (+3,848) | 245,539 (+4,811) | 570,433 (+8,659) | +| 2025-09-14 | 328,876 (+3,982) | 248,245 (+2,706) | 577,121 (+6,688) | +| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) | +| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) | +| 2025-09-17 | 351,117 (+8,508) | 260,970 (+5,706) | 612,087 (+14,214) | +| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) | +| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) | +| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) | +| 2025-09-21 | 377,079 (+4,987) | 280,261 (+3,344) | 657,340 (+8,331) | +| 2025-09-22 | 382,492 (+5,413) | 284,009 (+3,748) | 666,501 (+9,161) | +| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) | +| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) | +| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) | +| 2025-09-26 | 404,334 (+5,455) | 306,713 (+5,050) | 711,047 (+10,505) | +| 2025-09-27 | 411,618 (+7,284) | 317,763 (+11,050) | 729,381 (+18,334) | +| 2025-09-28 | 414,910 (+3,292) | 322,522 (+4,759) | 737,432 (+8,051) | +| 2025-09-29 | 419,919 (+5,009) | 328,033 (+5,511) | 747,952 (+10,520) | +| 2025-09-30 | 427,991 (+8,072) | 336,472 (+8,439) | 764,463 (+16,511) | +| 2025-10-01 | 433,591 (+5,600) | 341,742 (+5,270) | 775,333 (+10,870) | +| 2025-10-02 | 440,852 (+7,261) | 348,099 (+6,357) | 788,951 (+13,618) | +| 2025-10-03 | 446,829 (+5,977) | 359,937 (+11,838) | 806,766 (+17,815) | +| 2025-10-04 | 452,561 (+5,732) | 370,386 (+10,449) | 822,947 (+16,181) | +| 2025-10-05 | 455,559 (+2,998) | 374,745 (+4,359) | 830,304 (+7,357) | +| 2025-10-06 | 460,927 (+5,368) | 379,489 (+4,744) | 840,416 (+10,112) | +| 2025-10-07 | 467,336 (+6,409) | 385,438 (+5,949) | 852,774 (+12,358) | +| 2025-10-08 | 474,643 (+7,307) | 394,139 (+8,701) | 868,782 (+16,008) | +| 2025-10-09 | 479,203 (+4,560) | 400,526 (+6,387) | 879,729 (+10,947) | +| 2025-10-10 | 484,374 (+5,171) | 406,015 (+5,489) | 890,389 (+10,660) | +| 2025-10-11 | 488,427 (+4,053) | 414,699 (+8,684) | 903,126 (+12,737) | +| 2025-10-12 | 492,125 (+3,698) | 418,745 (+4,046) | 910,870 (+7,744) | +| 2025-10-14 | 505,130 (+13,005) | 429,286 (+10,541) | 934,416 (+23,546) | +| 2025-10-15 | 512,717 (+7,587) | 439,290 (+10,004) | 952,007 (+17,591) | +| 2025-10-16 | 517,719 (+5,002) | 447,137 (+7,847) | 964,856 (+12,849) | +| 2025-10-17 | 526,239 (+8,520) | 457,467 (+10,330) | 983,706 (+18,850) | +| 2025-10-18 | 531,564 (+5,325) | 465,272 (+7,805) | 996,836 (+13,130) | +| 2025-10-19 | 536,209 (+4,645) | 469,078 (+3,806) | 1,005,287 (+8,451) | +| 2025-10-20 | 541,264 (+5,055) | 472,952 (+3,874) | 1,014,216 (+8,929) | +| 2025-10-21 | 548,721 (+7,457) | 479,703 (+6,751) | 1,028,424 (+14,208) | +| 2025-10-22 | 557,949 (+9,228) | 491,395 (+11,692) | 1,049,344 (+20,920) | +| 2025-10-23 | 564,716 (+6,767) | 498,736 (+7,341) | 1,063,452 (+14,108) | +| 2025-10-24 | 572,692 (+7,976) | 506,905 (+8,169) | 1,079,597 (+16,145) | +| 2025-10-25 | 578,927 (+6,235) | 516,129 (+9,224) | 1,095,056 (+15,459) | +| 2025-10-26 | 584,409 (+5,482) | 521,179 (+5,050) | 1,105,588 (+10,532) | +| 2025-10-27 | 589,999 (+5,590) | 526,001 (+4,822) | 1,116,000 (+10,412) | +| 2025-10-28 | 595,776 (+5,777) | 532,438 (+6,437) | 1,128,214 (+12,214) | +| 2025-10-29 | 606,259 (+10,483) | 542,064 (+9,626) | 1,148,323 (+20,109) | +| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) | +| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) | +| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) | +| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) | +| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) | +| 2025-11-03 | 653,130 (+9,063) | 597,139 (+7,135) | 1,250,269 (+16,198) | +| 2025-11-04 | 663,912 (+10,782) | 608,056 (+10,917) | 1,271,968 (+21,699) | +| 2025-11-05 | 675,074 (+11,162) | 619,690 (+11,634) | 1,294,764 (+22,796) | +| 2025-11-06 | 686,252 (+11,178) | 630,885 (+11,195) | 1,317,137 (+22,373) | +| 2025-11-07 | 696,646 (+10,394) | 642,146 (+11,261) | 1,338,792 (+21,655) | +| 2025-11-08 | 706,035 (+9,389) | 653,489 (+11,343) | 1,359,524 (+20,732) | +| 2025-11-09 | 713,462 (+7,427) | 660,459 (+6,970) | 1,373,921 (+14,397) | +| 2025-11-10 | 722,288 (+8,826) | 668,225 (+7,766) | 1,390,513 (+16,592) | +| 2025-11-11 | 729,769 (+7,481) | 677,501 (+9,276) | 1,407,270 (+16,757) | +| 2025-11-12 | 740,180 (+10,411) | 686,454 (+8,953) | 1,426,634 (+19,364) | +| 2025-11-13 | 749,905 (+9,725) | 696,157 (+9,703) | 1,446,062 (+19,428) | +| 2025-11-14 | 759,928 (+10,023) | 705,237 (+9,080) | 1,465,165 (+19,103) | +| 2025-11-15 | 765,955 (+6,027) | 712,870 (+7,633) | 1,478,825 (+13,660) | +| 2025-11-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) | +| 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) | +| 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205) | 1,524,107 (+20,607) | +| 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) | +| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) | +| 2025-11-21 | 826,309 (+11,689) | 769,307 (+11,400) | 1,595,616 (+23,089) | +| 2025-11-22 | 837,269 (+10,960) | 780,996 (+11,689) | 1,618,265 (+22,649) | +| 2025-11-23 | 846,609 (+9,340) | 795,069 (+14,073) | 1,641,678 (+23,413) | +| 2025-11-24 | 856,733 (+10,124) | 804,033 (+8,964) | 1,660,766 (+19,088) | +| 2025-11-25 | 869,423 (+12,690) | 817,339 (+13,306) | 1,686,762 (+25,996) | +| 2025-11-26 | 881,414 (+11,991) | 832,518 (+15,179) | 1,713,932 (+27,170) | +| 2025-11-27 | 893,960 (+12,546) | 846,180 (+13,662) | 1,740,140 (+26,208) | +| 2025-11-28 | 901,741 (+7,781) | 856,482 (+10,302) | 1,758,223 (+18,083) | +| 2025-11-29 | 908,689 (+6,948) | 863,361 (+6,879) | 1,772,050 (+13,827) | +| 2025-11-30 | 916,116 (+7,427) | 870,194 (+6,833) | 1,786,310 (+14,260) | +| 2025-12-01 | 925,898 (+9,782) | 876,500 (+6,306) | 1,802,398 (+16,088) | +| 2025-12-02 | 939,250 (+13,352) | 890,919 (+14,419) | 1,830,169 (+27,771) | +| 2025-12-03 | 952,249 (+12,999) | 903,713 (+12,794) | 1,855,962 (+25,793) | +| 2025-12-04 | 965,611 (+13,362) | 916,471 (+12,758) | 1,882,082 (+26,120) | +| 2025-12-05 | 977,996 (+12,385) | 930,616 (+14,145) | 1,908,612 (+26,530) | +| 2025-12-06 | 987,884 (+9,888) | 943,773 (+13,157) | 1,931,657 (+23,045) | +| 2025-12-07 | 994,046 (+6,162) | 951,425 (+7,652) | 1,945,471 (+13,814) | +| 2025-12-08 | 1,000,898 (+6,852) | 957,149 (+5,724) | 1,958,047 (+12,576) | +| 2025-12-09 | 1,011,488 (+10,590) | 973,922 (+16,773) | 1,985,410 (+27,363) | +| 2025-12-10 | 1,025,891 (+14,403) | 991,708 (+17,786) | 2,017,599 (+32,189) | +| 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) | +| 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) | +| 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) | +| 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) | +| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) | +| 2025-12-16 | 1,120,477 (+26,845) | 1,078,022 (+18,944) | 2,198,499 (+45,789) | +| 2025-12-17 | 1,151,067 (+30,590) | 1,097,661 (+19,639) | 2,248,728 (+50,229) | +| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) | +| 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) | +| 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) | +| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) | +| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) | +| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) | +| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) | +| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) | +| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) | +| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) | +| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) | +| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) | +| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) | +| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) | +| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) | +| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) | +| 2026-01-03 | 1,618,065 (+54,591) | 1,331,914 (+10,955) | 2,949,979 (+65,546) | +| 2026-01-04 | 1,672,656 (+39,702) | 1,339,883 (+7,969) | 3,012,539 (+62,560) | +| 2026-01-05 | 1,738,171 (+65,515) | 1,353,043 (+13,160) | 3,091,214 (+78,675) | +| 2026-01-06 | 1,960,988 (+222,817) | 1,377,377 (+24,334) | 3,338,365 (+247,151) | From 3f463bc9168abd907be9ae582e161ff89c3a27c9 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 6 Jan 2026 04:18:20 -0600 Subject: [PATCH 19/55] fix(app): scroll store performance --- .../app/src/context/layout-scroll.test.ts | 73 +++++++++++ packages/app/src/context/layout-scroll.ts | 118 ++++++++++++++++++ packages/app/src/context/layout.tsx | 114 ++++++++++++++--- 3 files changed, 289 insertions(+), 16 deletions(-) create mode 100644 packages/app/src/context/layout-scroll.test.ts create mode 100644 packages/app/src/context/layout-scroll.ts diff --git a/packages/app/src/context/layout-scroll.test.ts b/packages/app/src/context/layout-scroll.test.ts new file mode 100644 index 00000000000..b7962c411cd --- /dev/null +++ b/packages/app/src/context/layout-scroll.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createStore } from "solid-js/store" +import { makePersisted, type SyncStorage } from "@solid-primitives/storage" +import { createScrollPersistence } from "./layout-scroll" + +describe("createScrollPersistence", () => { + test("debounces persisted scroll writes", async () => { + const key = "layout-scroll.test" + const data = new Map() + const writes: string[] = [] + const stats = { flushes: 0 } + + const storage = { + getItem: (k: string) => data.get(k) ?? null, + setItem: (k: string, v: string) => { + data.set(k, v) + if (k === key) writes.push(v) + }, + removeItem: (k: string) => { + data.delete(k) + }, + } as SyncStorage + + await new Promise((resolve, reject) => { + createRoot((dispose) => { + const [raw, setRaw] = createStore({ + sessionView: {} as Record }>, + }) + + const [store, setStore] = makePersisted([raw, setRaw], { name: key, storage }) + + const scroll = createScrollPersistence({ + debounceMs: 30, + getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll, + onFlush: (sessionKey, next) => { + stats.flushes += 1 + + const current = store.sessionView[sessionKey] + if (!current) { + setStore("sessionView", sessionKey, { scroll: next }) + return + } + setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next })) + }, + }) + + const run = async () => { + await new Promise((r) => setTimeout(r, 0)) + writes.length = 0 + + for (const i of Array.from({ length: 100 }, (_, n) => n)) { + scroll.setScroll("session", "review", { x: 0, y: i }) + } + + await new Promise((r) => setTimeout(r, 120)) + + expect(stats.flushes).toBeGreaterThanOrEqual(1) + expect(writes.length).toBeGreaterThanOrEqual(1) + expect(writes.length).toBeLessThanOrEqual(2) + } + + void run() + .then(resolve) + .catch(reject) + .finally(() => { + scroll.dispose() + dispose() + }) + }) + }) + }) +}) diff --git a/packages/app/src/context/layout-scroll.ts b/packages/app/src/context/layout-scroll.ts new file mode 100644 index 00000000000..30b0f69044a --- /dev/null +++ b/packages/app/src/context/layout-scroll.ts @@ -0,0 +1,118 @@ +import { createStore, produce } from "solid-js/store" + +export type SessionScroll = { + x: number + y: number +} + +type ScrollMap = Record + +type Options = { + debounceMs?: number + getSnapshot: (sessionKey: string) => ScrollMap | undefined + onFlush: (sessionKey: string, scroll: ScrollMap) => void +} + +export function createScrollPersistence(opts: Options) { + const wait = opts.debounceMs ?? 200 + const [cache, setCache] = createStore>({}) + const dirty = new Set() + const timers = new Map>() + + function clone(input?: ScrollMap) { + const out: ScrollMap = {} + if (!input) return out + + for (const key of Object.keys(input)) { + const pos = input[key] + if (!pos) continue + out[key] = { x: pos.x, y: pos.y } + } + + return out + } + + function seed(sessionKey: string) { + if (cache[sessionKey]) return + setCache(sessionKey, clone(opts.getSnapshot(sessionKey))) + } + + function scroll(sessionKey: string, tab: string) { + seed(sessionKey) + return cache[sessionKey]?.[tab] ?? opts.getSnapshot(sessionKey)?.[tab] + } + + function schedule(sessionKey: string) { + const prev = timers.get(sessionKey) + if (prev) clearTimeout(prev) + timers.set( + sessionKey, + setTimeout(() => flush(sessionKey), wait), + ) + } + + function setScroll(sessionKey: string, tab: string, pos: SessionScroll) { + seed(sessionKey) + + const prev = cache[sessionKey]?.[tab] + if (prev?.x === pos.x && prev?.y === pos.y) return + + setCache(sessionKey, tab, { x: pos.x, y: pos.y }) + dirty.add(sessionKey) + schedule(sessionKey) + } + + function flush(sessionKey: string) { + const timer = timers.get(sessionKey) + if (timer) clearTimeout(timer) + timers.delete(sessionKey) + + if (!dirty.has(sessionKey)) return + dirty.delete(sessionKey) + + opts.onFlush(sessionKey, clone(cache[sessionKey])) + } + + function flushAll() { + const keys = Array.from(dirty) + if (keys.length === 0) return + + for (const key of keys) { + flush(key) + } + } + + function drop(keys: string[]) { + if (keys.length === 0) return + + for (const key of keys) { + const timer = timers.get(key) + if (timer) clearTimeout(timer) + timers.delete(key) + dirty.delete(key) + } + + setCache( + produce((draft) => { + for (const key of keys) { + delete draft[key] + } + }), + ) + } + + function dispose() { + drop(Array.from(timers.keys())) + } + + return { + cache, + drop, + flush, + flushAll, + scroll, + seed, + setScroll, + dispose, + } +} diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index e454f6cfad1..def933c39a3 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -1,5 +1,5 @@ import { createStore, produce } from "solid-js/store" -import { batch, createEffect, createMemo, onMount } from "solid-js" +import { batch, createEffect, createMemo, onCleanup, onMount } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { useGlobalSync } from "./global-sync" import { useGlobalSDK } from "./global-sdk" @@ -7,6 +7,7 @@ import { useServer } from "./server" import { Project } from "@opencode-ai/sdk/v2" import { persisted } from "@/utils/persist" import { same } from "@/utils/same" +import { createScrollPersistence, type SessionScroll } from "./layout-scroll" const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] @@ -29,11 +30,6 @@ type SessionTabs = { all: string[] } -type SessionScroll = { - x: number - y: number -} - type SessionView = { scroll: Record reviewOpen?: string[] @@ -75,6 +71,97 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }), ) + const MAX_SESSION_KEYS = 50 + const meta = { active: undefined as string | undefined, pruned: false } + const used = new Map() + + function prune(keep?: string) { + if (!keep) return + + const keys = new Set() + for (const key of Object.keys(store.sessionView)) keys.add(key) + for (const key of Object.keys(store.sessionTabs)) keys.add(key) + if (keys.size <= MAX_SESSION_KEYS) return + + const score = (key: string) => { + if (key === keep) return Number.MAX_SAFE_INTEGER + return used.get(key) ?? 0 + } + + const ordered = Array.from(keys).sort((a, b) => score(b) - score(a)) + const drop = ordered.slice(MAX_SESSION_KEYS) + if (drop.length === 0) return + + setStore( + produce((draft) => { + for (const key of drop) { + delete draft.sessionView[key] + delete draft.sessionTabs[key] + } + }), + ) + + scroll.drop(drop) + + for (const key of drop) { + used.delete(key) + } + } + + function touch(sessionKey: string) { + meta.active = sessionKey + used.set(sessionKey, Date.now()) + + if (!ready()) return + if (meta.pruned) return + + meta.pruned = true + prune(sessionKey) + } + + const scroll = createScrollPersistence({ + debounceMs: 250, + getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll, + onFlush: (sessionKey, next) => { + const current = store.sessionView[sessionKey] + const keep = meta.active ?? sessionKey + if (!current) { + setStore("sessionView", sessionKey, { scroll: next }) + prune(keep) + return + } + + setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next })) + prune(keep) + }, + }) + + createEffect(() => { + if (!ready()) return + if (meta.pruned) return + const active = meta.active + if (!active) return + meta.pruned = true + prune(active) + }) + + onMount(() => { + const flush = () => batch(() => scroll.flushAll()) + const handleVisibility = () => { + if (document.visibilityState !== "hidden") return + flush() + } + + window.addEventListener("pagehide", flush) + document.addEventListener("visibilitychange", handleVisibility) + + onCleanup(() => { + window.removeEventListener("pagehide", flush) + document.removeEventListener("visibilitychange", handleVisibility) + scroll.dispose() + }) + }) + const usedColors = new Set() function pickAvailableColor(): AvatarColorKey { @@ -253,21 +340,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, }, view(sessionKey: string) { + touch(sessionKey) + scroll.seed(sessionKey) const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} }) return { scroll(tab: string) { - return s().scroll?.[tab] + return scroll.scroll(sessionKey, tab) }, setScroll(tab: string, pos: SessionScroll) { - const current = store.sessionView[sessionKey] - if (!current) { - setStore("sessionView", sessionKey, { scroll: { [tab]: pos } }) - return - } - - const prev = current.scroll?.[tab] - if (prev?.x === pos.x && prev?.y === pos.y) return - setStore("sessionView", sessionKey, "scroll", tab, pos) + scroll.setScroll(sessionKey, tab, pos) }, review: { open: createMemo(() => s().reviewOpen), @@ -285,6 +366,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } }, tabs(sessionKey: string) { + touch(sessionKey) const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] }) return { tabs, From b88bcd49fdea0955f2efc8f09a3614c188d22107 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 6 Jan 2026 08:18:17 -0600 Subject: [PATCH 20/55] fix(app): code splitting for web load perf gains --- packages/app/src/app.tsx | 21 +++- .../src/components/session/session-header.tsx | 7 +- packages/app/src/components/terminal.tsx | 9 +- packages/app/src/context/command.tsx | 13 ++- packages/app/src/context/server.tsx | 62 ++++++---- packages/ui/src/components/list.tsx | 5 +- packages/ui/src/components/markdown.tsx | 3 +- packages/ui/src/context/dialog.tsx | 109 ++++++++++-------- packages/ui/src/hooks/use-filtered-list.tsx | 6 +- 9 files changed, 151 insertions(+), 84 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index e41575e7ad4..a2f1aa4012c 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,5 +1,5 @@ import "@/index.css" -import { ErrorBoundary, Show, type ParentProps } from "solid-js" +import { ErrorBoundary, Show, Suspense, lazy, type ParentProps } from "solid-js" import { Router, Route, Navigate } from "@solidjs/router" import { MetaProvider } from "@solidjs/meta" import { Font } from "@opencode-ai/ui/font" @@ -21,12 +21,14 @@ import { NotificationProvider } from "@/context/notification" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { CommandProvider } from "@/context/command" import Layout from "@/pages/layout" -import Home from "@/pages/home" import DirectoryLayout from "@/pages/directory-layout" -import Session from "@/pages/session" import { ErrorPage } from "./pages/error" import { iife } from "@opencode-ai/util/iife" +const Home = lazy(() => import("@/pages/home")) +const Session = lazy(() => import("@/pages/session")) +const Loading = () =>
Loading...
+ declare global { interface Window { __OPENCODE__?: { updaterEnabled?: boolean; port?: number } @@ -81,7 +83,14 @@ export function App() { )} > - + ( + }> + + + )} + /> } /> - + }> + + diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index e70e0790cbc..4958ad2c353 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -244,8 +244,13 @@ export function SessionHeader() { } return shareURL }, + { initialValue: "" }, + ) + return ( + + {(shareUrl) => } + ) - return {(url) => } })} diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index a298e3f76fb..18c77653ecd 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,4 +1,4 @@ -import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" +import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" import { useSDK } from "@/context/sdk" import { SerializeAddon } from "@/addons/serialize" @@ -106,14 +106,15 @@ export const Terminal = (props: TerminalProps) => { } onMount(async () => { - ghostty = await Ghostty.load() + const mod = await import("ghostty-web") + ghostty = await mod.Ghostty.load() const socket = new WebSocket( sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`, ) ws = socket - const t = new Term({ + const t = new mod.Terminal({ cursorBlink: true, fontSize: 14, fontFamily: "IBM Plex Mono, monospace", @@ -142,7 +143,7 @@ export const Terminal = (props: TerminalProps) => { return false }) - fitAddon = new FitAddon() + fitAddon = new mod.FitAddon() serializeAddon = new SerializeAddon() t.loadAddon(serializeAddon) t.loadAddon(fitAddon) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index efd83bec861..7f88b74c883 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -177,8 +177,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const dialog = useDialog() const options = createMemo(() => { - const all = registrations().flatMap((x) => x()) + const seen = new Set() + const all: CommandOption[] = [] + + for (const reg of registrations()) { + for (const opt of reg()) { + if (seen.has(opt.id)) continue + seen.add(opt.id) + all.push(opt) + } + } + const suggested = all.filter((x) => x.suggested && !x.disabled) + return [ ...suggested.map((x) => ({ ...x, diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index beb00be87e2..48e7e99cceb 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -1,6 +1,6 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" -import { batch, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js" +import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { usePlatform } from "@/context/platform" import { persisted } from "@/utils/persist" @@ -91,27 +91,49 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const isReady = createMemo(() => ready() && !!active()) - const [healthy, { refetch }] = createResource( - () => active() || undefined, - async (url) => { - if (!url) return - - const sdk = createOpencodeClient({ - baseUrl: url, - fetch: platform.fetch, - signal: AbortSignal.timeout(3000), - }) - return sdk.global - .health() - .then((x) => x.data?.healthy === true) - .catch(() => false) - }, - ) + const [healthy, setHealthy] = createSignal(undefined) + + const check = (url: string) => { + const sdk = createOpencodeClient({ + baseUrl: url, + fetch: platform.fetch, + signal: AbortSignal.timeout(3000), + }) + return sdk.global + .health() + .then((x) => x.data?.healthy === true) + .catch(() => false) + } createEffect(() => { - if (!active()) return - const interval = setInterval(() => refetch(), 10_000) - onCleanup(() => clearInterval(interval)) + const url = active() + if (!url) return + + setHealthy(undefined) + + let alive = true + let busy = false + + const run = () => { + if (busy) return + busy = true + void check(url) + .then((next) => { + if (!alive) return + setHealthy(next) + }) + .finally(() => { + busy = false + }) + } + + run() + const interval = setInterval(run, 10_000) + + onCleanup(() => { + alive = false + clearInterval(interval) + }) }) const origin = createMemo(() => projectsKey(active())) diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index b94405c8165..60161f6dc9c 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -175,12 +175,13 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) fallback={
- {props.emptyMessage ?? "No results"} for "{filter()}" + {props.emptyMessage ?? (grouped.loading ? "Loading" : "No results")} for{" "} + "{filter()}"
} > - + {(group) => (
diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 7615d1737a3..6e40b700a27 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -15,6 +15,7 @@ export function Markdown( async (markdown) => { return marked.parse(markdown) }, + { initialValue: "" }, ) return (
) diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index f85eb48df80..8e770750aff 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -1,11 +1,11 @@ import { createContext, + createRoot, createSignal, getOwner, - Owner, - ParentProps, + type Owner, + type ParentProps, runWithOwner, - Show, useContext, type JSX, } from "solid-js" @@ -13,58 +13,66 @@ import { Dialog as Kobalte } from "@kobalte/core/dialog" type DialogElement = () => JSX.Element +type Active = { + id: string + node: JSX.Element + dispose: () => void + owner: Owner + onClose?: () => void +} + const Context = createContext>() function init() { - const [active, setActive] = createSignal< - | { - id: string - element: DialogElement - onClose?: () => void - owner: Owner - } - | undefined - >() + const [active, setActive] = createSignal() + + const close = () => { + const current = active() + if (!current) return + current.onClose?.() + current.dispose() + setActive(undefined) + } + + const show = (element: DialogElement, owner: Owner, onClose?: () => void) => { + close() - const result = { + const id = Math.random().toString(36).slice(2) + let dispose: (() => void) | undefined + + const node = runWithOwner(owner, () => + createRoot((d) => { + dispose = d + return ( + { + if (open) return + close() + }} + > + + + {element()} + + + ) + }), + ) + + if (!dispose) return + + setActive({ id, node, dispose, owner, onClose }) + } + + return { get active() { return active() }, - close() { - active()?.onClose?.() - setActive(undefined) - }, - show(element: DialogElement, owner: Owner, onClose?: () => void) { - active()?.onClose?.() - const id = Math.random().toString(36).slice(2) - setActive({ - id, - element: () => - runWithOwner(owner, () => ( - - { - if (!open) { - result.close() - } - }} - > - - - {element()} - - - - )), - onClose, - owner, - }) - }, + close, + show, } - - return result } export function DialogProvider(props: ParentProps) { @@ -72,7 +80,7 @@ export function DialogProvider(props: ParentProps) { return ( {props.children} -
{ctx.active?.element?.()}
+
{ctx.active?.node}
) } @@ -80,18 +88,21 @@ export function DialogProvider(props: ParentProps) { export function useDialog() { const ctx = useContext(Context) const owner = getOwner() + if (!owner) { throw new Error("useDialog must be used within a DialogProvider") } if (!ctx) { throw new Error("useDialog must be used within a DialogProvider") } + return { get active() { return ctx.active }, show(element: DialogElement, onClose?: () => void) { - ctx.show(element, owner, onClose) + const base = ctx.active?.owner ?? owner + ctx.show(element, base, onClose) }, close() { ctx.close() diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx index 416f030ef49..94099d78601 100644 --- a/packages/ui/src/hooks/use-filtered-list.tsx +++ b/packages/ui/src/hooks/use-filtered-list.tsx @@ -18,6 +18,9 @@ export interface FilteredListProps { export function useFilteredList(props: FilteredListProps) { const [store, setStore] = createStore<{ filter: string }>({ filter: "" }) + type Group = { category: string; items: [T, ...T[]] } + const empty: Group[] = [] + const [grouped, { refetch }] = createResource( () => ({ filter: store.filter, @@ -42,11 +45,12 @@ export function useFilteredList(props: FilteredListProps) { ) return result }, + { initialValue: empty }, ) const flat = createMemo(() => { return pipe( - grouped() || [], + grouped.latest || [], flatMap((x) => x.items), ) }) From 49d837e0c135438a45a6e22479a23dbdb1b914ff Mon Sep 17 00:00:00 2001 From: Justas Raudonius <10882793+justrau@users.noreply.github.com> Date: Tue, 6 Jan 2026 18:15:17 +0200 Subject: [PATCH 21/55] feat(app): add middle-click to close tabs in review sidebar (#7094) --- .../app/src/components/session/session-sortable-tab.tsx | 1 + packages/app/src/pages/session.tsx | 1 + packages/ui/src/components/tabs.tsx | 8 ++++++++ 3 files changed, 10 insertions(+) diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index 1e3f83546da..595ff9d6f8b 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -39,6 +39,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v } hideCloseButton + onMiddleClick={() => props.onTabClose(props.tab)} > {(p) => } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index a0de9021c9d..7221ebe867d 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -993,6 +993,7 @@ export default function Page() { } hideCloseButton + onMiddleClick={() => tabs().close("context")} >
diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx index 0a4d5b91a2c..8c892a6e53f 100644 --- a/packages/ui/src/components/tabs.tsx +++ b/packages/ui/src/components/tabs.tsx @@ -13,6 +13,7 @@ export interface TabsTriggerProps extends ComponentProps } hideCloseButton?: boolean closeButton?: JSX.Element + onMiddleClick?: () => void } export interface TabsContentProps extends ComponentProps {} @@ -55,6 +56,7 @@ function TabsTrigger(props: ParentProps) { "children", "closeButton", "hideCloseButton", + "onMiddleClick", ]) return (
) { ...(split.classList ?? {}), [split.class ?? ""]: !!split.class, }} + onAuxClick={(e) => { + if (e.button === 1 && split.onMiddleClick) { + e.preventDefault() + split.onMiddleClick() + } + }} > Date: Tue, 6 Jan 2026 18:27:22 +0200 Subject: [PATCH 22/55] fix(app): open review sidebar when selecting file from picker (#7096) --- packages/app/src/components/dialog-select-file.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 8e68a3eb805..9e3bbeddd05 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -27,6 +27,7 @@ export function DialogSelectFile() { const value = file.tab(path) tabs().open(value) file.load(path) + layout.review.open() } dialog.close() }} From 6092f8792edab800dbde6fdfb494100fa3a923d5 Mon Sep 17 00:00:00 2001 From: Justas Raudonius <10882793+justrau@users.noreply.github.com> Date: Tue, 6 Jan 2026 18:28:03 +0200 Subject: [PATCH 23/55] feat(app): add view button to open files from review sidebar (#7095) --- packages/app/src/pages/session.tsx | 12 ++++++++++ packages/ui/src/components/icon.tsx | 1 + packages/ui/src/components/session-review.css | 24 +++++++++++++++++++ packages/ui/src/components/session-review.tsx | 13 ++++++++++ 4 files changed, 50 insertions(+) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 7221ebe867d..853d3a894ce 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -58,6 +58,7 @@ interface SessionReviewTabProps { view: () => ReturnType["view"]> diffStyle: DiffStyle onDiffStyleChange?: (style: DiffStyle) => void + onViewFile?: (file: string) => void classes?: { root?: string header?: string @@ -137,6 +138,7 @@ function SessionReviewTab(props: SessionReviewTabProps) { diffs={props.diffs()} diffStyle={props.diffStyle} onDiffStyleChange={props.onDiffStyleChange} + onViewFile={props.onViewFile} /> ) } @@ -818,6 +820,11 @@ export default function Page() { diffs={diffs} view={view} diffStyle="unified" + onViewFile={(path) => { + const value = file.tab(path) + tabs().open(value) + file.load(path) + }} classes={{ root: "pb-[calc(var(--prompt-height,8rem)+32px)]", header: "px-4", @@ -1028,6 +1035,11 @@ export default function Page() { view={view} diffStyle={layout.review.diffStyle()} onDiffStyleChange={layout.review.setDiffStyle} + onViewFile={(path) => { + const value = file.tab(path) + tabs().open(value) + file.load(path) + }} />
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 17aa1bbd5ea..25d4b4f36f5 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -23,6 +23,7 @@ const icons = { "code-lines": ``, "circle-ban-sign": ``, "edit-small-2": ``, + eye: ``, enter: ``, folder: ``, "magnifying-glass": ``, diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css index e16c0eeb62b..eb6ddb44158 100644 --- a/packages/ui/src/components/session-review.css +++ b/packages/ui/src/components/session-review.css @@ -106,6 +106,30 @@ flex-shrink: 0; } + [data-slot="session-review-view-button"] { + display: flex; + align-items: center; + justify-content: center; + padding: 2px; + margin-left: 8px; + border: none; + background: transparent; + color: var(--text-base); + cursor: pointer; + border-radius: 4px; + opacity: 0; + transition: opacity 0.15s ease; + + &:hover { + color: var(--text-strong); + background: var(--surface-base); + } + } + + [data-slot="accordion-trigger"]:hover [data-slot="session-review-view-button"] { + opacity: 1; + } + [data-slot="session-review-trigger-actions"] { flex-shrink: 0; display: flex; diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index e11df6c9fa4..be5181a985f 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -28,6 +28,7 @@ export interface SessionReviewProps { classes?: { root?: string; header?: string; container?: string } actions?: JSX.Element diffs: (FileDiff & { preloaded?: PreloadMultiFileDiffResult })[] + onViewFile?: (file: string) => void } export const SessionReview = (props: SessionReviewProps) => { @@ -107,6 +108,18 @@ export const SessionReview = (props: SessionReviewProps) => { {getDirectory(diff.file)}‎ {getFilename(diff.file)} + + +
From 528c6c1a75a76b66ab920fd52ff8429536f12f3a Mon Sep 17 00:00:00 2001 From: Andrew Thal <467872+athal7@users.noreply.github.com> Date: Tue, 6 Jan 2026 10:58:55 -0600 Subject: [PATCH 24/55] docs(ecosystem): add opencode-devcontainers plugin (#7100) --- packages/web/src/content/docs/ecosystem.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 46cb5646e87..9ecc96ffb40 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -22,6 +22,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw | [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 | From f0e559c0edff33daa0c9187897d5124ebf2f3c03 Mon Sep 17 00:00:00 2001 From: ryanwyler Date: Tue, 6 Jan 2026 10:15:39 -0700 Subject: [PATCH 25/55] fix: sidebar title padding to prevent scrollbar edge case (#7089) --- packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index a9ed042d1bb..98b8cd6d349 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -80,7 +80,7 @@ export function Sidebar(props: { sessionID: string }) { > - + {session().title} From d0a1e6fa46d50e7acf577cd5c3822eeb1a90f033 Mon Sep 17 00:00:00 2001 From: "Guofang.Tang" Date: Wed, 7 Jan 2026 01:18:13 +0800 Subject: [PATCH 26/55] docs: add Simplified Chinese README (#7055) --- README.zh-CN.md | 115 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 README.zh-CN.md diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 00000000000..c0d67a4abea --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,115 @@ +

+ + + + + OpenCode logo + + +

+

开源的 AI Coding Agent。

+

+ Discord + npm + Build status +

+ +[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) + +--- + +### 安装 + +```bash +# 直接安装 (YOLO) +curl -fsSL https://opencode.ai/install | bash + +# 软件包管理器 +npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn +scoop bucket add extras; scoop install extras/opencode # Windows +choco install opencode # Windows +brew install opencode # macOS 和 Linux +paru -S opencode-bin # Arch Linux +mise use -g opencode # 任意系统 +nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支 +``` + +> [!TIP] +> 安装前请先移除 0.1.x 之前的旧版本。 + +### 桌面应用程序 (BETA) + +OpenCode 也提供桌面版应用。可直接从 [发布页 (releases page)](https://github.com/anomalyco/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/download) 下载。 + +| 平台 | 下载文件 | +| --------------------- | ------------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | +| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`、`.rpm` 或 AppImage | + +```bash +# macOS (Homebrew Cask) +brew install --cask opencode-desktop +``` + +#### 安装目录 + +安装脚本按照以下优先级决定安装路径: + +1. `$OPENCODE_INSTALL_DIR` - 自定义安装目录 +2. `$XDG_BIN_DIR` - 符合 XDG 基础目录规范的路径 +3. `$HOME/bin` - 如果存在或可创建的用户二进制目录 +4. `$HOME/.opencode/bin` - 默认备用路径 + +```bash +# 示例 +OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash +``` + +### Agents + +OpenCode 内置两种 Agent,可用 `Tab` 键快速切换: + +- **build** - 默认模式,具备完整权限,适合开发工作 +- **plan** - 只读模式,适合代码分析与探索 + - 默认拒绝修改文件 + - 运行 bash 命令前会询问 + - 便于探索未知代码库或规划改动 + +另外还包含一个 **general** 子 Agent,用于复杂搜索和多步任务,内部使用,也可在消息中输入 `@general` 调用。 + +了解更多 [Agents](https://opencode.ai/docs/agents) 相关信息。 + +### 文档 + +更多配置说明请查看我们的 [**官方文档**](https://opencode.ai/docs)。 + +### 参与贡献 + +如有兴趣贡献代码,请在提交 PR 前阅读 [贡献指南 (Contributing Docs)](./CONTRIBUTING.md)。 + +### 基于 OpenCode 进行开发 + +如果你在项目名中使用了 “opencode”(如 “opencode-dashboard” 或 “opencode-mobile”),请在 README 里注明该项目不是 OpenCode 团队官方开发,且不存在隶属关系。 + +### 常见问题 (FAQ) + +#### 这和 Claude Code 有什么不同? + +功能上很相似,关键差异: + +- 100% 开源。 +- 不绑定特定提供商。推荐使用 [OpenCode Zen](https://opencode.ai/zen) 的模型,但也可搭配 Claude、OpenAI、Google 甚至本地模型。模型迭代会缩小差异、降低成本,因此保持 provider-agnostic 很重要。 +- 内置 LSP 支持。 +- 聚焦终端界面 (TUI)。OpenCode 由 Neovim 爱好者和 [terminal.shop](https://terminal.shop) 的创建者打造,会持续探索终端的极限。 +- 客户端/服务器架构。可在本机运行,同时用移动设备远程驱动。TUI 只是众多潜在客户端之一。 + +#### 另一个同名的仓库是什么? + +另一个名字相近的仓库与本项目无关。[点击这里了解背后故事](https://x.com/thdxr/status/1933561254481666466)。 + +--- + +**加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) From 1016a52cf1c28656ddd5c66689cf97b6d028c2f6 Mon Sep 17 00:00:00 2001 From: ikeda-tomoya-swx Date: Wed, 7 Jan 2026 02:18:34 +0900 Subject: [PATCH 27/55] fix(provider): add jp. prefix auto-assignment for Tokyo region (ap-northeast-1) (#7053) --- packages/opencode/src/provider/provider.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index cd0a80c2c48..9f14b5464c9 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -266,13 +266,24 @@ export namespace Provider { } case "ap": { const isAustraliaRegion = ["ap-southeast-2", "ap-southeast-4"].includes(region) + const isTokyoRegion = region === "ap-northeast-1" if ( isAustraliaRegion && ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((m) => modelID.includes(m)) ) { regionPrefix = "au" modelID = `${regionPrefix}.${modelID}` + } else if (isTokyoRegion) { + // Tokyo region uses jp. prefix for cross-region inference + const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) => + modelID.includes(m), + ) + if (modelRequiresPrefix) { + regionPrefix = "jp" + modelID = `${regionPrefix}.${modelID}` + } } else { + // Other APAC regions use apac. prefix const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) => modelID.includes(m), ) From 8265621d48a13f3f630de880ce568ec04d485f11 Mon Sep 17 00:00:00 2001 From: "Guofang.Tang" Date: Wed, 7 Jan 2026 01:29:06 +0800 Subject: [PATCH 28/55] fix: prevent jdtls path checks from throwing (#7052) --- packages/opencode/src/lsp/server.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 0e2dba675e1..e68ed662897 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -13,6 +13,7 @@ import { Archive } from "../util/archive" export namespace LSPServer { const log = Log.create({ service: "lsp.server" }) + const pathExists = async (p: string) => fs.stat(p).then(() => true).catch(() => false) export interface Handle { process: ChildProcessWithoutNullStreams @@ -1145,7 +1146,7 @@ export namespace LSPServer { } const distPath = path.join(Global.Path.bin, "jdtls") const launcherDir = path.join(distPath, "plugins") - const installed = await fs.exists(launcherDir) + const installed = await pathExists(launcherDir) if (!installed) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("Downloading JDTLS LSP server.") @@ -1163,7 +1164,7 @@ export namespace LSPServer { .nothrow() .then(({ stdout }) => stdout.toString().trim()) const launcherJar = path.join(launcherDir, jarFileName) - if (!(await fs.exists(launcherJar))) { + if (!(await pathExists(launcherJar))) { log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`) return } From a35c278424a5d950fb5e13c61fcb3f550b6b0f57 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 6 Jan 2026 17:29:44 +0000 Subject: [PATCH 29/55] chore: generate --- packages/opencode/src/lsp/server.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index e68ed662897..c818e2b3e94 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -13,7 +13,11 @@ import { Archive } from "../util/archive" export namespace LSPServer { const log = Log.create({ service: "lsp.server" }) - const pathExists = async (p: string) => fs.stat(p).then(() => true).catch(() => false) + const pathExists = async (p: string) => + fs + .stat(p) + .then(() => true) + .catch(() => false) export interface Handle { process: ChildProcessWithoutNullStreams From aa612b27d408f9b661dcbc152a9d3d935e29d084 Mon Sep 17 00:00:00 2001 From: Akinfolami Akin-Alamu <59776300+akinfelami@users.noreply.github.com> Date: Tue, 6 Jan 2026 09:54:49 -0800 Subject: [PATCH 30/55] feat(tui): add 'c' shortcut to copy device code in OAuth flow (#7020) --- .../cli/cmd/tui/component/dialog-provider.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index ce2d70f56e5..72b12d99b7c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -10,6 +10,9 @@ import { useTheme } from "../context/theme" import { TextAttributes } from "@opentui/core" import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2" import { DialogModel } from "./dialog-model" +import { useKeyboard } from "@opentui/solid" +import { Clipboard } from "@tui/util/clipboard" +import { useToast } from "../ui/toast" const PROVIDER_PRIORITY: Record = { opencode: 0, @@ -104,6 +107,17 @@ function AutoMethod(props: AutoMethodProps) { const sdk = useSDK() const dialog = useDialog() const sync = useSync() + const toast = useToast() + + useKeyboard((evt) => { + if (evt.name === "c" && !evt.ctrl && !evt.meta) { + const code = + props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4}/)?.[0] ?? props.authorization.instructions + Clipboard.copy(code) + .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .catch(toast.error) + } + }) onMount(async () => { const result = await sdk.client.provider.oauth.callback({ @@ -132,6 +146,9 @@ function AutoMethod(props: AutoMethodProps) { {props.authorization.instructions}
Waiting for authorization... + + c copy +
) } From d76d6db58921ff38413317ff6eb9c220eac453db Mon Sep 17 00:00:00 2001 From: Matthijs Wolting Date: Tue, 6 Jan 2026 18:56:49 +0100 Subject: [PATCH 31/55] fix: add missing await for available skills in `skill` tool (#7072) --- packages/opencode/src/tool/skill.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 00a081eaca0..24a727d3063 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -47,7 +47,7 @@ export const SkillTool = Tool.define("skill", async () => { const skill = await Skill.get(params.name) if (!skill) { - const available = Skill.all().then((x) => Object.keys(x).join(", ")) + const available = await Skill.all().then((x) => Object.keys(x).join(", ")) throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`) } From 485aadcbfa4c8c8d54eaf2361f1e4b543c975512 Mon Sep 17 00:00:00 2001 From: "M. Adel Alhashemi" <64827602+malhashemi@users.noreply.github.com> Date: Tue, 6 Jan 2026 21:01:37 +0300 Subject: [PATCH 32/55] fix: restore skill filtering by agent permissions (#7042) --- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/tool/skill.ts | 34 ++++++++++++------------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 87b53f526bb..1f5fc98843f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -675,7 +675,7 @@ export namespace SessionPrompt { }, }) - for (const item of await ToolRegistry.tools(input.model.providerID)) { + for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) { const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) tools[item.id] = tool({ id: item.id as any, diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 24a727d3063..386abdae745 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -3,31 +3,33 @@ import z from "zod" import { Tool } from "./tool" import { Skill } from "../skill" import { ConfigMarkdown } from "../config/markdown" +import { PermissionNext } from "../permission/next" -export const SkillTool = Tool.define("skill", async () => { +const parameters = z.object({ + name: z.string().describe("The skill identifier from available_skills (e.g., 'code-review' or 'category/helper')"), +}) + +export const SkillTool = Tool.define("skill", async (ctx) => { const skills = await Skill.all() // Filter skills by agent permissions if agent provided - /* - let accessibleSkills = skills - if (ctx?.agent) { - const permissions = ctx.agent.permission.skill - accessibleSkills = skills.filter((skill) => { - const action = Wildcard.all(skill.name, permissions) - return action !== "deny" + const agent = ctx?.agent + const accessibleSkills = agent + ? skills.filter((skill) => { + const rule = PermissionNext.evaluate("skill", skill.name, agent.permission) + return rule.action !== "deny" }) - } - */ + : skills const description = - skills.length === 0 + accessibleSkills.length === 0 ? "Load a skill to get detailed instructions for a specific task. No skills are currently available." : [ "Load a skill to get detailed instructions for a specific task.", "Skills provide specialized knowledge and step-by-step guidance.", "Use this when a task matches an available skill's description.", "", - ...skills.flatMap((skill) => [ + ...accessibleSkills.flatMap((skill) => [ ` `, ` ${skill.name}`, ` ${skill.description}`, @@ -38,12 +40,8 @@ export const SkillTool = Tool.define("skill", async () => { return { description, - parameters: z.object({ - name: z - .string() - .describe("The skill identifier from available_skills (e.g., 'code-review' or 'category/helper')"), - }), - async execute(params, ctx) { + parameters, + async execute(params: z.infer, ctx) { const skill = await Skill.get(params.name) if (!skill) { From ecbcbfbe901cb1393990318f0605c91ca17ed420 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 6 Jan 2026 12:03:14 -0600 Subject: [PATCH 33/55] fix(app): more contrast in terminal text --- packages/app/src/components/terminal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 18c77653ecd..0335b7c894a 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -53,7 +53,7 @@ export const Terminal = (props: TerminalProps) => { const variant = mode === "dark" ? currentTheme.dark : currentTheme.light if (!variant?.seeds) return fallback const resolved = resolveThemeVariant(variant, mode === "dark") - const text = resolved["text-base"] ?? fallback.foreground + const text = resolved["text-stronger"] ?? fallback.foreground const background = resolved["background-stronger"] ?? fallback.background return { background, From bb09df0c778896daa36b8b43cb3447ef25b16b4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Tue, 6 Jan 2026 19:07:18 +0100 Subject: [PATCH 34/55] fix(desktop): use current_binary() to support symlinked executables (#7102) --- packages/desktop/src-tauri/src/cli.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index 6b86cbcd2c3..9de936cf5e2 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/packages/desktop/src-tauri/src/cli.rs @@ -10,8 +10,9 @@ fn get_cli_install_path() -> Option { } pub fn get_sidecar_path() -> std::path::PathBuf { - tauri::utils::platform::current_exe() - .expect("Failed to get current exe") + // Get binary with symlinks support + tauri::process::current_binary() + .expect("Failed to get current binary") .parent() .expect("Failed to get parent dir") .join("opencode-cli") From c7a2c737e8867099392030b5ed5f876ac150edb3 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 6 Jan 2026 12:21:04 -0600 Subject: [PATCH 35/55] fix: ensure 'name' isnt being sent in request body for custom agent --- packages/opencode/src/agent/agent.ts | 1 - packages/opencode/src/config/config.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index c683727dfa3..6fc228795af 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -188,7 +188,6 @@ export namespace Agent { item.topP = value.top_p ?? item.topP item.mode = value.mode ?? item.mode item.color = value.color ?? item.color - item.name = value.options?.name ?? item.name item.steps = value.steps ?? item.steps item.options = mergeDeep(item.options, value.options ?? {}) item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {})) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a91c91cf0a0..130031f020e 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -483,6 +483,7 @@ export namespace Config { .catchall(z.any()) .transform((agent, ctx) => { const knownKeys = new Set([ + "name", "model", "prompt", "description", From 01eadf3ded6a93c5ce0ab51ae16c7bb6e962d39c Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 6 Jan 2026 12:31:41 -0600 Subject: [PATCH 36/55] test: fix test --- packages/opencode/src/agent/agent.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 6fc228795af..21859186659 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -188,6 +188,7 @@ export namespace Agent { item.topP = value.top_p ?? item.topP item.mode = value.mode ?? item.mode item.color = value.color ?? item.color + item.name = value.name ?? item.name item.steps = value.steps ?? item.steps item.options = mergeDeep(item.options, value.options ?? {}) item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {})) From 675eba65886004eb90ef163f5bc33b5832427603 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 6 Jan 2026 12:59:05 -0600 Subject: [PATCH 37/55] Revert "fix(desktop): use current_binary() to support symlinked executables (#7102)" This reverts commit bb09df0c778896daa36b8b43cb3447ef25b16b4b. --- packages/desktop/src-tauri/src/cli.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index 9de936cf5e2..6b86cbcd2c3 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/packages/desktop/src-tauri/src/cli.rs @@ -10,9 +10,8 @@ fn get_cli_install_path() -> Option { } pub fn get_sidecar_path() -> std::path::PathBuf { - // Get binary with symlinks support - tauri::process::current_binary() - .expect("Failed to get current binary") + tauri::utils::platform::current_exe() + .expect("Failed to get current exe") .parent() .expect("Failed to get parent dir") .join("opencode-cli") From 494e03490e50852c7f67f1a61aa4b44f30eebb00 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 6 Jan 2026 13:07:20 -0600 Subject: [PATCH 38/55] docs: fix desktop stuff --- CONTRIBUTING.md | 20 +++++++++++++++++++- packages/desktop/README.md | 33 +++++++++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b73129b2a1e..08ab0159c2a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,12 +83,30 @@ This starts a local dev server at http://localhost:5173 (or similar port shown i ### Running the Desktop App -The desktop app is a native Tauri application that wraps the web UI. To run it: +The desktop app is a native Tauri application that wraps the web UI. + +To run the native desktop app: + +```bash +bun run --cwd packages/desktop tauri dev +``` + +This starts the web dev server on http://localhost:1420 and opens the native window. + +If you only want the web dev server (no native shell): ```bash bun run --cwd packages/desktop dev ``` +To create a production `dist/` and build the native app bundle: + +```bash +bun run --cwd packages/desktop tauri build +``` + +This runs `bun run --cwd packages/desktop build` automatically via Tauri’s `beforeBuildCommand`. + > [!NOTE] > Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions. diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 7567e65f50e..ebaf4882231 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -1,7 +1,32 @@ -# Tauri + Vanilla TS +# OpenCode Desktop -This template should help get you started developing with Tauri in vanilla HTML, CSS and TypeScript. +Native OpenCode desktop app, built with Tauri v2. -## Recommended IDE Setup +## Development -- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) +From the repo root: + +```bash +bun install +bun run --cwd packages/desktop tauri dev +``` + +This starts the Vite dev server on http://localhost:1420 and opens the native window. + +If you only want the web dev server (no native shell): + +```bash +bun run --cwd packages/desktop dev +``` + +## Build + +To create a production `dist/` and build the native app bundle: + +```bash +bun run --cwd packages/desktop tauri build +``` + +## Prerequisites + +Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions. From 3049ac576afafc53d1df584710a111ccdfa64374 Mon Sep 17 00:00:00 2001 From: Damian Barabonkov Date: Tue, 6 Jan 2026 20:10:35 +0100 Subject: [PATCH 39/55] docs: Expand keybinds documentation (#7108) --- packages/web/src/content/docs/keybinds.mdx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 79464642fd4..d7ac002d45c 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -14,12 +14,16 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf "editor_open": "e", "theme_list": "t", "sidebar_toggle": "b", + "scrollbar_toggle": "none", "username_toggle": "none", "status_view": "s", + "tool_details": "none", "session_export": "x", "session_new": "n", "session_list": "l", "session_timeline": "g", + "session_fork": "none", + "session_rename": "none", "session_share": "none", "session_unshare": "none", "session_interrupt": "escape", @@ -43,6 +47,8 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf "model_list": "m", "model_cycle_recent": "f2", "model_cycle_recent_reverse": "shift+f2", + "model_cycle_favorite": "none", + "model_cycle_favorite_reverse": "none", "variant_cycle": "ctrl+t", "command_list": "ctrl+p", "agent_list": "a", @@ -87,7 +93,9 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf "input_delete_word_backward": "ctrl+w,ctrl+backspace,alt+backspace", "history_previous": "up", "history_next": "down", - "terminal_suspend": "ctrl+z" + "terminal_suspend": "ctrl+z", + "terminal_title_toggle": "none", + "tips_toggle": "h" } } ``` From 5fc4472921f216318c5fc28d9391f93a7c936955 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 6 Jan 2026 14:22:52 -0500 Subject: [PATCH 40/55] OpenCode Black --- infra/console.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/infra/console.ts b/infra/console.ts index 626697c2f86..578546fc6b0 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -97,6 +97,19 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint", ], }) +const zenProduct = new stripe.Product("ZenBlack", { + name: "OpenCode Black", +}) +const zenPrice = new stripe.Price("ZenBlackPrice", { + product: zenProduct.id, + unitAmount: 20000, + currency: "usd", + recurring: { + interval: "month", + intervalCount: 1, + }, +}) + const ZEN_MODELS = [ new sst.Secret("ZEN_MODELS1"), new sst.Secret("ZEN_MODELS2"), From 5db78f20e9ae83c9708b9b0a490f10659e3ce229 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 6 Jan 2026 13:30:31 -0600 Subject: [PATCH 41/55] core: fix title generation for subtask-only messages to extract actual user prompts instead of generic tool execution descriptions --- packages/opencode/src/session/prompt.ts | 43 +++++++++++++------------ 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1f5fc98843f..b669e95672f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -306,7 +306,6 @@ export namespace SessionPrompt { session, modelID: lastUser.model.modelID, providerID: lastUser.model.providerID, - message: msgs.find((m) => m.info.role === "user")!, history: msgs, }) @@ -1565,22 +1564,39 @@ export namespace SessionPrompt { async function ensureTitle(input: { session: Session.Info - message: MessageV2.WithParts history: MessageV2.WithParts[] providerID: string modelID: string }) { if (input.session.parentID) return if (!Session.isDefaultTitle(input.session.title)) return + + // Find first non-synthetic user message + const firstRealUserIdx = input.history.findIndex( + (m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic), + ) + if (firstRealUserIdx === -1) return + const isFirst = input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic)) .length === 1 if (!isFirst) return + + // Gather all messages up to and including the first real user message for context + // This includes any shell/subtask executions that preceded the user's first prompt + const contextMessages = input.history.slice(0, firstRealUserIdx + 1) + const firstRealUser = contextMessages[firstRealUserIdx] + + // For subtask-only messages (from command invocations), extract the prompt directly + // since toModelMessage converts subtask parts to generic "The following tool was executed by the user" + const subtaskParts = firstRealUser.parts.filter((p) => p.type === "subtask") as MessageV2.SubtaskPart[] + const hasOnlySubtaskParts = subtaskParts.length > 0 && firstRealUser.parts.every((p) => p.type === "subtask") + const agent = await Agent.get("title") if (!agent) return const result = await LLM.stream({ agent, - user: input.message.info as MessageV2.User, + user: firstRealUser.info as MessageV2.User, system: [], small: true, tools: {}, @@ -1598,24 +1614,9 @@ export namespace SessionPrompt { role: "user", content: "Generate a title for this conversation:\n", }, - ...MessageV2.toModelMessage([ - { - info: { - id: Identifier.ascending("message"), - role: "user", - sessionID: input.session.id, - time: { - created: Date.now(), - }, - agent: input.message.info.role === "user" ? input.message.info.agent : await Agent.defaultAgent(), - model: { - providerID: input.providerID, - modelID: input.modelID, - }, - }, - parts: input.message.parts, - }, - ]), + ...(hasOnlySubtaskParts + ? [{ role: "user" as const, content: subtaskParts.map((p) => p.prompt).join("\n") }] + : MessageV2.toModelMessage(contextMessages)), ], }) const text = await result.text.catch((err) => log.error("failed to generate title", { error: err })) From 5181e4e90acf285207629597990635184715262e Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 6 Jan 2026 13:36:51 -0600 Subject: [PATCH 42/55] fix(app): copy and paste in terminal was broken --- packages/app/src/components/terminal.tsx | 76 ++++++++++++++---------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 0335b7c894a..8b25f740b36 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -73,29 +73,11 @@ export const Terminal = (props: TerminalProps) => { setOption("theme", colors) }) - const focusTerminal = () => term?.focus() - const copySelection = () => { - if (!term || !term.hasSelection()) return false - const selection = term.getSelection() - if (!selection) return false - if (document.body) { - const textarea = document.createElement("textarea") - textarea.value = selection - textarea.setAttribute("readonly", "") - textarea.style.position = "fixed" - textarea.style.opacity = "0" - document.body.appendChild(textarea) - textarea.select() - const copied = document.execCommand("copy") - document.body.removeChild(textarea) - if (copied) return true - } - const clipboard = navigator.clipboard - if (clipboard?.writeText) { - clipboard.writeText(selection).catch(() => {}) - return true - } - return false + const focusTerminal = () => { + const t = term + if (!t) return + t.focus() + setTimeout(() => t.textarea?.focus(), 0) } const handlePointerDown = () => { const activeElement = document.activeElement @@ -125,21 +107,52 @@ export const Terminal = (props: TerminalProps) => { }) term = t + const copy = () => { + const selection = t.getSelection() + if (!selection) return false + + const body = document.body + if (body) { + const textarea = document.createElement("textarea") + textarea.value = selection + textarea.setAttribute("readonly", "") + textarea.style.position = "fixed" + textarea.style.opacity = "0" + body.appendChild(textarea) + textarea.select() + const copied = document.execCommand("copy") + body.removeChild(textarea) + if (copied) return true + } + + const clipboard = navigator.clipboard + if (clipboard?.writeText) { + clipboard.writeText(selection).catch(() => {}) + return true + } + + return false + } + t.attachCustomKeyEventHandler((event) => { const key = event.key.toLowerCase() - if (key === "c") { - const macCopy = event.metaKey && !event.ctrlKey && !event.altKey - const linuxCopy = event.ctrlKey && event.shiftKey && !event.metaKey - if ((macCopy || linuxCopy) && copySelection()) { - event.preventDefault() - return true - } + + if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") { + copy() + return true } + + if (event.metaKey && !event.ctrlKey && !event.altKey && key === "c") { + if (!t.hasSelection()) return true + copy() + return true + } + // allow for ctrl-` to toggle terminal in parent if (event.ctrlKey && key === "`") { - event.preventDefault() return true } + return false }) @@ -156,7 +169,6 @@ export const Terminal = (props: TerminalProps) => { if (local.pty.rows && local.pty.cols) { t.resize(local.pty.cols, local.pty.rows) } - t.reset() t.write(local.pty.buffer, () => { if (local.pty.scrollY) { t.scrollToLine(local.pty.scrollY) From 630476afc0f5df2219aff1d03ecb1ca34ce0aa7c Mon Sep 17 00:00:00 2001 From: Thomas Gormley Date: Tue, 6 Jan 2026 19:43:55 +0000 Subject: [PATCH 43/55] load `OPENCODE_CONFIG_DIR` AGENTS.md into the system prompt (#7115) --- packages/opencode/src/session/system.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index f9ac12a2bbd..dc180bee886 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -15,6 +15,7 @@ import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt" import PROMPT_CODEX from "./prompt/codex.txt" import type { Provider } from "@/provider/provider" +import { Flag } from "@/flag/flag" export namespace SystemPrompt { export function header(providerID: string) { @@ -66,6 +67,10 @@ export namespace SystemPrompt { path.join(os.homedir(), ".claude", "CLAUDE.md"), ] + if (Flag.OPENCODE_CONFIG_DIR) { + GLOBAL_RULE_FILES.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md")) + } + export async function custom() { const config = await Config.get() const paths = new Set() From d4e7a88bbac172342c855562c962c453c875e098 Mon Sep 17 00:00:00 2001 From: galkatz373 Date: Tue, 6 Jan 2026 22:05:57 +0200 Subject: [PATCH 44/55] feat(cli): frecency file autocomplete (#6603) --- packages/opencode/src/cli/cmd/tui/app.tsx | 13 +-- .../cmd/tui/component/prompt/autocomplete.tsx | 26 +++++- .../cli/cmd/tui/component/prompt/frecency.tsx | 89 +++++++++++++++++++ 3 files changed, 120 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index e22423309ae..2af5b21152c 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -23,6 +23,7 @@ import { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" import { Session } from "@tui/routes/session" import { PromptHistoryProvider } from "./component/prompt/history" +import { FrecencyProvider } from "./component/prompt/frecency" import { PromptStashProvider } from "./component/prompt/stash" import { DialogAlert } from "./ui/dialog-alert" import { ToastProvider, useToast } from "./ui/toast" @@ -124,11 +125,13 @@ export function tui(input: { url: string; args: Args; directory?: string; onExit - - - - - + + + + + + + diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index ae4f18d4cb3..8a6db34242a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -11,6 +11,7 @@ import { useCommandDialog } from "@tui/component/dialog-command" import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" +import { useFrecency } from "./frecency" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -57,6 +58,7 @@ export type AutocompleteOption = { description?: string isDirectory?: boolean onSelect?: () => void + path?: string } export function Autocomplete(props: { @@ -76,6 +78,7 @@ export function Autocomplete(props: { const command = useCommandDialog() const { theme } = useTheme() const dimensions = useTerminalDimensions() + const frecency = useFrecency() const [store, setStore] = createStore({ index: 0, @@ -168,6 +171,10 @@ export function Autocomplete(props: { draft.parts.push(part) props.setExtmark(partIndex, extmarkId) }) + + if (part.type === "file" && part.source && part.source.type === "file") { + frecency.updateFrecency(part.source.path) + } } const [files] = createResource( @@ -186,9 +193,19 @@ export function Autocomplete(props: { // Add file options if (!result.error && result.data) { + const sortedFiles = result.data.sort((a, b) => { + const aScore = frecency.getFrecency(a) + const bScore = frecency.getFrecency(b) + if (aScore !== bScore) return bScore - aScore + const aDepth = a.split("/").length + const bDepth = b.split("/").length + if (aDepth !== bDepth) return aDepth - bDepth + return a.localeCompare(b) + }) + const width = props.anchor().width - 4 options.push( - ...result.data.map((item): AutocompleteOption => { + ...sortedFiles.map((item): AutocompleteOption => { let url = `file://${process.cwd()}/${item}` let filename = item if (lineRange && !item.endsWith("/")) { @@ -205,6 +222,7 @@ export function Autocomplete(props: { return { display: Locale.truncateMiddle(filename, width), isDirectory: isDir, + path: item, onSelect: () => { insertPart(filename, { type: "file", @@ -471,10 +489,12 @@ export function Autocomplete(props: { limit: 10, scoreFn: (objResults) => { const displayResult = objResults[0] + let score = objResults.score if (displayResult && displayResult.target.startsWith(store.visible + currentFilter)) { - return objResults.score * 2 + score *= 2 } - return objResults.score + const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0 + return score * (1 + frecencyScore) }, }) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx new file mode 100644 index 00000000000..5f8a3920d53 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/frecency.tsx @@ -0,0 +1,89 @@ +import path from "path" +import { Global } from "@/global" +import { onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { createSimpleContext } from "../../context/helper" +import { appendFile } from "fs/promises" + +function calculateFrecency(entry?: { frequency: number; lastOpen: number }): number { + if (!entry) return 0 + const daysSince = (Date.now() - entry.lastOpen) / 86400000 // ms per day + const weight = 1 / (1 + daysSince) + return entry.frequency * weight +} + +const MAX_FRECENCY_ENTRIES = 1000 + +export const { use: useFrecency, provider: FrecencyProvider } = createSimpleContext({ + name: "Frecency", + init: () => { + const frecencyFile = Bun.file(path.join(Global.Path.state, "frecency.jsonl")) + onMount(async () => { + const text = await frecencyFile.text().catch(() => "") + const lines = text + .split("\n") + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line) as { path: string; frequency: number; lastOpen: number } + } catch { + return null + } + }) + .filter((line): line is { path: string; frequency: number; lastOpen: number } => line !== null) + + const latest = lines.reduce( + (acc, entry) => { + acc[entry.path] = entry + return acc + }, + {} as Record, + ) + + const sorted = Object.values(latest) + .sort((a, b) => b.lastOpen - a.lastOpen) + .slice(0, MAX_FRECENCY_ENTRIES) + + setStore( + "data", + Object.fromEntries( + sorted.map((entry) => [entry.path, { frequency: entry.frequency, lastOpen: entry.lastOpen }]), + ), + ) + + if (sorted.length > 0) { + const content = sorted.map((entry) => JSON.stringify(entry)).join("\n") + "\n" + Bun.write(frecencyFile, content).catch(() => {}) + } + }) + + const [store, setStore] = createStore({ + data: {} as Record, + }) + + function updateFrecency(filePath: string) { + const absolutePath = path.resolve(process.cwd(), filePath) + const newEntry = { + frequency: (store.data[absolutePath]?.frequency || 0) + 1, + lastOpen: Date.now(), + } + setStore("data", absolutePath, newEntry) + appendFile(frecencyFile.name!, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {}) + + if (Object.keys(store.data).length > MAX_FRECENCY_ENTRIES) { + const sorted = Object.entries(store.data) + .sort(([, a], [, b]) => b.lastOpen - a.lastOpen) + .slice(0, MAX_FRECENCY_ENTRIES) + setStore("data", Object.fromEntries(sorted)) + const content = sorted.map(([path, entry]) => JSON.stringify({ path, ...entry })).join("\n") + "\n" + Bun.write(frecencyFile, content).catch(() => {}) + } + } + + return { + getFrecency: (filePath: string) => calculateFrecency(store.data[path.resolve(process.cwd(), filePath)]), + updateFrecency, + data: () => store.data, + } + }, +}) From cde06e90d0baaefaf4572c1ded201eba4546ac23 Mon Sep 17 00:00:00 2001 From: Maik <38255215+byBackfish@users.noreply.github.com> Date: Tue, 6 Jan 2026 21:28:47 +0100 Subject: [PATCH 45/55] chore: update stars count (#7120) --- packages/console/app/src/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts index 9fd7de5a6f7..b92fb116648 100644 --- a/packages/console/app/src/config.ts +++ b/packages/console/app/src/config.ts @@ -9,8 +9,8 @@ export const config = { github: { repoUrl: "https://github.com/anomalyco/opencode", starsFormatted: { - compact: "45K", - full: "45,000", + compact: "50K", + full: "50,000", }, }, From a10cc634035f283b918e53dbd5e812547477cddd Mon Sep 17 00:00:00 2001 From: Spoon <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Tue, 6 Jan 2026 21:48:22 +0100 Subject: [PATCH 46/55] feat: url based instructions (#7125) --- packages/opencode/src/session/system.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index dc180bee886..12b6a537eaa 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -90,8 +90,13 @@ export namespace SystemPrompt { } } + const urls: string[] = [] if (config.instructions) { for (let instruction of config.instructions) { + if (instruction.startsWith("https://") || instruction.startsWith("http://")) { + urls.push(instruction) + continue + } if (instruction.startsWith("~/")) { instruction = path.join(os.homedir(), instruction.slice(2)) } @@ -111,12 +116,18 @@ export namespace SystemPrompt { } } - const found = Array.from(paths).map((p) => + const foundFiles = Array.from(paths).map((p) => Bun.file(p) .text() .catch(() => "") .then((x) => "Instructions from: " + p + "\n" + x), ) - return Promise.all(found).then((result) => result.filter(Boolean)) + const foundUrls = urls.map((url) => + fetch(url) + .then((res) => (res.ok ? res.text() : "")) + .catch(() => "") + .then((x) => (x ? "Instructions from: " + url + "\n" + x : "")), + ) + return Promise.all([...foundFiles, ...foundUrls]).then((result) => result.filter(Boolean)) } } From ba105246ea646428c72c3a9a5df8d95495cfc9b2 Mon Sep 17 00:00:00 2001 From: Justas Raudonius <10882793+justrau@users.noreply.github.com> Date: Tue, 6 Jan 2026 22:58:36 +0200 Subject: [PATCH 47/55] fix(app): open links in new tab or browser (#7127) --- packages/ui/src/context/marked.tsx | 8 ++++++++ packages/web/src/components/share/content-markdown.tsx | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 3bd6cb0768e..9bd48c9a9bc 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -379,6 +379,14 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext( name: "Marked", init: () => { return marked.use( + { + renderer: { + link({ href, title, text }) { + const titleAttr = title ? ` title="${title}"` : "" + return `${text}` + }, + }, + }, markedKatex({ throwOnError: false, }), diff --git a/packages/web/src/components/share/content-markdown.tsx b/packages/web/src/components/share/content-markdown.tsx index 69cde82b2c7..b9b1d5dcb1a 100644 --- a/packages/web/src/components/share/content-markdown.tsx +++ b/packages/web/src/components/share/content-markdown.tsx @@ -8,6 +8,14 @@ import { transformerNotationDiff } from "@shikijs/transformers" import style from "./content-markdown.module.css" const markedWithShiki = marked.use( + { + renderer: { + link({ href, title, text }) { + const titleAttr = title ? ` title="${title}"` : "" + return `${text}` + }, + }, + }, markedShiki({ highlight(code, lang) { return codeToHtml(code, { From 7d6ce6fc5ebf96db8afc1d93a8308deeddba67db Mon Sep 17 00:00:00 2001 From: Mateusz Tymek Date: Tue, 6 Jan 2026 21:00:22 +0000 Subject: [PATCH 48/55] docs: add OpenCode-Obsidian plugin (#7129) --- packages/web/src/content/docs/ecosystem.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 9ecc96ffb40..c071152c92c 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -54,6 +54,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw | [opencode.nvim](https://github.com/sudo-tee/opencode.nvim) | Neovim frontend for opencode - a terminal-based AI coding agent | | [ai-sdk-provider-opencode-sdk](https://github.com/ben-vargas/ai-sdk-provider-opencode-sdk) | Vercel AI SDK provider for using OpenCode via @opencode-ai/sdk | | [OpenChamber](https://github.com/btriapitsyn/openchamber) | Web / Desktop App and VS Code Extension for OpenCode | +| [OpenCode-Obsidian](https://github.com/mtymek/opencode-obsidian) | Obsidian plugin that embedds OpenCode in Obsidian's UI | --- From 32e0b612d98766e49f46e0d2d79c429e2c57b819 Mon Sep 17 00:00:00 2001 From: Spoon <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Tue, 6 Jan 2026 22:00:34 +0100 Subject: [PATCH 49/55] adding timeout (#7128) --- packages/opencode/src/session/system.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 12b6a537eaa..5ab165ba245 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -123,7 +123,7 @@ export namespace SystemPrompt { .then((x) => "Instructions from: " + p + "\n" + x), ) const foundUrls = urls.map((url) => - fetch(url) + fetch(url, { signal: AbortSignal.timeout(5000) }) .then((res) => (res.ok ? res.text() : "")) .catch(() => "") .then((x) => (x ? "Instructions from: " + url + "\n" + x : "")), From dc62f9393a28a0ecf97bff5902ea2b5c0a629429 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 6 Jan 2026 16:16:33 -0500 Subject: [PATCH 50/55] zen: fix rate limit --- .../app/src/routes/zen/util/handler.ts | 2 +- .../app/src/routes/zen/util/rateLimiter.ts | 34 +- .../core/migrations/0041_odd_misty_knight.sql | 6 + .../core/migrations/meta/0041_snapshot.json | 1161 +++++++++++++++++ .../core/migrations/meta/_journal.json | 9 +- packages/console/core/src/schema/ip.sql.ts | 10 + 6 files changed, 1206 insertions(+), 16 deletions(-) create mode 100644 packages/console/core/migrations/0041_odd_misty_knight.sql create mode 100644 packages/console/core/migrations/meta/0041_snapshot.json diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index ac487a341e7..8981ecac25e 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -69,7 +69,7 @@ export async function handler( const dataDumper = createDataDumper(sessionId, requestId, projectId) const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient) const isTrial = await trialLimiter?.isTrial() - const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip) + const rateLimiter = createRateLimiter(modelInfo.rateLimit, ip) await rateLimiter?.check() const stickyTracker = createStickyTracker(modelInfo.stickyProvider ?? false, sessionId) const stickyProvider = await stickyTracker?.get() diff --git a/packages/console/app/src/routes/zen/util/rateLimiter.ts b/packages/console/app/src/routes/zen/util/rateLimiter.ts index b3c03681575..244db072c6d 100644 --- a/packages/console/app/src/routes/zen/util/rateLimiter.ts +++ b/packages/console/app/src/routes/zen/util/rateLimiter.ts @@ -1,28 +1,34 @@ -import { Resource } from "@opencode-ai/console-resource" +import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizzle/index.js" +import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js" import { RateLimitError } from "./error" import { logger } from "./logger" -export function createRateLimiter(model: string, limit: number | undefined, ip: string) { +export function createRateLimiter(limit: number | undefined, rawIp: string) { if (!limit) return + const ip = !rawIp.length ? "unknown" : rawIp const now = Date.now() - const currKey = `usage:${ip}:${model}:${buildYYYYMMDDHH(now)}` - const prevKey = `usage:${ip}:${model}:${buildYYYYMMDDHH(now - 3_600_000)}` - let currRate: number - let prevRate: number + const intervals = [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)] return { track: async () => { - await Resource.GatewayKv.put(currKey, currRate + 1, { expirationTtl: 3600 }) + await Database.use((tx) => + tx + .insert(IpRateLimitTable) + .values({ ip, interval: intervals[0], count: 1 }) + .onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }), + ) }, check: async () => { - const values = await Resource.GatewayKv.get([currKey, prevKey]) - const prevValue = values?.get(prevKey) - const currValue = values?.get(currKey) - prevRate = prevValue ? parseInt(prevValue) : 0 - currRate = currValue ? parseInt(currValue) : 0 - logger.debug(`rate limit ${model} prev/curr: ${prevRate}/${currRate}`) - if (prevRate + currRate >= limit) throw new RateLimitError(`Rate limit exceeded. Please try again later.`) + const rows = await Database.use((tx) => + tx + .select({ count: IpRateLimitTable.count }) + .from(IpRateLimitTable) + .where(and(eq(IpRateLimitTable.ip, ip), inArray(IpRateLimitTable.interval, intervals))), + ) + const total = rows.reduce((sum, r) => sum + r.count, 0) + logger.debug(`rate limit total: ${total}`) + if (total >= limit) throw new RateLimitError(`Rate limit exceeded. Please try again later.`) }, } } diff --git a/packages/console/core/migrations/0041_odd_misty_knight.sql b/packages/console/core/migrations/0041_odd_misty_knight.sql new file mode 100644 index 00000000000..5b98137f08a --- /dev/null +++ b/packages/console/core/migrations/0041_odd_misty_knight.sql @@ -0,0 +1,6 @@ +CREATE TABLE `ip_rate_limit` ( + `ip` varchar(45) NOT NULL, + `interval` varchar(10) NOT NULL, + `count` int NOT NULL, + CONSTRAINT `ip_rate_limit_ip_interval_pk` PRIMARY KEY(`ip`,`interval`) +); diff --git a/packages/console/core/migrations/meta/0041_snapshot.json b/packages/console/core/migrations/meta/0041_snapshot.json new file mode 100644 index 00000000000..4c3d6fcdb33 --- /dev/null +++ b/packages/console/core/migrations/meta/0041_snapshot.json @@ -0,0 +1,1161 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "9cf10c24-6029-4cb4-866e-ff9b501eaf7e", + "prevId": "bf19cd74-71f9-4bdf-b50e-67c2436f3408", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "account_id_pk": { + "name": "account_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth": { + "name": "auth", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "enum('email','github','google')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "provider": { + "name": "provider", + "columns": [ + "provider", + "subject" + ], + "isUnique": true + }, + "account_id": { + "name": "account_id", + "columns": [ + "account_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_id_pk": { + "name": "auth_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "benchmark": { + "name": "benchmark", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "time_created": { + "name": "time_created", + "columns": [ + "time_created" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "benchmark_id_pk": { + "name": "benchmark_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "billing": { + "name": "billing", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_type": { + "name": "payment_method_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_last4": { + "name": "payment_method_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload": { + "name": "reload", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_trigger": { + "name": "reload_trigger", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_amount": { + "name": "reload_amount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_error": { + "name": "reload_error", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_error": { + "name": "time_reload_error", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_locked_till": { + "name": "time_reload_locked_till", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_customer_id": { + "name": "global_customer_id", + "columns": [ + "customer_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "billing_workspace_id_id_pk": { + "name": "billing_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "payment": { + "name": "payment", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_refunded": { + "name": "time_refunded", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "payment_workspace_id_id_pk": { + "name": "payment_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "usage": { + "name": "usage", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_5m_tokens": { + "name": "cache_write_5m_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_1h_tokens": { + "name": "cache_write_1h_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_id": { + "name": "key_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "usage_time_created": { + "name": "usage_time_created", + "columns": [ + "workspace_id", + "time_created" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_workspace_id_id_pk": { + "name": "usage_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip_rate_limit": { + "name": "ip_rate_limit", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval": { + "name": "interval", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_rate_limit_ip_interval_pk": { + "name": "ip_rate_limit_ip_interval_pk", + "columns": [ + "ip", + "interval" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip": { + "name": "ip", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage": { + "name": "usage", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_ip_pk": { + "name": "ip_ip_pk", + "columns": [ + "ip" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "key": { + "name": "key", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_used": { + "name": "time_used", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_key": { + "name": "global_key", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "key_workspace_id_id_pk": { + "name": "key_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "model": { + "name": "model", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "model_workspace_model": { + "name": "model_workspace_model", + "columns": [ + "workspace_id", + "model" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "model_workspace_id_id_pk": { + "name": "model_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "provider": { + "name": "provider", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_provider": { + "name": "workspace_provider", + "columns": [ + "workspace_id", + "provider" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "provider_workspace_id_id_pk": { + "name": "provider_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','member')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_account_id": { + "name": "user_account_id", + "columns": [ + "workspace_id", + "account_id" + ], + "isUnique": true + }, + "user_email": { + "name": "user_email", + "columns": [ + "workspace_id", + "email" + ], + "isUnique": true + }, + "global_account_id": { + "name": "global_account_id", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "global_email": { + "name": "global_email", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_workspace_id_id_pk": { + "name": "user_workspace_id_id_pk", + "columns": [ + "workspace_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workspace_id": { + "name": "workspace_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/console/core/migrations/meta/_journal.json b/packages/console/core/migrations/meta/_journal.json index f205abf7645..2a43ada6c60 100644 --- a/packages/console/core/migrations/meta/_journal.json +++ b/packages/console/core/migrations/meta/_journal.json @@ -288,6 +288,13 @@ "when": 1767584617316, "tag": "0040_broken_gamora", "breakpoints": true + }, + { + "idx": 41, + "version": "5", + "when": 1767732559197, + "tag": "0041_odd_misty_knight", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/console/core/src/schema/ip.sql.ts b/packages/console/core/src/schema/ip.sql.ts index be5fb7fa2da..97e35602487 100644 --- a/packages/console/core/src/schema/ip.sql.ts +++ b/packages/console/core/src/schema/ip.sql.ts @@ -10,3 +10,13 @@ export const IpTable = mysqlTable( }, (table) => [primaryKey({ columns: [table.ip] })], ) + +export const IpRateLimitTable = mysqlTable( + "ip_rate_limit", + { + ip: varchar("ip", { length: 45 }).notNull(), + interval: varchar("interval", { length: 10 }).notNull(), + count: int("count").notNull(), + }, + (table) => [primaryKey({ columns: [table.ip, table.interval] })], +) From 409f8f678edc4503dc78064d83a943ca4aaa66ab Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 6 Jan 2026 21:17:29 +0000 Subject: [PATCH 51/55] chore: generate --- .../core/migrations/meta/0041_snapshot.json | 120 ++++-------------- .../core/migrations/meta/_journal.json | 2 +- 2 files changed, 28 insertions(+), 94 deletions(-) diff --git a/packages/console/core/migrations/meta/0041_snapshot.json b/packages/console/core/migrations/meta/0041_snapshot.json index 4c3d6fcdb33..583b55925b3 100644 --- a/packages/console/core/migrations/meta/0041_snapshot.json +++ b/packages/console/core/migrations/meta/0041_snapshot.json @@ -43,9 +43,7 @@ "compositePrimaryKeys": { "account_id_pk": { "name": "account_id_pk", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -109,17 +107,12 @@ "indexes": { "provider": { "name": "provider", - "columns": [ - "provider", - "subject" - ], + "columns": ["provider", "subject"], "isUnique": true }, "account_id": { "name": "account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false } }, @@ -127,9 +120,7 @@ "compositePrimaryKeys": { "auth_id_pk": { "name": "auth_id_pk", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -193,9 +184,7 @@ "indexes": { "time_created": { "name": "time_created", - "columns": [ - "time_created" - ], + "columns": ["time_created"], "isUnique": false } }, @@ -203,9 +192,7 @@ "compositePrimaryKeys": { "benchmark_id_pk": { "name": "benchmark_id_pk", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -353,9 +340,7 @@ "indexes": { "global_customer_id": { "name": "global_customer_id", - "columns": [ - "customer_id" - ], + "columns": ["customer_id"], "isUnique": true } }, @@ -363,10 +348,7 @@ "compositePrimaryKeys": { "billing_workspace_id_id_pk": { "name": "billing_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -453,10 +435,7 @@ "compositePrimaryKeys": { "payment_workspace_id_id_pk": { "name": "payment_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -576,10 +555,7 @@ "indexes": { "usage_time_created": { "name": "usage_time_created", - "columns": [ - "workspace_id", - "time_created" - ], + "columns": ["workspace_id", "time_created"], "isUnique": false } }, @@ -587,10 +563,7 @@ "compositePrimaryKeys": { "usage_workspace_id_id_pk": { "name": "usage_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -626,10 +599,7 @@ "compositePrimaryKeys": { "ip_rate_limit_ip_interval_pk": { "name": "ip_rate_limit_ip_interval_pk", - "columns": [ - "ip", - "interval" - ] + "columns": ["ip", "interval"] } }, "uniqueConstraints": {}, @@ -681,9 +651,7 @@ "compositePrimaryKeys": { "ip_ip_pk": { "name": "ip_ip_pk", - "columns": [ - "ip" - ] + "columns": ["ip"] } }, "uniqueConstraints": {}, @@ -761,9 +729,7 @@ "indexes": { "global_key": { "name": "global_key", - "columns": [ - "key" - ], + "columns": ["key"], "isUnique": true } }, @@ -771,10 +737,7 @@ "compositePrimaryKeys": { "key_workspace_id_id_pk": { "name": "key_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -831,10 +794,7 @@ "indexes": { "model_workspace_model": { "name": "model_workspace_model", - "columns": [ - "workspace_id", - "model" - ], + "columns": ["workspace_id", "model"], "isUnique": true } }, @@ -842,10 +802,7 @@ "compositePrimaryKeys": { "model_workspace_id_id_pk": { "name": "model_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -909,10 +866,7 @@ "indexes": { "workspace_provider": { "name": "workspace_provider", - "columns": [ - "workspace_id", - "provider" - ], + "columns": ["workspace_id", "provider"], "isUnique": true } }, @@ -920,10 +874,7 @@ "compositePrimaryKeys": { "provider_workspace_id_id_pk": { "name": "provider_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -1036,32 +987,22 @@ "indexes": { "user_account_id": { "name": "user_account_id", - "columns": [ - "workspace_id", - "account_id" - ], + "columns": ["workspace_id", "account_id"], "isUnique": true }, "user_email": { "name": "user_email", - "columns": [ - "workspace_id", - "email" - ], + "columns": ["workspace_id", "email"], "isUnique": true }, "global_account_id": { "name": "global_account_id", - "columns": [ - "account_id" - ], + "columns": ["account_id"], "isUnique": false }, "global_email": { "name": "global_email", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": false } }, @@ -1069,10 +1010,7 @@ "compositePrimaryKeys": { "user_workspace_id_id_pk": { "name": "user_workspace_id_id_pk", - "columns": [ - "workspace_id", - "id" - ] + "columns": ["workspace_id", "id"] } }, "uniqueConstraints": {}, @@ -1129,9 +1067,7 @@ "indexes": { "slug": { "name": "slug", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -1139,9 +1075,7 @@ "compositePrimaryKeys": { "workspace_id": { "name": "workspace_id", - "columns": [ - "id" - ] + "columns": ["id"] } }, "uniqueConstraints": {}, @@ -1158,4 +1092,4 @@ "tables": {}, "indexes": {} } -} \ No newline at end of file +} diff --git a/packages/console/core/migrations/meta/_journal.json b/packages/console/core/migrations/meta/_journal.json index 2a43ada6c60..4b68600bf47 100644 --- a/packages/console/core/migrations/meta/_journal.json +++ b/packages/console/core/migrations/meta/_journal.json @@ -297,4 +297,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} From f7b3371b02a7d707dc9b3b04894341bd5e74229e Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 6 Jan 2026 16:31:39 -0500 Subject: [PATCH 52/55] docs: readme Removed section about unrelated repository. --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index 15c382e638c..04c7b53e518 100644 --- a/README.md +++ b/README.md @@ -107,10 +107,6 @@ It's very similar to Claude Code in terms of capability. Here are the key differ - A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal. - A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients. -#### What's the other repo? - -The other confusingly named repo has no relation to this one. You can [read the story behind it here](https://x.com/thdxr/status/1933561254481666466). - --- **Join our community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) From dadddcaf57226a454a37393355d481572e0ba940 Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Tue, 6 Jan 2026 19:00:37 -0300 Subject: [PATCH 53/55] Desktop: Fix Big Messages (#7133) --- packages/ui/src/components/message-part.css | 2 +- packages/ui/src/components/session-turn.css | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index b154f981cc8..a2992905894 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -77,7 +77,7 @@ [data-slot="user-message-text"] { white-space: pre-wrap; - overflow: hidden; + overflow-x: auto; background: var(--surface-base); padding: 8px 12px; border-radius: 4px; diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 6725f178dbd..9b7aa736437 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -59,6 +59,7 @@ [data-slot="session-turn-message-content"] { margin-top: -18px; + max-width: 100%; } [data-slot="session-turn-message-title"] { From b2341c2d9a4b34d96181003deedee3daafa16d94 Mon Sep 17 00:00:00 2001 From: opencode Date: Tue, 6 Jan 2026 22:06:38 +0000 Subject: [PATCH 54/55] release: v1.1.4 --- bun.lock | 30 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++------ packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 4 ++-- packages/sdk/js/package.json | 4 ++-- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 18 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index 9bb91c0b520..0a82517145d 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.3", + "version": "1.1.4", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -70,7 +70,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.3", + "version": "1.1.4", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -98,7 +98,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.3", + "version": "1.1.4", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -125,7 +125,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.3", + "version": "1.1.4", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -149,7 +149,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.3", + "version": "1.1.4", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -173,7 +173,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.1.3", + "version": "1.1.4", "dependencies": { "@opencode-ai/app": "workspace:*", "@solid-primitives/storage": "catalog:", @@ -201,7 +201,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.3", + "version": "1.1.4", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -230,7 +230,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.3", + "version": "1.1.4", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -246,7 +246,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.3", + "version": "1.1.4", "bin": { "opencode": "./bin/opencode", }, @@ -349,7 +349,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.3", + "version": "1.1.4", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -369,7 +369,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.3", + "version": "1.1.4", "devDependencies": { "@hey-api/openapi-ts": "0.88.1", "@tsconfig/node22": "catalog:", @@ -380,7 +380,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.3", + "version": "1.1.4", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -393,7 +393,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.3", + "version": "1.1.4", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -431,7 +431,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.3", + "version": "1.1.4", "dependencies": { "zod": "catalog:", }, @@ -442,7 +442,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.3", + "version": "1.1.4", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 0e88a282b63..62eb1b055a5 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.3", + "version": "1.1.4", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 42061338a50..c65bba4b1aa 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.1.3", + "version": "1.1.4", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 065336b97cf..c22276bf055 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.1.3", + "version": "1.1.4", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 60c21e5bb9b..cc433d64785 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.1.3", + "version": "1.1.4", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 5be55028ada..a38da04aec7 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.1.3", + "version": "1.1.4", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index f73516e741e..3370ad36389 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.1.3", + "version": "1.1.4", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 7d1254f19c5..811e8571787 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.1.3", + "version": "1.1.4", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 1548c296d5e..79c8e2ab51a 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.1.3" +version = "1.1.4" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.3/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.4/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.3/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.4/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.3/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.4/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.3/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.4/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.3/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.4/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 0824d71e23a..4db6d75b8be 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.1.3", + "version": "1.1.4", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 0d2a6d2b230..f9ee3a3e5de 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.3", + "version": "1.1.4", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 12e164b2c4e..476e2b3bcfe 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.1.3", + "version": "1.1.4", "type": "module", "license": "MIT", "scripts": { @@ -25,4 +25,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} +} \ No newline at end of file diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 534d354285d..a7d229110b3 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.1.3", + "version": "1.1.4", "type": "module", "license": "MIT", "scripts": { @@ -30,4 +30,4 @@ "publishConfig": { "directory": "dist" } -} +} \ No newline at end of file diff --git a/packages/slack/package.json b/packages/slack/package.json index 9456dadd41a..a3f3c81cf11 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.1.3", + "version": "1.1.4", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index ac9c76ea0a9..2893d6eefd9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.1.3", + "version": "1.1.4", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index 6b1cbfda422..5c69ab326db 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.1.3", + "version": "1.1.4", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index 4e325d8dd7c..8664035f51a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.1.3", + "version": "1.1.4", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 4d0d0291536..aec9e2f234c 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.1.3", + "version": "1.1.4", "publisher": "sst-dev", "repository": { "type": "git", From b4eba0082370f8c423952a7e550ef6684d64cc75 Mon Sep 17 00:00:00 2001 From: shuv Date: Tue, 6 Jan 2026 15:08:01 -0800 Subject: [PATCH 55/55] adding plans before 1.1.4 merge --- ...-266-pwa-menu-audio-bundling-2026-01-05.md | 462 +++++++++ ...N-268-askquestion-dialog-fix-2026-01-05.md | 573 +++++++++++ ...-269-tui-transparency-toggle-2026-01-06.md | 244 +++++ ...AN-270-tui-bash-spinner-stop-2026-01-06.md | 166 ++++ ...web-input-bar-bottom-padding-2026-01-06.md | 259 +++++ .../PLAN-tauri-mobile-support-2026-01-06.md | 912 ++++++++++++++++++ plan.md | 240 +++++ 7 files changed, 2856 insertions(+) create mode 100644 CONTEXT/PLAN-264-266-pwa-menu-audio-bundling-2026-01-05.md create mode 100644 CONTEXT/PLAN-268-askquestion-dialog-fix-2026-01-05.md create mode 100644 CONTEXT/PLAN-269-tui-transparency-toggle-2026-01-06.md create mode 100644 CONTEXT/PLAN-270-tui-bash-spinner-stop-2026-01-06.md create mode 100644 CONTEXT/PLAN-271-web-input-bar-bottom-padding-2026-01-06.md create mode 100644 CONTEXT/PLAN-tauri-mobile-support-2026-01-06.md create mode 100644 plan.md diff --git a/CONTEXT/PLAN-264-266-pwa-menu-audio-bundling-2026-01-05.md b/CONTEXT/PLAN-264-266-pwa-menu-audio-bundling-2026-01-05.md new file mode 100644 index 00000000000..c36f086bd88 --- /dev/null +++ b/CONTEXT/PLAN-264-266-pwa-menu-audio-bundling-2026-01-05.md @@ -0,0 +1,462 @@ +## Plan Overview +Address two open bugs: PWA safe-area/scroll regressions (issue #264) and plugin audio asset bundling (issue #266). This plan captures current code context, decision points from issues, external references, and a sequenced task list with validation steps. + +**Revision Note (v1):** This plan has been revised based on codebase review to address CSS selector mismatches, missing implementation details, and PWA detection gaps. + +**Revision Note (v2 - 2026-01-06):** Plan reviewed against codebase. Added explicit directory creation, single-file plugin handling, PullToRefresh refactor details, and additional audio formats. + +## Source Issues +| Issue | Title | Link | Acceptance Criteria (abridged) | +| --- | --- | --- | --- | +| #264 | fix(pwa): Menu button hidden behind Dynamic Island and viewport scrolling not locked on iOS PWA | https://github.com/Latitudes-Dev/shuvcode/issues/264 | Menu buttons visible below Dynamic Island; session viewport locked; consistent PWA behavior; no desktop regressions; works on Dynamic Island + notch devices; Android pull-to-refresh regression noted in comment. | +| #266 | Plugin bundling doesn't copy audio files (.wav) breaking opencode-notifier sounds | https://github.com/Latitudes-Dev/shuvcode/issues/266 | Bundling copies audio assets; sounds work; assets discoverable relative to bundled plugin dir; likely include other audio formats. | + +## Context Capture and Decisions +### Issue #266 (Plugin audio assets) +- Bundled plugins are built with `Bun.build()` in `packages/opencode/src/bun/index.ts`. +- Non-JS assets are copied via `copyPluginAssets()` but only for a limited extension list (no audio). +- `copyPluginAssets()` currently flattens paths via `path.basename(entry)` which drops subdirectory structure (`bun/index.ts:235`). +- `@mohak34/opencode-notifier` resolves sounds via `__dirname/../sounds/*.wav`, so flattening + missing `.wav` results in missing files after bundling. +- Bundled assets are copied to both the package bundle directory and `Global.Path.cache` for runtime resolution. +- **GAP IDENTIFIED:** Local plugin bundling in `packages/opencode/src/plugin/index.ts:25-79` (`bundleLocalPlugin()`) does NOT call any asset copy logic after bundling. +- **GAP IDENTIFIED:** Local plugin bundling only has an entry file path; asset copying needs a reliable plugin root (for `sounds/` and similar directories). + +Decisions: +- Expand `assetExtensions` to include audio formats (`.wav`, `.mp3`, `.ogg`, `.flac`, `.m4a`) AND video/font formats for future-proofing (`.mp4`, `.webm`, `.woff`, `.woff2`, `.ttf`). +- Preserve plugin directory structure when copying assets (use the relative entry path, not `basename`). +- Ensure the copy logic creates parent directories before writing nested files using `Bun.$\`mkdir -p\``. +- **CONFIRMED:** Local `file://` plugins MUST also get asset copying. Extract `copyPluginAssets()` to a shared utility at `packages/opencode/src/util/asset-copy.ts`. +- Resolve a local plugin root before copying assets (walk up from the entry file to the nearest `package.json`, fallback to `path.dirname(filePath)`). +- Copy local plugin assets into both the bundled-local output directory and `Global.Path.cache` to preserve `__dirname/..` resolution parity with npm bundles. +- Guard against unsafe paths or symlinks in asset copying (skip entries that escape `pluginDir` or are symlinks, if detectable). +- Collision risk: assets are copied into shared dirs (`bundled`, `bundled-local`, `Global.Path.cache`). Keep this for compatibility, but log when overwriting an existing asset to surface conflicts. + +### Issue #264 (PWA safe area + viewport locking) +- Home menu button is absolutely positioned at `top-0 left-0 p-2` without safe-area offset in `packages/app/src/pages/home.tsx:35-41`. +- **CRITICAL GAP:** Session header (`packages/app/src/components/session/session-header.tsx:52`) does NOT have `data-tauri-drag-region` attribute, but existing CSS rule at `index.css:109-112` targets `header[data-tauri-drag-region]`. The CSS selector does not match. +- PWA-related safe area variables are defined in `packages/app/src/index.css:8-11`. +- `isPWA()` already exists in `packages/app/src/context/platform.tsx:5-11` and can be reused. +- Mobile pages use `PullToRefresh` wrapper in `packages/app/src/pages/layout.tsx:1177-1179` which can trigger pull-to-refresh. The issue comment notes Android refresh on downward swipe. +- **GAP IDENTIFIED:** `PullToRefresh` component has no mechanism to detect PWA standalone mode. +- Session view has scroll container at `packages/app/src/pages/session.tsx:906` with class `overflow-y-auto no-scrollbar` but no `overscroll-behavior` constraint. + +Decisions: +- Use the existing `isPWA()` in `packages/app/src/context/platform.tsx` instead of adding a new utility. +- Keep PWA styling based on `@media (display-mode: standalone)` (avoid `data-pwa` attributes that would add another detection path). +- Add `.home-menu-button` and `.session-scroll-container` classes and extend existing PWA media-query rules in `index.css`. +- Add `data-tauri-drag-region` to the session header so the existing PWA safe-area rule applies. +- Keep the mobile scroll container from `PullToRefresh` but disable refresh behavior in PWA mode (add a prop or internal PWA check instead of removing the wrapper). + +## External References (for asset copy patterns) +- https://github.com/mohak34/opencode-notifier (plugin using `sounds/*.wav`) +- https://github.com/jadujoel/bun-copy-plugin (Bun build copy plugin reference) +- https://github.com/noriyotcp/esbuild-plugin-just-copy (asset copy with preserved paths) + +## Relevant Internal Files +| File | Purpose | Key Lines | +| --- | --- | --- | +| `packages/opencode/src/bun/index.ts` | npm plugin bundling | `copyPluginAssets()` at L224-253, `assetExtensions` at L226 | +| `packages/opencode/src/plugin/index.ts` | local plugin bundling | `bundleLocalPlugin()` at L25-79 (missing asset copy) | +| `packages/app/src/pages/home.tsx` | home page with menu button | Menu button at L35-41 (`top-0 left-0`) | +| `packages/app/src/components/session/session-header.tsx` | session header | Header at L52 (missing `data-tauri-drag-region`) | +| `packages/app/src/pages/session.tsx` | session view | Scroll container at L906 | +| `packages/app/src/pages/layout.tsx` | layout with PullToRefresh | PullToRefresh wrapper at L1177-1179 | +| `packages/app/src/components/pull-to-refresh.tsx` | pull-to-refresh component | Scroll container + refresh logic | +| `packages/app/src/context/platform.tsx` | platform utils | `isPWA()` at L5-11 | +| `packages/app/src/index.css` | PWA CSS rules | Safe-area vars L8-11, PWA rules L90-121 | +| `packages/app/index.html` | HTML entry | Body classes at L27 | + +## Technical Specifications + +### Plugin Asset Bundling (Issue #266) + +#### Asset Extensions (Expanded) +```ts +const ASSET_EXTENSIONS = [ + // Existing + ".html", ".css", ".json", ".txt", ".svg", ".png", ".jpg", ".gif", + // Audio (new) + ".wav", ".mp3", ".ogg", ".flac", ".m4a", ".aac", ".webm", + // Video (new - future-proofing) + ".mp4", ".webm", ".mov", + // Fonts (new - future-proofing) + ".woff", ".woff2", ".ttf", ".otf" +] +``` + +**Note:** Use `ASSET_EXTENSIONS` (uppercase) for the shared constant to distinguish from local variables. + +#### Directory Structure Preservation +**Current (broken):** +```ts +// packages/opencode/src/bun/index.ts:235 +const destPath = path.join(destDir, path.basename(entry)) // Drops directory +await Bun.write(destPath, content) // No mkdir for nested paths +``` + +**Fixed:** +```ts +const destPath = path.join(destDir, entry) // Preserve relative path +await Bun.$`mkdir -p ${path.dirname(destPath)}` // Create parent dirs +await Bun.write(destPath, content) +``` + +#### Shared Asset Copy Utility +Create `packages/opencode/src/util/asset-copy.ts`: +```ts +import path from "path" +import fs from "fs" +import { Log } from "./log" + +const log = Log.create({ service: "asset-copy" }) + +export const ASSET_EXTENSIONS = [ + ".html", ".css", ".json", ".txt", ".svg", ".png", ".jpg", ".gif", + ".wav", ".mp3", ".ogg", ".flac", ".m4a", ".aac", + ".mp4", ".webm", ".mov", + ".woff", ".woff2", ".ttf", ".otf" +] + +/** + * Copy non-JS assets from a plugin directory to target directory. + * Preserves directory structure (e.g., sounds/alerts/beep.wav). + * + * @param pluginDir - Root directory to scan for assets (must be resolved) + * @param targetDir - Destination directory + */ +export async function copyPluginAssets(pluginDir: string, targetDir: string) { + const entries = await Array.fromAsync( + new Bun.Glob("**/*").scan({ cwd: pluginDir, dot: false }) + ) + + for (const entry of entries) { + const ext = path.extname(entry).toLowerCase() + if (!ASSET_EXTENSIONS.includes(ext)) continue + + const srcPath = path.join(pluginDir, entry) + const destPath = path.join(targetDir, entry) // Preserve structure + + // Security: Skip entries that escape pluginDir via symlinks or path traversal + const realSrcPath = await fs.promises.realpath(srcPath).catch(() => null) + if (!realSrcPath || !realSrcPath.startsWith(await fs.promises.realpath(pluginDir))) { + log.warn("skipping asset outside plugin directory", { src: entry }) + continue + } + + try { + // CRITICAL: Create parent directories before writing nested files + const destDir = path.dirname(destPath) + await Bun.$`mkdir -p ${destDir}`.quiet() + + // Log if overwriting existing file + const exists = await Bun.file(destPath).exists() + if (exists) { + log.info("overwriting existing plugin asset", { dest: destPath }) + } + + const content = await Bun.file(srcPath).arrayBuffer() + await Bun.write(destPath, content) + log.info("copied plugin asset", { src: entry, dest: destPath }) + } catch (e) { + log.error("failed to copy plugin asset", { + src: srcPath, + dest: destPath, + error: (e as Error).message, + }) + } + } +} + +/** + * Resolve the root directory of a local plugin. + * Walks up from the entry file to find nearest package.json. + * Falls back to the entry file's directory for single-file plugins. + * + * @param entryFilePath - Absolute path to the plugin entry file + * @returns Resolved plugin root directory + */ +export async function resolvePluginRoot(entryFilePath: string): Promise { + let dir = path.dirname(entryFilePath) + const root = path.parse(dir).root + + while (dir !== root) { + const pkgPath = path.join(dir, "package.json") + if (await Bun.file(pkgPath).exists()) { + return dir + } + dir = path.dirname(dir) + } + + // Fallback for single-file plugins without package.json + return path.dirname(entryFilePath) +} +``` + +**Implementation Notes:** +- `pluginDir` should be resolved via `resolvePluginRoot()` for local plugins +- Security: Uses `fs.promises.realpath()` to detect symlink escapes +- Collision detection: Logs when overwriting existing assets +- Directory creation: Uses `mkdir -p` BEFORE `Bun.write()` for nested paths +- Single-file plugins: Falls back to entry file's directory when no package.json exists + +### PWA Safe Area + Viewport Locking (Issue #264) + +#### PWA Detection Utility (existing) +Use the existing helper in `packages/app/src/context/platform.tsx`: +```ts +export function isPWA(): boolean { + if (typeof window === "undefined") return false + return ( + window.matchMedia("(display-mode: standalone)").matches || + // @ts-ignore - iOS Safari specific + window.navigator.standalone === true + ) +} +``` + +#### Home Menu Button Fix +**File:** `packages/app/src/pages/home.tsx:35-41` + +**Current:** +```tsx +
+``` + +**Fixed (CSS-based for consistency):** +```tsx +
+``` + +#### Session Header Fix +**File:** `packages/app/src/components/session/session-header.tsx:52` + +**Current:** +```tsx +
+``` + +**Fixed (match existing CSS selector):** +```tsx +
+``` + +#### CSS Rules (PWA-specific) +**File:** `packages/app/src/index.css` - Extend the existing `@media (display-mode: standalone)` block: + +```css +@media (display-mode: standalone) { + /* Existing header[data-tauri-drag-region] rule remains; ensure SessionHeader has the attribute */ + .home-menu-button { + top: var(--safe-area-inset-top); + } + + .session-scroll-container { + overscroll-behavior: contain; + } +} +``` + +#### Session Scroll Container Fix +**File:** `packages/app/src/pages/session.tsx:906` + +**Current:** +```tsx +class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar" +``` + +**Fixed:** +```tsx +class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar session-scroll-container" +``` + +#### PullToRefresh Guard +**File:** `packages/app/src/pages/layout.tsx:1177-1179` + +**Current:** +```tsx +
+ {props.children} +
+``` + +**Fixed (keep scroll container, disable refresh):** +```tsx +import { isPWA } from "@/context/platform" + +// In component: +const pwaMode = isPWA() + +// In render: +
+ {props.children} +
+``` + +**PullToRefresh component change (`packages/app/src/components/pull-to-refresh.tsx`):** +```tsx +export function PullToRefresh(props: ParentProps<{ enabled?: boolean }>) { + // ... existing signals ... + + // Reactive enabled check + const enabled = () => props.enabled !== false + + const handleTouchStart = (e: TouchEvent) => { + if (!enabled()) return // Early return when disabled + if (isRefreshing()) return + if (!canPull()) return + // ... rest of handler + } + + const handleTouchMove = (e: TouchEvent) => { + if (!enabled()) return // Early return when disabled + if (!isPulling() || isRefreshing()) return + // ... rest of handler + } + + const handleTouchEnd = async () => { + if (!enabled()) return // Early return when disabled + if (!isPulling()) return + // ... rest of handler + } + + // Note: Touch event listeners remain attached for scroll containment + // The enabled() guard prevents refresh behavior without removing listeners +} +``` + +**Why keep listeners attached:** The scroll container behavior (`overflow-y-auto`, `contain-strict`) should remain even when refresh is disabled. Only the pull-to-refresh gesture handling is guarded. + +### API/Config/Integration Points +- API endpoints: None added/changed. +- Config: `opencode.json` plugin list remains unchanged. +- Integration points: + - `copyPluginAssets()` moved to `packages/opencode/src/util/asset-copy.ts` (shared utility). + - Called from `packages/opencode/src/bun/index.ts` for npm plugins. + - Called from `packages/opencode/src/plugin/index.ts` for local plugins (after resolving plugin root). + - Reuse `isPWA()` from `packages/app/src/context/platform.tsx` in layout. + - `PullToRefresh` accepts an `enabled` prop to keep scroll container while disabling refresh. + - PWA CSS in `packages/app/src/index.css`. + +## Option Comparison (Asset Copy Strategy) +| Option | Summary | Pros | Cons | Decision | +| --- | --- | --- | --- | --- | +| Extend existing `copyPluginAssets()` | Update extensions + preserve directory structure | Minimal change, consistent with current bundling | Still manual copy logic | **SELECTED** | +| Introduce external copy helper/plugin | Use a bundler copy plugin | Potentially reusable | Adds dependency/config | Rejected | + +## Implementation Order and Milestones + +### Milestone 1: Fix plugin audio asset bundling (#266) +- [ ] Create shared utility `packages/opencode/src/util/asset-copy.ts`: + - Export `ASSET_EXTENSIONS` constant with audio, video, font formats + - Export `copyPluginAssets(pluginDir, targetDir)` with directory preservation + - Export `resolvePluginRoot(entryFilePath)` to find nearest package.json or fallback + - Include symlink/path traversal security checks via `fs.promises.realpath()` + - Log overwrites when copying to shared target directories +- [ ] Update `packages/opencode/src/bun/index.ts`: + - Import `{ copyPluginAssets, ASSET_EXTENSIONS }` from shared utility + - Remove inline `copyPluginAssets` function and `assetExtensions` array + - Preserve existing call sites: `copyPluginAssets(mod, bundledDir)` and `copyPluginAssets(mod, Global.Path.cache)` +- [ ] Update `packages/opencode/src/plugin/index.ts` `bundleLocalPlugin()`: + - Import `{ copyPluginAssets, resolvePluginRoot }` from shared utility + - After successful `Bun.build()`, resolve plugin root: `const pluginRoot = await resolvePluginRoot(absolutePath)` + - Call `copyPluginAssets(pluginRoot, bundledDir)` for assets + - Call `copyPluginAssets(pluginRoot, Global.Path.cache)` for runtime resolution parity +- [ ] Add tests in `packages/opencode/test/asset-copy.test.ts`: + - Test audio file copying (`.wav`, `.mp3`, `.ogg`) + - Test nested directory preservation (`sounds/alerts/beep.wav` → `targetDir/sounds/alerts/beep.wav`) + - Test `resolvePluginRoot` with package.json present + - Test `resolvePluginRoot` fallback for single-file plugins (no package.json) + - Test symlink/path traversal entries are skipped + - Test overwrite logging when file already exists + +### Milestone 2: PWA safe area and viewport locking (#264) +- [ ] Reuse `isPWA()` from `packages/app/src/context/platform.tsx` in layout (no new utility file). +- [ ] Add `.home-menu-button` class to menu button in `packages/app/src/pages/home.tsx:35`. +- [ ] Add `data-tauri-drag-region` to the session header in `packages/app/src/components/session/session-header.tsx:52`. +- [ ] Add `.session-scroll-container` class to scroll container in `packages/app/src/pages/session.tsx:906`. +- [ ] Extend PWA CSS rules in `packages/app/src/index.css`: + - `.home-menu-button` top offset + - `.session-scroll-container` overscroll-behavior + - Keep existing `header[data-tauri-drag-region]` safe-area rule +- [ ] Update `packages/app/src/components/pull-to-refresh.tsx` to accept an `enabled` prop and guard refresh behavior. +- [ ] Update `packages/app/src/pages/layout.tsx` to pass `enabled={!isPWA()}` to `PullToRefresh` while preserving the scroll wrapper. +- [ ] Verify `#root`/body sizing still uses `h-dvh` and `min-height` values appropriate for iOS PWA. + +### Milestone 3: Validation and regression checks +- [ ] Run `bun test` in `packages/opencode` (new asset copy tests). +- [ ] Run `bun turbo test` at repo root for full test suite. +- [ ] Manually verify iOS PWA on Dynamic Island device (iPhone 14+). +- [ ] Manually verify iOS PWA on notch device (iPhone X-13). +- [ ] Manually verify Android PWA pull-down behavior in session view. +- [ ] Verify no desktop/browser regressions for menu placement and scrolling. +- [ ] Verify `@mohak34/opencode-notifier` sounds play correctly. + +## Validation Criteria + +### Automated +- `bun test` in `packages/opencode` passes (new asset copy tests). +- `bun turbo test` at repo root passes. +- TypeScript compilation succeeds for both `opencode` and `app` packages. + +### Manual +- `@mohak34/opencode-notifier` plays sounds after bundling with audio assets copied. +- Home and session menu buttons are fully visible below the Dynamic Island in PWA standalone mode. +- Session view does not allow scrolling past content into blank space in PWA standalone mode. +- Android PWA swipe-down does not refresh the page when scrolling back up in session view. +- Non-PWA mobile still scrolls correctly and pull-to-refresh behavior remains unchanged. +- Desktop browser shows no visual regressions in header/menu positioning. + +### Manual Test Steps +```bash +# Issue #266: Plugin asset bundling +# Clear bundled plugin cache for notifier +rm -rf ~/.cache/opencode/bundled/*mohak34* +rm -rf ~/.cache/opencode/bundled-local/* + +# Launch opencode/shuvcode and install notifier plugin +# Trigger notification events and verify sounds play + +# Verify directory structure preserved +ls -la ~/.cache/opencode/bundled/ +# Should show: mohak34-opencode-notifier.js AND sounds/ directory with .wav files +``` + +```bash +# Issue #264: PWA testing +# iOS: Add to Home Screen from Safari, launch as PWA +# - Verify menu button not obscured by Dynamic Island +# - Verify session header not obscured +# - Verify session scroll doesn't overscroll to blank space + +# Android: Add to Home Screen from Chrome, launch as PWA +# - Verify pull-down in session view doesn't trigger refresh +# +# Non-PWA mobile browser: +# - Verify session view still scrolls and pull-to-refresh behavior is unchanged +``` + +## File Changes Summary + +| File | Change Type | Description | +| --- | --- | --- | +| `packages/opencode/src/util/asset-copy.ts` | **NEW** | Shared asset copy utility: `ASSET_EXTENSIONS`, `copyPluginAssets()`, `resolvePluginRoot()` | +| `packages/opencode/src/bun/index.ts` | MODIFY | Import shared utility, remove inline `copyPluginAssets` and `assetExtensions` | +| `packages/opencode/src/plugin/index.ts` | MODIFY | Import `resolvePluginRoot`, call `copyPluginAssets()` after `bundleLocalPlugin()` | +| `packages/opencode/test/asset-copy.test.ts` | **NEW** | Tests for asset copying, directory preservation, plugin root resolution, security | +| `packages/app/src/pages/home.tsx` | MODIFY | Add `.home-menu-button` class | +| `packages/app/src/components/session/session-header.tsx` | MODIFY | Add `data-tauri-drag-region` attribute | +| `packages/app/src/pages/session.tsx` | MODIFY | Add `.session-scroll-container` class | +| `packages/app/src/components/pull-to-refresh.tsx` | MODIFY | Add `enabled` prop, guard touch handlers with early returns | +| `packages/app/src/pages/layout.tsx` | MODIFY | Import `isPWA`, pass `enabled={!isPWA()}` to `PullToRefresh` | +| `packages/app/src/index.css` | MODIFY | Extend PWA-specific CSS rules | + +## Resolved Questions + +| Question | Resolution | +| --- | --- | +| Should audio formats beyond `.wav` be included? | YES - Include `.wav`, `.mp3`, `.ogg`, `.flac`, `.m4a`, `.aac` plus video/font formats | +| Should local `file://` plugins get asset copying? | YES - Share `copyPluginAssets()` between `bun/index.ts` and `plugin/index.ts`, copying from resolved plugin root into `bundled-local` and `Global.Path.cache` | +| How to find plugin root for local plugins? | Walk up from entry file to nearest `package.json`. Fallback to `path.dirname(entryFilePath)` for single-file plugins without package.json | +| PWA styling: inline styles vs CSS utilities? | CSS with `@media (display-mode: standalone)` for maintainability | +| How to detect PWA mode in components? | Reuse existing `isPWA()` from `packages/app/src/context/platform.tsx` | +| Should PullToRefresh keep scroll container when disabled? | YES - Only guard refresh gesture, keep scroll containment for consistent UX | diff --git a/CONTEXT/PLAN-268-askquestion-dialog-fix-2026-01-05.md b/CONTEXT/PLAN-268-askquestion-dialog-fix-2026-01-05.md new file mode 100644 index 00000000000..75991099b3b --- /dev/null +++ b/CONTEXT/PLAN-268-askquestion-dialog-fix-2026-01-05.md @@ -0,0 +1,573 @@ +# Plan: AskQuestion Tool Dialog Fix + +**Issue:** [#268 - AskQuestion tool: Dialog not appearing in Web or TUI mode](https://github.com/Latitudes-Dev/shuvcode/issues/268) + +**Created:** 2026-01-05 + +**Revised:** 2026-01-06 - Added callID validation, sync confirmation recommendations, detection helper extraction + +**Severity:** High - This breaks the core UX of the experimental askquestion feature. + +**Status:** READY TO IMPLEMENT + +--- + +## Overview + +The `askquestion` tool is invoked by the LLM, but the expected wizard dialog does not appear in either Web or TUI mode. The user cannot respond to clarifying questions, causing the tool to hang indefinitely. + +### Configuration Required + +```yaml +# .opencode/config.yaml +experimental: + askquestion_tool: true +``` + +--- + +## Acceptance Criteria + +- [ ] Wizard dialog appears when LLM invokes `askquestion` in **Web mode** +- [ ] Wizard dialog appears when LLM invokes `askquestion` in **TUI mode** +- [ ] User can submit answers via the wizard +- [ ] User can cancel the wizard with Escape +- [ ] Tool resumes correctly after user response +- [ ] Comprehensive end-to-end tests exist proving the full flow works + +--- + +## Architecture Reference + +### Component Map + +| Layer | File | Purpose | +|-------|------|---------| +| Tool Definition | `packages/opencode/src/tool/askquestion.ts` | Defines tool schema, registers pending request, awaits response | +| State Management | `packages/opencode/src/askquestion/index.ts` | `register()`, `respond()`, `cancel()`, `cleanup()` functions | +| Server Endpoints | `packages/opencode/src/server/server.ts:1694-1763` | `POST /askquestion/respond` and `/askquestion/cancel` | +| Web App Detection | `packages/app/src/pages/session.tsx:240-288` | `pendingAskQuestion` memo + handlers | +| Web App UI | `packages/app/src/components/askquestion-wizard.tsx` | `AskQuestionWizard` Solid.js component | +| Web App Rendering | `packages/app/src/pages/session.tsx:993-1010` | Conditional render of wizard vs prompt input | +| TUI Detection | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:391-418` | `pendingAskQuestionFromSync` memo | +| TUI UI | `packages/opencode/src/cli/cmd/tui/ui/dialog-askquestion.tsx` | `DialogAskQuestion` component | +| TUI Rendering | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:1447-1489` | Switch/Match conditional rendering | +| Tool Context | `packages/opencode/src/session/prompt.ts:662-677` | `ctx.metadata()` implementation | +| Part Sync | `packages/opencode/src/session/index.ts:391-401` | `updatePart()` publishes `PartUpdated` event | +| Existing Tests | `packages/opencode/test/tool/askquestion.test.ts` | Core promise flow + detection logic mocks | + +### Expected Data Flow + +``` +1. LLM calls askquestion tool with questions array + └─> askquestion.ts:19-28 + +2. Tool calls await ctx.metadata({ status: "waiting", questions }) + └─> prompt.ts:662-677 -> Session.updatePart() + └─> session/index.ts:391-401 -> Bus.publish(PartUpdated) + +3. SSE delivers PartUpdated event to clients + └─> server.ts:173-209 (global event stream) + +4. Client detects pending askquestion via sync.data.part + └─> Web: session.tsx:240-268 (pendingAskQuestion memo) + └─> TUI: session/index.tsx:391-418 (pendingAskQuestionFromSync memo) + +5. Client renders wizard dialog + └─> Web: session.tsx:993-1010 (AskQuestionWizard) + └─> TUI: session/index.tsx:1448-1484 (DialogAskQuestion) + +6. User submits answers + └─> POST /askquestion/respond -> server.ts:1694-1728 + └─> AskQuestion.respond() resolves the promise + +7. Tool promise resolves, returns formatted answers to LLM + └─> askquestion.ts:46-77 +``` + +--- + +## Suspected Root Causes + +### 1. Sync/Reactivity Gap (Most Likely) + +**Hypothesis:** The `ctx.metadata()` call updates the part and publishes `PartUpdated`, but the SSE sync may not deliver the updated part state to the client before the detection logic runs. + +**Evidence:** +- `ctx.metadata()` is async and awaited (`askquestion.ts:22`) +- `Session.updatePart()` publishes to Bus, which SSE listens to +- But there's no explicit "wait for sync" mechanism + +**Location:** `packages/opencode/src/session/prompt.ts:662-677` + +**Review Finding:** The `ctx.metadata()` at `prompt.ts:665-676` does await `Session.updatePart()`, but this only ensures the local state is updated. SSE delivery to clients is asynchronous and not confirmed. + +**Mitigation Options:** +1. **Small delay after metadata update** (simplest): + ```ts + await ctx.metadata({ ... }) + await new Promise(resolve => setTimeout(resolve, 50)) // Allow SSE delivery + ``` +2. **Use dedicated Bus event** - `AskQuestion.Event.Requested` already exists at `askquestion/index.ts:44-52` but is not currently published. Clients could listen for this instead of polling part state. +3. **Polling/retry in client detection** - If first check fails, retry a few times with small delays. + +### 2. Part State Structure Mismatch + +**Hypothesis:** The detection logic expects `part.state.metadata.status === "waiting"`, but the actual synced structure may differ. + +**Evidence:** +- Tool sets: `metadata: { questions, status: "waiting" }` (`askquestion.ts:24-27`) +- Detection checks: `part.state.metadata?.status !== "waiting"` (`session.tsx:257`) +- The `ToolStateRunning` schema shows `metadata: z.record(z.string(), z.any()).optional()` (`message-v2.ts:244`) + +**Location:** `packages/opencode/src/session/message-v2.ts:239-252` + +### 3. callID Undefined + +**Hypothesis:** The `ctx.callID` may be undefined when the tool is invoked. + +**Evidence:** +- Tool uses `ctx.callID!` (non-null assertion) at `askquestion.ts:32,40` +- Tool.Context defines `callID?: string` (optional) at `tool.ts:20` + +**Location:** `packages/opencode/src/tool/askquestion.ts:32` + +**Risk Assessment (from review):** Low risk - `callID` comes from `options.toolCallId` in `prompt.ts:659` which is set by the AI SDK for all tool calls. However, defensive validation should be added. + +**Required Fix:** Add explicit validation at the start of execute(): +```ts +async execute(params, ctx) { + if (!ctx.callID) { + throw new Error("callID is required for askquestion tool") + } + // ... rest of implementation +} +``` + +### 4. Switch/Match Ordering (TUI Only) + +**Hypothesis:** If another condition matches first (e.g., permissions), the dialog won't show. + +**Evidence:** +- Current order at `session/index.tsx:1447-1509`: + 1. `pendingAskQuestionFromSync()` - DialogAskQuestion + 2. `permissions().length > 0` - PermissionPrompt + 3. `searchMode()` - SearchInput + 4. Default - Prompt + +**Assessment:** This ordering is correct (askquestion first), so unlikely to be the issue. + +--- + +## Implementation Tasks + +### Phase 0: Required Fix (Pre-Investigation) + +- [ ] **0.1** Add callID validation to `askquestion.ts` execute function + - File: `packages/opencode/src/tool/askquestion.ts:19` + - Add at start of execute(): + ```ts + if (!ctx.callID) { + throw new Error("callID is required for askquestion tool") + } + ``` + - Remove non-null assertions (`!`) at lines 32 and 40, replace with direct `ctx.callID` usage + +### Phase 1: Investigation & Debugging + +- [ ] **1.1** Add debug logging to `askquestion.ts` after `ctx.metadata()` call to verify it returns + - File: `packages/opencode/src/tool/askquestion.ts:28` + - Add: `console.log("[askquestion] metadata updated, callID:", ctx.callID)` + +- [ ] **1.2** Add debug logging to Web detection memo to see what parts are being scanned + - File: `packages/app/src/pages/session.tsx:240-268` + - Add console.log for each part checked, especially tool parts + +- [ ] **1.3** Add debug logging to TUI detection memo + - File: `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:391-418` + - Add console.log for each part checked + +- [ ] **1.4** Verify SSE delivers the `PartUpdated` event with correct structure + - Use browser DevTools to inspect SSE events + - Check that `part.state.metadata.status === "waiting"` is present + +- [ ] **1.5** Verify `ctx.callID` is defined when `askquestion` tool executes + - File: `packages/opencode/src/session/prompt.ts:659` + - Log the `options.toolCallId` value + +### Phase 2: Fix Sync/Reactivity Issues + +Based on investigation results, one or more of these may be needed: + +- [ ] **2.1** Ensure `ctx.metadata()` properly awaits sync propagation + - File: `packages/opencode/src/session/prompt.ts:662-677` + - Current implementation awaits `Session.updatePart()` - this is correct + - Verify the async function is properly awaited before returning + +- [ ] **2.2** Add explicit sync wait after metadata update (if needed) + - File: `packages/opencode/src/tool/askquestion.ts:28` + - Option A (simple): Add 50ms delay after metadata update to allow SSE delivery + - Option B (robust): Publish `AskQuestion.Event.Requested` via Bus and have clients listen for it + - Option C (client-side): Add retry logic to client detection memos + +- [ ] **2.3** (DONE in Phase 0) callID validation added to tool execute function + +### Phase 3: Fix Detection Logic (If Needed) + +- [ ] **3.1** Verify `toolPart.callID` is available (not undefined) in detection + - File: `packages/app/src/pages/session.tsx:260` + - File: `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:409` + +- [ ] **3.2** Verify `toolPart.state.metadata` type matches expected schema + - Ensure detection correctly extracts `{ status, questions }` from metadata + +- [ ] **3.3** Consider alternative detection using `AskQuestion.getForSession()` directly + - This would bypass sync reactivity issues + - Would require server endpoint to expose pending requests + +### Phase 4: Comprehensive Testing + +#### 4.1 Server Endpoint Integration Tests + +- [ ] **4.1.1** Create test file: `packages/opencode/test/server/askquestion.test.ts` + +```typescript +// packages/opencode/test/server/askquestion.test.ts +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import { AskQuestion } from "../../src/askquestion" +import { Server } from "../../src/server/server" +import { Instance } from "../../src/project/instance" + +describe("askquestion server endpoints", () => { + test("POST /askquestion/respond resolves pending request", async () => { + await Instance.provide({ + directory: process.cwd(), + fn: async () => { + const server = Server.create() + const callID = "test-call-123" + const sessionID = "test-session" + const messageID = "test-message" + + // Register pending request + const promise = AskQuestion.register(callID, sessionID, messageID, [ + { id: "q1", label: "Q1", question: "Pick one", options: [ + { value: "a", label: "A" }, + { value: "b", label: "B" }, + ]}, + ]) + + // Simulate client response + const res = await server.request("/askquestion/respond", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + callID, + sessionID, + answers: [{ questionId: "q1", values: ["a"] }], + }), + }) + + expect(res.status).toBe(200) + const answers = await promise + expect(answers[0].values).toEqual(["a"]) + }, + }) + }) + + test("POST /askquestion/cancel rejects pending request", async () => { + await Instance.provide({ + directory: process.cwd(), + fn: async () => { + const server = Server.create() + const callID = "test-call-456" + const sessionID = "test-session" + const messageID = "test-message" + + const promise = AskQuestion.register(callID, sessionID, messageID, [ + { id: "q1", label: "Q1", question: "Pick one", options: [ + { value: "a", label: "A" }, + ]}, + ]) + + const res = await server.request("/askquestion/cancel", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ callID, sessionID }), + }) + + expect(res.status).toBe(200) + await expect(promise).rejects.toThrow("User cancelled") + }, + }) + }) + + test("POST /askquestion/respond returns 404 for unknown callID", async () => { + await Instance.provide({ + directory: process.cwd(), + fn: async () => { + const server = Server.create() + + const res = await server.request("/askquestion/respond", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + callID: "nonexistent", + sessionID: "test-session", + answers: [], + }), + }) + + expect(res.status).toBe(500) // Will throw error internally + }, + }) + }) +}) +``` + +#### 4.2 Sync Propagation Tests + +- [ ] **4.2.1** Create test for part sync after metadata update + +```typescript +// packages/opencode/test/tool/askquestion-sync.test.ts +import { describe, expect, test } from "bun:test" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { Bus } from "../../src/bus" +import { Instance } from "../../src/project/instance" + +describe("AskQuestion Sync Propagation", () => { + test("metadata update publishes PartUpdated event with correct structure", async () => { + await Instance.provide({ + directory: process.cwd(), + fn: async () => { + const events: any[] = [] + const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, (evt) => { + events.push(evt) + }) + + const part: MessageV2.ToolPart = { + id: "part-123", + sessionID: "session-123", + messageID: "message-123", + type: "tool", + tool: "askquestion", + callID: "call-123", + state: { + status: "running", + input: {}, + time: { start: Date.now() }, + metadata: { + status: "waiting", + questions: [{ id: "q1", label: "Q1", question: "Test?", options: [] }], + }, + }, + } + + await Session.updatePart(part) + + expect(events.length).toBe(1) + expect(events[0].part.state.metadata.status).toBe("waiting") + expect(events[0].part.state.status).toBe("running") + + unsub() + }, + }) + }) +}) +``` + +#### 4.3 Detection Logic Tests + +- [ ] **4.3.1** Add tests for detection edge cases + +```typescript +// packages/opencode/test/tool/askquestion.test.ts (extend existing) + +describe("AskQuestion Detection Edge Cases", () => { + test("detects pending when callID is present", () => { + const messages = [{ id: "m1" }] + const partsMap = { + m1: [ + { + type: "tool", + tool: "askquestion", + callID: "call-123", // Important: callID must be present + state: { + status: "running", + metadata: { status: "waiting", questions: [] }, + }, + }, + ], + } + const result = detectPending(messages, partsMap) + expect(result).not.toBeNull() + expect(result?.callID).toBe("call-123") + }) + + test("returns null when callID is undefined", () => { + const messages = [{ id: "m1" }] + const partsMap = { + m1: [ + { + type: "tool", + tool: "askquestion", + callID: undefined, // Missing callID + state: { + status: "running", + metadata: { status: "waiting", questions: [] }, + }, + }, + ], + } + const result = detectPending(messages, partsMap) + // Should this return null or handle gracefully? + expect(result?.callID).toBeUndefined() + }) + + test("ignores when part.state.status is not 'running'", () => { + const messages = [{ id: "m1" }] + const partsMap = { + m1: [ + { + type: "tool", + tool: "askquestion", + callID: "call-123", + state: { + status: "pending", // Not running + metadata: { status: "waiting", questions: [] }, + }, + }, + ], + } + const result = detectPending(messages, partsMap) + expect(result).toBeNull() + }) + + test("ignores when metadata.status is 'completed'", () => { + const messages = [{ id: "m1" }] + const partsMap = { + m1: [ + { + type: "tool", + tool: "askquestion", + callID: "call-123", + state: { + status: "running", + metadata: { status: "completed", answers: [] }, + }, + }, + ], + } + const result = detectPending(messages, partsMap) + expect(result).toBeNull() + }) +}) +``` + +#### 4.4 Session Abort Cleanup Tests + +- [ ] **4.4.1** Add test for cleanup on session abort + +```typescript +describe("AskQuestion Cleanup", () => { + test("cleanup rejects all pending requests for session", async () => { + const sessionID = "session-to-abort" + const promises = [] + + for (let i = 0; i < 3; i++) { + promises.push( + AskQuestion.register(`call-${i}`, sessionID, `msg-${i}`, []) + ) + } + + AskQuestion.cleanup(sessionID) + + for (const promise of promises) { + await expect(promise).rejects.toThrow("Session aborted") + } + }) +}) +``` + +### Phase 5: Manual Validation + +- [ ] **5.1** Test in TUI mode + - Start shuvcode in TUI + - Enable `experimental.askquestion_tool: true` + - Trigger LLM to use askquestion (e.g., "Help me choose a database") + - Verify dialog appears + - Test submit and cancel + +- [ ] **5.2** Test in Web mode + - Start shuvcode server + - Open web app + - Enable `experimental.askquestion_tool: true` + - Trigger LLM to use askquestion + - Verify wizard appears + - Test submit and cancel on desktop + - Test submit and cancel on mobile viewport + +- [ ] **5.3** Test edge cases + - Multiple questions in sequence + - Cancel mid-flow + - Session abort while question pending + - Custom text response + +--- + +## External References + +- **Solid.js Reactivity:** https://www.solidjs.com/docs/latest/api#creatememo +- **Hono SSE Streaming:** https://hono.dev/helpers/streaming#sse-stream +- **Bun Test:** https://bun.sh/docs/cli/test + +--- + +## File Modifications Summary + +| File | Action | Description | +|------|--------|-------------| +| `packages/opencode/src/tool/askquestion.ts` | Modify | Add callID validation, remove non-null assertions, add debug logging | +| `packages/opencode/src/session/prompt.ts` | Modify | Verify metadata sync (may add delay or event publish) | +| `packages/app/src/pages/session.tsx` | Modify | Add debug logging for detection (temporary) | +| `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` | Modify | Add debug logging for detection (temporary) | +| `packages/opencode/test/server/askquestion.test.ts` | Create | Server endpoint tests | +| `packages/opencode/test/tool/askquestion.test.ts` | Modify | Add edge case tests, callID validation test | + +--- + +## Definition of Done + +1. All acceptance criteria checkboxes are checked +2. All new tests pass (`bun turbo test`) +3. TypeScript compiles without errors for both `opencode` and `app` packages +4. Manual validation passes in both TUI and Web modes +5. Debug logging is removed before merge +6. PR is reviewed and approved + +--- + +## Risks & Mitigations + +| Risk | Likelihood | Impact | Mitigation | +|------|------------|--------|------------| +| SSE timing issues in production | Medium | High | Add explicit sync confirmation mechanism | +| Breaking other tool metadata flows | Low | High | Comprehensive test coverage | +| Mobile-specific issues | Medium | Medium | Explicit mobile testing in validation | + +--- + +## Notes + +- The detection logic is duplicated between Web (`session.tsx:240-268`) and TUI (`session/index.tsx:391-418`) - consider extracting to shared utility after fix is confirmed +- The `ctx.callID!` non-null assertion is addressed in Phase 0 with explicit validation +- The tool is behind `experimental.askquestion_tool` flag, so production impact is limited to opt-in users +- The `detectPending` helper function referenced in test examples (Phase 4.3) does not exist - it represents the extracted detection logic that should be created as part of the refactor + +## Post-Fix Refactoring (Optional) + +After the fix is confirmed working, consider: +1. Extract `detectPendingAskQuestion(messages, parts)` to `packages/opencode/src/askquestion/detect.ts` +2. Share detection logic between Web and TUI +3. Publish `AskQuestion.Event.Requested` from tool for more reliable client notification diff --git a/CONTEXT/PLAN-269-tui-transparency-toggle-2026-01-06.md b/CONTEXT/PLAN-269-tui-transparency-toggle-2026-01-06.md new file mode 100644 index 00000000000..4759d96e7bc --- /dev/null +++ b/CONTEXT/PLAN-269-tui-transparency-toggle-2026-01-06.md @@ -0,0 +1,244 @@ +# Plan: TUI Transparency Toggle Fix + +**Issue:** [#269 - Transparency toggle ignored; TUI background stays transparent across themes](https://github.com/Latitudes-Dev/shuvcode/issues/269) + +**Created:** 2026-01-06 + +**Revised:** 2026-01-06 - Critical fix: Target `resolveColor` or post-resolution normalization, not just `resolveTheme`. Added fallback chain for themes with all-transparent backgrounds. + +**Status:** NEEDS REVISION BEFORE IMPLEMENTATION + +## Overview +The TUI transparency toggle currently does not restore opaque backgrounds. The fix must ensure `theme_transparent=false` renders opaque backgrounds for all built-in themes, while `theme_transparent=true` enforces transparent backgrounds. The toggle must update the runtime theme immediately, persist across restart, and keep selected list item contrast readable. + +## Critical Issue Identified in Review + +**Root Cause:** The plan's original approach is incorrect. The current `resolveTheme` at line 226-230 only forces transparency when `transparent=true`. It does NOT force opacity when `transparent=false`. + +The real issue is that themes like `lucent-orng` use `"transparent"` as a literal color value in the JSON: +```json +"background": { "dark": "transparent", "light": "transparent" } +``` + +The `resolveColor` function at `theme.tsx:180` converts `"transparent"` to `RGBA(0,0,0,0)` **BEFORE** `resolveTheme` can apply any toggle override. By the time `resolveTheme` checks `if (transparent)`, the damage is done. + +## Requirements (from issue) +- [ ] `theme_transparent=false` renders opaque backgrounds for all built-in themes. +- [ ] `theme_transparent=true` renders transparent backgrounds consistently. +- [ ] Toggling the command updates the runtime theme immediately and persists across restart. +- [ ] Selected list item contrast stays readable when transparency is off. + +## Current Code Context +### Observations +- `resolveTheme` forces `background` alpha to 0 when `transparent` is true. +- `resolveColor` maps "transparent" or "none" to RGBA(0,0,0,0) regardless of toggle state. +- `selectedForeground` uses `theme.background.a === 0` to compute selected list item text color. +- Theme state is stored in KV using `theme_transparent` and read into `store.transparent` via `useKV`. +- Built-in theme `lucent-orng` contains "transparent" values, which can produce alpha 0 even when the toggle is off. + +### Internal References +| Area | File | Notes | +| --- | --- | --- | +| Toggle command | `packages/opencode/src/cli/cmd/tui/app.tsx` | "Toggle transparency" invokes `setTransparent(!transparent())`. | +| Theme resolution | `packages/opencode/src/cli/cmd/tui/context/theme.tsx:175-238` | `resolveTheme` function - add normalization here. | +| Color resolution | `packages/opencode/src/cli/cmd/tui/context/theme.tsx:177-196` | `resolveColor` converts "transparent" to alpha=0 - DO NOT MODIFY. | +| Selected foreground | `packages/opencode/src/cli/cmd/tui/context/theme.tsx:106-121` | `selectedForeground` checks `background.a === 0` - will auto-correct after normalization. | +| Theme persistence | `packages/opencode/src/cli/cmd/tui/context/theme.tsx:294,396-398` | `kv.get("theme_transparent", false)` and `kv.set("theme_transparent", transparent)`. | +| Built-in theme | `packages/opencode/src/cli/cmd/tui/context/theme/lucent-orng.json:64-79` | Uses "transparent" values for all backgrounds except `backgroundMenu`. | + +### Configuration Values +| Key | Location | Purpose | Type | +| --- | --- | --- | --- | +| `theme_transparent` | KV store | Persist transparency toggle | boolean | +| `theme` | KV store / sync config | Active theme name | string | +| `theme_mode` | KV store | Light/dark mode | "light" or "dark" | + +## Technical Approach and Decisions +### Hypotheses to Validate +- `store.transparent` is stuck `true` due to persistence or rehydration issues. +- Theme JSON values set to "transparent" are overriding the toggle when `transparent=false`. **CONFIRMED** +- Theme recomputation is not re-running after toggling. + +### Root Cause (Confirmed) +The `resolveColor` function at `theme.tsx:180` maps `"transparent"` or `"none"` strings to `RGBA(0,0,0,0)` **regardless of the toggle state**. This happens during color resolution, before `resolveTheme` can apply the `transparent` parameter. + +### Decision (Revised) +Add a **post-resolution normalization step** that enforces opaque backgrounds when `transparent=false`: +- When `transparent=true`, backgrounds should be fully transparent (current behavior). +- When `transparent=false`, ANY background color with alpha=0 should be replaced with an opaque fallback. +- Fallback chain: `background` → `backgroundPanel` → `backgroundElement` → `backgroundMenu` → derive from `primary`. + +Rationale: The acceptance criteria requires opaque backgrounds for all built-in themes when transparency is off. The normalization must happen AFTER `resolveColor` has processed all values. + +### Option Comparison (Updated) +| Option | Summary | Pros | Cons | Decision | +| --- | --- | --- | --- | --- | +| Pass `transparent` to `resolveColor` | Make color resolution aware of toggle | Early fix | Requires threading parameter through all calls | Rejected (too invasive) | +| **Post-resolution normalization** | Add step after all colors resolved to enforce opacity | Central fix, doesn't modify resolveColor | Requires fallback color logic | **Selected** | +| Edit theme JSONs | Replace "transparent" values with opaque colors per theme | Simple to reason about per theme | Breaks custom themes and user overrides | Rejected | +| Add per-theme allowlist | Allow only specific themes to stay transparent | Fine-grained | Contradicts acceptance criteria | Rejected | + +## Technical Specifications + +### Opaque Fallback Rules +Add a normalization function called AFTER all colors are resolved but BEFORE returning the theme: + +```ts +// In theme.tsx, after resolveTheme builds the resolved object + +function normalizeBackgrounds(resolved: Partial, transparent: boolean): Partial { + if (transparent) return resolved // No normalization when transparency is on + + // Find first opaque background to use as fallback + const findOpaqueFallback = (): RGBA => { + // Fallback chain: backgroundMenu → backgroundElement → backgroundPanel → derive from primary + const candidates = [ + resolved.backgroundMenu, + resolved.backgroundElement, + resolved.backgroundPanel, + resolved.background, + ] + + for (const color of candidates) { + if (color && color.a > 0) return color + } + + // Last resort: derive dark background from primary + // Use primary at 10% luminance for dark themes, 95% for light + const primary = resolved.primary! + return RGBA.fromInts( + Math.round(primary.r * 0.1 * 255), + Math.round(primary.g * 0.1 * 255), + Math.round(primary.b * 0.1 * 255), + 255 // Fully opaque + ) + } + + const fallback = findOpaqueFallback() + + // Replace any transparent backgrounds with the fallback + const backgroundFields: (keyof ThemeColors)[] = [ + 'background', 'backgroundPanel', 'backgroundElement', 'backgroundMenu' + ] + + for (const field of backgroundFields) { + const color = resolved[field] + if (color && color.a === 0) { + resolved[field] = fallback + } + } + + return resolved +} +``` + +### Integration Point +In `resolveTheme` function (`theme.tsx:175`), call normalization AFTER resolution but BEFORE returning: + +```ts +function resolveTheme(theme: ThemeJson, mode: "dark" | "light", transparent: boolean) { + // ... existing resolution logic (lines 176-230) ... + + // NEW: Normalize backgrounds when transparency is off + const normalized = normalizeBackgrounds(resolved, transparent) + + return { + ...normalized, + _hasSelectedListItemText: hasSelectedListItemText, + thinkingOpacity, + transparent, + } as Theme +} +``` + +### Impacted Colors +These fields are normalized when alpha is 0 and `transparent=false`: +- `background` - main app background +- `backgroundPanel` - panel/sidebar backgrounds +- `backgroundElement` - element backgrounds (inputs, buttons) +- `backgroundMenu` - menu/dropdown backgrounds + +### Selected List Item Contrast +The `selectedForeground` function at `theme.tsx:106-121` checks `theme.background.a === 0` to determine contrast mode: +- After normalization, `background.a` will be `1` (opaque) when `transparent=false` +- This means selected list items will use `theme.background` as foreground (correct behavior) +- No changes needed to `selectedForeground` - it will automatically behave correctly after normalization + +### lucent-orng Theme Analysis +This theme is the primary test case. Current values: +- `background`: `"transparent"` (dark/light) → alpha=0 +- `backgroundPanel`: `"transparent"` → alpha=0 +- `backgroundElement`: `"transparent"` → alpha=0 +- `backgroundMenu`: `"darkPanelBg"` / `"lightPanelBg"` → **opaque!** (`#2a1a1599` has alpha) + +Wait, `#2a1a1599` is a hex color with alpha. Let me check: +- `#2a1a1599` = RGB(42, 26, 21) with alpha 0x99 = 153/255 ≈ 60% opacity + +So `backgroundMenu` is semi-transparent, not fully opaque. The fallback chain must handle this: +- If ALL backgrounds have alpha < 1, derive from primary as last resort + +## Implementation Plan + +### Milestone 1: Reproduce and Inspect State +- [ ] Reproduce in TUI and log `transparent()` before and after toggling. +- [ ] Verify `kv.get("theme_transparent", false)` changes and persists across restart. +- [ ] Inspect `resolveTheme` output for `background.a` with multiple themes (Night Owl, Nord, lucent-orng). +- [ ] Confirm that the theme memo re-runs on `setTransparent` by logging `store.transparent` and `values().background.a`. +- [ ] **NEW:** Verify that `lucent-orng` theme resolves to alpha=0 even when toggle is off (confirms root cause). + +### Milestone 2: Fix Theme Resolution +- [ ] Create `normalizeBackgrounds(resolved, transparent)` helper function in `theme.tsx`. +- [ ] Implement fallback chain: `backgroundMenu` → `backgroundElement` → `backgroundPanel` → derive from `primary`. +- [ ] Handle semi-transparent colors (e.g., `#2a1a1599` with alpha=0x99) - require full opacity (alpha=1) for fallback. +- [ ] Call `normalizeBackgrounds()` at the end of `resolveTheme()` before returning. +- [ ] Ensure the `theme.transparent` flag reflects the toggle state correctly. + +### Milestone 3: Contrast and Theme UX +- [ ] Re-validate `selectedForeground` behavior with opaque backgrounds. +- [ ] Verify that selected list item contrast remains readable for Night Owl, Nord, opencode, and lucent-orng themes. +- [ ] **NEW:** Test with light mode themes to ensure derived fallback works for both dark and light modes. + +### Milestone 4: Tests +- [ ] Add unit tests in `packages/opencode/test/theme.test.ts` for: + - `normalizeBackgrounds` with fully transparent theme + - `normalizeBackgrounds` with semi-transparent `backgroundMenu` + - `normalizeBackgrounds` fallback derivation from `primary` + - Full `resolveTheme` with `transparent=false` and lucent-orng fixture +- [ ] Add test for `selectedForeground` to verify readable contrast when transparency is off. + +### Milestone 5: Manual Validation +- [ ] Toggle transparency on/off in TUI and verify immediate updates. +- [ ] Switch themes (Night Owl, Nord, lucent-orng) and verify backgrounds are opaque when toggle is off. +- [ ] Restart TUI and confirm the last toggle state is restored. +- [ ] **NEW:** Test lucent-orng specifically in both dark and light modes with transparency off. + +## Validation Criteria +### Automated +- [ ] `bun test` in `packages/opencode` passes. +- [ ] Theme transparency tests cover both on and off cases. + +### Manual +- [ ] With `theme_transparent=false`, background is opaque for all built-in themes. +- [ ] With `theme_transparent=true`, background is fully transparent. +- [ ] Selected list item text remains readable when transparency is off. +- [ ] Toggle state persists after restart. + +### Suggested Commands +```bash +cd /home/shuv/repos/worktrees/shuvcode/shuvcode-dev/packages/opencode +bun test +``` + +## External References (Git URLs) +- https://github.com/tauri-apps/wry/blob/dev/examples/transparent.rs +- https://github.com/tauri-apps/tao/blob/dev/examples/transparent.rs +- https://raw.githubusercontent.com/electron/electron/main/docs/api/browser-window.md + +## Risks and Mitigations +| Risk | Impact | Mitigation | +| --- | --- | --- | +| Opaque fallback picks a poor color for transparent themes | Medium | Use fallback chain to find best available opaque color; derive from primary as last resort. | +| Derived primary fallback looks bad | Medium | Use 10% luminance of primary for dark mode, 95% for light mode to ensure sufficient contrast. | +| Fix changes behavior for custom themes | Medium | Gate fallback only when `transparent=false` and alpha is 0. Custom themes with explicit opaque colors are unaffected. | +| Contrast regressions on selected items | Medium | Add tests for `selectedForeground` and manual spot checks. The function auto-corrects based on final `background.a`. | +| Semi-transparent backgrounds (e.g., 60% alpha) not handled | Low | Require full opacity (alpha=1) for fallback eligibility; semi-transparent stays as-is or falls through to derived. | diff --git a/CONTEXT/PLAN-270-tui-bash-spinner-stop-2026-01-06.md b/CONTEXT/PLAN-270-tui-bash-spinner-stop-2026-01-06.md new file mode 100644 index 00000000000..8654a600754 --- /dev/null +++ b/CONTEXT/PLAN-270-tui-bash-spinner-stop-2026-01-06.md @@ -0,0 +1,166 @@ +# Plan: TUI Bash Spinner Stops on Completion + +**Issue:** [#270 - Fix TUI tool spinner never stops after command completion](https://github.com/Latitudes-Dev/shuvcode/issues/270) + +**Created:** 2026-01-06 + +**Revised:** 2026-01-06 - Critical correction: Original hypothesis about metadata overwrites is incorrect. The guard already exists at `prompt.ts:664`. Investigation should focus on TUI reactivity chain, not metadata updates. + +**Status:** REVISED - INCORPORATES CODEBASE FINDINGS + +## Overview +The TUI Bash tool spinner continues animating after a command completes. The UI hides the spinner only when the tool part status is no longer `running`, so the plan focuses on ensuring the Bash tool part transitions to `completed` or `error` and that the TUI properly reacts to state changes. + +## Requirements (from issue) +- [ ] Spinner disappears when tool part status transitions to `completed` or `error`. +- [ ] Bash tool parts move out of `running` state once the command exits. +- [ ] No lingering spinner in transcript/history after completion. + +## Current Code Context +### Observations +- The Bash spinner uses a non-reactive constant `isRunning = props.part.state.status === "running"` in the session view; this can stay stale after updates. +- The Bash tool streams output via `ctx.metadata` asynchronously while the process is running. +- `SessionProcessor` updates tool parts to `completed` or `error` on tool-result/tool-error events. +- `ctx.metadata` in the prompt pipeline rewrites state with `status: "running"` and `time.start`. The guard checks in-memory toolcalls, but late metadata can still race with persisted updates if the entry has not been cleared yet. + +### Internal References +| Area | File | Notes | +| --- | --- | --- | +| Bash spinner state | `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:2088-2152` | `isRunning` is a non-reactive const; `Show when={isRunning}`. | +| Tool status transitions | `packages/opencode/src/session/processor.ts:171-209` | Sets tool part to `completed` on tool-result, `error` on tool-error. | +| Metadata guard + overwrite risk | `packages/opencode/src/session/prompt.ts:662-676` | Guard checks toolcalls entry; `ctx.metadata` rewrites status to `running`. | +| Bash tool execution | `packages/opencode/src/tool/bash.ts:201-217` | Streams output via `ctx.metadata` (fire-and-forget). | +| Bash tool return | `packages/opencode/src/tool/bash.ts:293-300` | Returns `{ title, metadata, output }` triggering tool-result. | +| Event definition | `packages/opencode/src/session/message-v2.ts:419-425` | Event name is `message.part.updated`. | +| Session updatePart publish | `packages/opencode/src/session/index.ts:391-399` | Publishes `MessageV2.Event.PartUpdated`. | +| TUI sync context | `packages/opencode/src/cli/cmd/tui/context/sync.tsx:112-249` | Handles `message.part.updated` events into store. | +| Spinner frames | `packages/opencode/src/cli/cmd/tui/util/spinners.ts` | Provides `getSpinnerFrame()` used by TUI. | + +## Technical Approach and Decisions +### Hypotheses to Validate + +**Hypothesis 1: tool-result not emitted** (Unlikely) +- The tool-result event is not emitted for Bash tool executions, so status never reaches `completed`. +- **Assessment:** Bash tool returns via `return { title, metadata, output }` at `bash.ts:293-300`, which should trigger tool-result in the AI SDK. + +**Hypothesis 2: non-reactive spinner state** (Likely) +- `isRunning` is a non-reactive const in the Bash component; it can remain `true` after status updates. + +**Hypothesis 3: metadata overwrites** (Possible - guard not sufficient) +- `ctx.metadata` rewrites status to `running`. The guard only checks the in-memory toolcalls entry; late metadata can still race with persisted `completed` updates. + +**Hypothesis 4: TUI reactivity gap / event delivery** (Possible) +- The completion update is emitted but not reflected in the TUI due to sync or rendering issues. +- Solid.js reactivity chain: `sync.data.part[messageID]` → component props → `isRunning` derivation +- Event name is `message.part.updated`; confirm the stream delivers it. + +**Hypothesis 5: Part lookup mismatch** (Possible) +- The TUI looks up parts by `messageID` but the spinner component receives the wrong part reference. +- Need to verify TUI part lookup matches the part being updated. + +### Decision (Revised) +Prioritize the **non-reactive spinner state** and validate ordering/race conditions: +1. Confirm `isRunning` is non-reactive in the Bash component and fix it if so. +2. Verify tool-result fires and `Session.updatePart` is called with `status: "completed"`. +3. Validate whether late `ctx.metadata` calls can regress the persisted status. +4. Verify the event stream (`message.part.updated`) reaches the TUI sync store. + +### Option Comparison (Revised) +| Option | Summary | Pros | Cons | Decision | +| --- | --- | --- | --- | --- | +| Fix Bash spinner reactivity | Make `isRunning` reactive (memo or inline check) | Directly addresses likely root cause | Requires UI change only | **Primary fix** | +| Guard against status regression | Prevent `completed`/`error` -> `running` writes | Eliminates race risk | Needs careful invariants | Secondary if race confirmed | +| Verify event delivery | Confirm `message.part.updated` events reach sync store | Confirms data path | Diagnostic only | Diagnostic step | +| Fix part lookup mismatch | Ensure spinner uses updated part instance | Resolves mismatched references | Less likely | Triage if needed | +| Add timeout-based spinner hide | Hide spinner after N seconds regardless of status | Simple workaround | Masks underlying bug | Rejected | + +## Technical Specifications +### Tool Part Status Flow +- Initial tool part state: `running`. +- Completion: `completed` with `time.end`, `metadata`, `output`. +- Failure: `error` with `time.end` and error message. + +### Bash Tool Metadata Schema +- `metadata.output`: raw output (possibly truncated). +- `metadata.description`: user-provided description. +- `metadata.exit`: process exit code (final result). + +### UI Behavior +- Spinner visible only when `props.part.state.status === "running"`. + +## Implementation Plan + +### Milestone 1: Reproduce and Trace State Transitions +- [ ] Reproduce in TUI and confirm the Bash part status after command completion (server-side). +- [ ] Inspect `Bash` component `isRunning` for reactivity; convert to `createMemo` or inline reactive check if stale. +- [ ] Add logging to `processor.ts:171-191` (tool-result case) to verify it fires for Bash tool. +- [ ] Add logging to `Session.updatePart` to confirm it's called with `status: "completed"`. +- [ ] Log when `ctx.metadata` attempts to write after completion (guarded and unguarded cases). + +### Milestone 2: Trace Event Delivery and Store Updates +- [ ] Add logging to TUI sync handler when `message.part.updated` is received. +- [ ] Add logging when `sync.data.part[messageID]` is updated in the store. +- [ ] Identify the TUI component that renders the Bash spinner and trace its props/derivations. +- [ ] Confirm the component re-renders on part status change (post `isRunning` fix). + +### Milestone 3: Fix Based on Findings +Based on investigation, the fix will be one or more of: +- [ ] Fix Bash spinner reactivity (`isRunning` as memo or inline check). +- [ ] **If metadata regression confirmed:** Prevent `completed`/`error` → `running` writes (guard in `Session.updatePart` or `ctx.metadata`). +- [ ] **If event not delivering:** Fix event stream subscription or reconnection logic. +- [ ] **If store not updating:** Fix Solid.js store update (ensure `produce` or proper setter is used). +- [ ] **If part lookup wrong:** Fix the part ID/callID matching between processor and TUI. + +### Milestone 4: Tests +- [ ] Add a session-level test to verify tool-result → part status `completed` transition. +- [ ] **If regression guard added:** Add a test that prevents `completed`/`error` → `running` status downgrade. +- [ ] Document TUI spinner verification as manual (no TUI harness today). + +### Milestone 5: Manual Validation +- [ ] Run a Bash command via the TUI and confirm the spinner stops. +- [ ] Verify at least one other tool (Write or Task) still updates correctly. +- [ ] **NEW:** Test with both short (<1s) and long (>5s) running commands. +- [ ] **NEW:** Test spinner behavior when command errors (non-zero exit). + +## Validation Criteria +### Automated +- [ ] `bun test` in `packages/opencode` passes. +- [ ] New tests cover status transitions and any regression guard (if added). + +### Manual +- [ ] Bash tool spinner disappears after command completion. +- [ ] Bash tool parts show `completed` or `error` statuses in transcript/history. +- [ ] No regressions in other tool spinners. + +### Suggested Commands +```bash +cd /home/shuv/repos/worktrees/shuvcode/shuvcode-dev/packages/opencode +bun test +``` + +## Current Findings + +Based on codebase review: + +1. **`ctx.metadata` guard exists but is not conclusive** + - Guard checks the in-memory toolcalls entry; late metadata can still race if the entry has not been cleared yet. +2. **Bash tool metadata calls are fire-and-forget** + - Streaming metadata updates (`bash.ts:201-217`) are not awaited and can arrive after completion. +3. **tool-result handler updates status when invoked** + - `processor.ts:171-188` sets status to `completed`; still verify it fires for Bash tool. + +## External References (Git URLs) +- https://github.com/sindresorhus/ora +- https://github.com/typesense/typesense/blob/e44a57004c981c8d7be7459d792a0fc971fdb05d/benchmark/src/services/typesense-process.ts +- https://github.com/vadimdemedes/pronto/blob/5e5ea6a8e38eec315542021010efd5d1efcb9e72/cli.js + +## Risks and Mitigations +| Risk | Impact | Mitigation | +| --- | --- | --- | +| Non-reactive `isRunning` is the root cause | High | Fix to reactive memo/inline check; re-test spinner behavior. | +| Metadata race causes status regression | Medium | Add regression guard and test; log ordering for confirmation. | +| Root cause is in Solid.js reactivity beyond Bash component | Medium | Use Solid.js DevTools or targeted logging to trace updates. | +| SSE disconnection causes missed updates | Medium | Check SSE reconnection logic; consider adding heartbeat verification. | +| Logging overwhelms output | Low | Gate logs behind debug flag or sample output. | +| Fix breaks other tool spinners | Medium | Add regression test for Write or Task tool and verify manually. | +| Investigation takes longer than fix | Low | Time-box investigation to 2 hours; document findings even if incomplete. | diff --git a/CONTEXT/PLAN-271-web-input-bar-bottom-padding-2026-01-06.md b/CONTEXT/PLAN-271-web-input-bar-bottom-padding-2026-01-06.md new file mode 100644 index 00000000000..a2705d3b662 --- /dev/null +++ b/CONTEXT/PLAN-271-web-input-bar-bottom-padding-2026-01-06.md @@ -0,0 +1,259 @@ +# Plan: Fix Web Input Bar Bottom Padding After Status Bar Removal + +**Issue:** [#271](https://github.com/Latitudes-Dev/shuvcode/issues/271) +**Created:** 2026-01-06 +**Type:** Bug Fix (CSS/Styling) +**Complexity:** Low +**Estimated Time:** 15-30 minutes + +--- + +## Problem Summary + +After removing the bottom status bar in commit `d60c9a9eb` (to adopt upstream changes where MCP/server info moved to the top header), the prompt input bar now sits flush against the bottom edge of the screen without adequate padding. This creates a cramped visual appearance and poor UX, especially on desktop. + +### Root Cause + +The `` component (32px height via `h-8` class) previously provided visual spacing at the bottom of the viewport. With its removal, no compensation was made for the lost vertical space. + +### Current State + +**File:** `packages/app/src/pages/session.tsx:984` + +```tsx +
(promptDock = el)} + class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none" + style={{ "padding-bottom": "env(safe-area-inset-bottom, 0px)" }} +> +``` + +**Issues:** +1. `pb-4` (16px mobile) and `md:pb-8` (32px desktop) are insufficient now that the status bar is gone +2. The inline `style` with `env(safe-area-inset-bottom)` **overwrites** the Tailwind `pb-*` classes entirely on iOS devices +3. Desktop has no minimum padding guarantee + +--- + +## Technical Analysis + +### CSS Spacing Scale (Tailwind 4) + +| Class | Value | +|-------|-------| +| `pb-4` | 1rem (16px) | +| `pb-6` | 1.5rem (24px) | +| `pb-8` | 2rem (32px) | +| `pb-10` | 2.5rem (40px) | +| `pb-12` | 3rem (48px) | + +### Safe Area Inset Handling + +The current implementation has a flaw: using `style={{ "padding-bottom": "env(...)" }}` as an inline style **completely overrides** any Tailwind `pb-*` classes. This means: + +- On devices with no safe area (desktop, most Android), `env(safe-area-inset-bottom, 0px)` resolves to `0px`, leaving **no** bottom padding +- The Tailwind classes (`pb-4 md:pb-8`) are present but never applied due to inline style specificity + +### Best Practice Pattern + +From [Safari 15 Bottom Tab Bars article](https://samuelkraft.com/blog/safari-15-bottom-tab-bars-web) and MDN documentation, the recommended approach is to use `max()` to combine a minimum padding with the safe area inset: + +```css +padding-bottom: max(2rem, env(safe-area-inset-bottom, 0px)); +``` + +This ensures: +- Minimum 2rem (32px) padding on all devices +- Safe area inset is respected when it's larger than the minimum + +--- + +## Acceptance Criteria (from Issue) + +- [ ] Input bar has visible padding/margin from the bottom edge on desktop (minimum ~16-24px) +- [ ] Mobile safe area insets are still respected via `env(safe-area-inset-bottom)` +- [ ] The gradient background still fades correctly above the input +- [ ] Visual consistency with the previous appearance (when status bar existed) + +--- + +## Implementation Plan + +### Task 1: Update Prompt Dock Container Styling + +**File:** `packages/app/src/pages/session.tsx` +**Line:** ~984 + +#### Approach A: Use CSS `max()` Function (Recommended) + +Replace the separate `class` and `style` attributes with a combined approach using `max()`: + +```diff +
(promptDock = el)} +- class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none" +- style={{ "padding-bottom": "env(safe-area-inset-bottom, 0px)" }} ++ class="absolute inset-x-0 bottom-0 pt-12 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none" ++ style={{ "padding-bottom": "max(1.5rem, env(safe-area-inset-bottom, 0px))" }} +> +``` + +**Rationale:** +- `max(1.5rem, env(...))` ensures minimum 24px padding while respecting larger safe areas +- Removes redundant `pb-4 md:pb-8` classes that were being overridden anyway +- Single source of truth for bottom padding + +#### Approach B: Increase Tailwind Classes + Fix Style Override + +If we want to keep the Tailwind classes for responsive behavior: + +```diff +
(promptDock = el)} +- class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none" +- style={{ "padding-bottom": "env(safe-area-inset-bottom, 0px)" }} ++ class="absolute inset-x-0 bottom-0 pt-12 pb-6 md:pb-10 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none" ++ style={{ "padding-bottom": "max(var(--tw-pb), env(safe-area-inset-bottom, 0px))" }} +> +``` + +**Note:** Tailwind 4 doesn't expose `--tw-pb` directly, so Approach A is cleaner. + +#### Approach C: Use CSS Variables (as done in askquestion-wizard.tsx) + +Following the pattern in `packages/app/src/components/askquestion-wizard.tsx:336`: + +```tsx +style={{ "padding-bottom": "calc(1.5rem + var(--safe-area-inset-bottom))" }} +``` + +Where `--safe-area-inset-bottom` is defined in `packages/app/src/index.css`: + +```css +:root { + --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px); +} +``` + +**Note:** This adds to the safe area rather than taking the max, which could result in excessive padding on iOS devices. + +### Recommended Solution + +**Use Approach A** with `max()` function: + +- [ ] **1.1** Edit `packages/app/src/pages/session.tsx:984` +- [ ] **1.2** Remove `pb-4 md:pb-8` from class string +- [ ] **1.3** Update style to use `max(1.5rem, env(safe-area-inset-bottom, 0px))` + +If different padding is desired for mobile vs desktop, consider: +```tsx +style={{ + "padding-bottom": window.innerWidth >= 768 + ? "max(2.5rem, env(safe-area-inset-bottom, 0px))" // Desktop: 40px min + : "max(1.5rem, env(safe-area-inset-bottom, 0px))" // Mobile: 24px min +}} +``` + +However, for simplicity and SSR compatibility, a single `max()` value is preferred. + +--- + +### Task 2: Verify Gradient Background + +- [ ] **2.1** Confirm gradient (`bg-gradient-to-t from-background-stronger via-background-stronger to-transparent`) still displays correctly with increased padding +- [ ] **2.2** Test that the gradient fades properly above the input area + +The gradient is applied to the container, not the padding, so it should adapt automatically. + +--- + +### Task 3: Test Across Viewports + +- [ ] **3.1** Test on desktop browser (Chrome/Firefox/Safari) at various widths +- [ ] **3.2** Test on mobile simulator or device (iOS Safari, Chrome Android) +- [ ] **3.3** Test in PWA mode on iOS (Dynamic Island consideration) +- [ ] **3.4** Verify safe area insets work on devices with home indicators + +--- + +### Task 4: Visual QA + +- [ ] **4.1** Compare before/after screenshots +- [ ] **4.2** Verify input bar no longer appears flush against bottom edge +- [ ] **4.3** Confirm minimum 24px visible padding on desktop +- [ ] **4.4** Ensure no excessive whitespace (keep it balanced) + +--- + +## Code References + +### Internal Files + +| File | Purpose | +|------|---------| +| `packages/app/src/pages/session.tsx:984` | Prompt dock container (target of fix) | +| `packages/app/src/pages/session.tsx:985` | Current inline style with `env()` | +| `packages/app/src/index.css:6-11` | CSS variable definitions for safe area insets | +| `packages/app/src/components/status-bar.tsx:49` | Removed StatusBar component (reference for original spacing: `h-8` = 32px) | +| `packages/app/src/components/askquestion-wizard.tsx:336` | Similar safe area handling pattern | + +### Related Commits + +| Commit | Description | +|--------|-------------| +| `d60c9a9eb` | Removed StatusBar, causing this issue | +| `90d5fc834` | Adopted upstream header pattern (context) | + +### External References + +| Resource | URL | +|----------|-----| +| Safari 15 Bottom Tab Bars (safe area patterns) | https://samuelkraft.com/blog/safari-15-bottom-tab-bars-web | +| MDN env() function | https://developer.mozilla.org/en-US/docs/Web/CSS/env | +| CSS max() function | https://developer.mozilla.org/en-US/docs/Web/CSS/max | + +--- + +## Testing Commands + +```bash +# Start development server +cd packages/app && bun dev + +# Open in browser at http://localhost:3001 +# Test at various viewport sizes + +# For iOS testing, use Safari's Responsive Design Mode +# or connect a real device via Safari Web Inspector +``` + +--- + +## Rollback Plan + +If the fix causes issues: + +1. Revert the single line change in `session.tsx:984-985` +2. Restore original classes: `pb-4 md:pb-8` +3. Restore original style: `{ "padding-bottom": "env(safe-area-inset-bottom, 0px)" }` + +--- + +## Definition of Done + +- [ ] Input bar has minimum ~24px padding from bottom edge on desktop +- [ ] Mobile safe area insets (iPhone notch/home indicator) are respected +- [ ] Gradient background fades correctly +- [ ] Visual regression testing passed +- [ ] No TypeScript errors +- [ ] Works in standard browser and PWA mode +- [ ] PR created and reviewed + +--- + +## Notes + +- This is a low-risk, single-file CSS change +- No tests required (visual/CSS change) +- Should be a quick fix once the approach is decided +- The `max()` CSS function has excellent browser support (96%+ globally) diff --git a/CONTEXT/PLAN-tauri-mobile-support-2026-01-06.md b/CONTEXT/PLAN-tauri-mobile-support-2026-01-06.md new file mode 100644 index 00000000000..f37b4403fb8 --- /dev/null +++ b/CONTEXT/PLAN-tauri-mobile-support-2026-01-06.md @@ -0,0 +1,912 @@ +# Tauri Mobile Support Implementation Plan + +**Date:** 2026-01-06 +**Author:** Shuvcode Fork Team +**Status:** Draft +**Target:** Android & iOS native apps via Tauri v2 + +## Executive Summary + +This plan outlines the implementation of native mobile app support for the shuvcode fork using Tauri v2's mobile capabilities. The fork already has excellent PWA support with mobile-optimized components. This plan builds on that foundation to create native Android and iOS apps that provide better platform integration, offline support, and App Store/Play Store distribution. + +## Current State Analysis + +### Existing Mobile Infrastructure (PWA) + +The shuvcode fork already has significant mobile-ready infrastructure: + +| Component | Location | Purpose | +|-----------|----------|---------| +| `MobileTerminalInput` | `packages/app/src/components/mobile-terminal-input.tsx` | Hidden input bridge for mobile keyboard to terminal WebSocket | +| `PullToRefresh` | `packages/app/src/components/pull-to-refresh.tsx` | Touch gesture detection for iOS-style pull-to-refresh | +| `useKeyboardVisibility` | `packages/app/src/hooks/use-keyboard-visibility.tsx` | Visual viewport API hook for mobile keyboard detection | +| Mobile sidebar | `packages/app/src/context/layout.tsx` | `mobileSidebar` state, drawer-style navigation | +| Mobile tabs | `packages/app/src/pages/session.tsx` | Session/Review tab switcher for mobile | +| Safe area insets | `packages/app/src/index.css` | CSS variables for notch/dynamic island handling | +| PWA manifest | `packages/app/public/site.webmanifest` | Standalone display, portrait orientation | +| Service worker | `packages/app/vite.config.ts` | VitePWA with offline caching | + +### Existing Desktop Tauri Infrastructure + +The desktop Tauri app provides a solid foundation: + +| Component | Location | Purpose | +|-----------|----------|---------| +| `tauri.conf.json` | `packages/desktop/src-tauri/tauri.conf.json` | App configuration, bundle settings | +| `Cargo.toml` | `packages/desktop/src-tauri/Cargo.toml` | Rust dependencies, Tauri plugins | +| `lib.rs` | `packages/desktop/src-tauri/src/lib.rs` | Main app logic, sidecar management | +| `cli.rs` | `packages/desktop/src-tauri/src/cli.rs` | CLI installation, path resolution | +| `window_customizer.rs` | `packages/desktop/src-tauri/src/window_customizer.rs` | Pinch zoom disable (Linux only) | +| Mobile icons | `packages/desktop/src-tauri/icons/prod/android/` | Pre-generated Android mipmap icons | +| iOS icons | `packages/desktop/src-tauri/icons/prod/ios/` | Pre-generated iOS AppIcon assets | +| Platform context | `packages/app/src/context/platform.tsx` | Platform abstraction layer | +| Desktop entry | `packages/desktop/src/index.tsx` | Tauri platform implementation | + +### Key Observation: Mobile Entry Point Exists + +The codebase already includes the mobile entry point attribute: +```rust +// packages/desktop/src-tauri/src/lib.rs:193 +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { +``` + +This indicates the Rust side is partially prepared for mobile builds. + +## Technical Challenges + +### 1. Sidecar Binary Architecture + +**Current Desktop Approach:** +- Desktop app spawns a sidecar binary (`shuvcode-cli`) that runs the server +- Server listens on localhost and WebView connects to it +- Sidecar handles all agent functionality, LSP, MCP, file operations + +**Mobile Challenge:** +- iOS does not allow spawning background processes/sidecars +- Android has similar restrictions in recent versions +- Mobile apps run in sandboxed environments + +**Solution Options:** + +| Option | Pros | Cons | Recommendation | +|--------|------|------|----------------| +| **A. Remote Server** | Simple, works today | Requires network, no offline | For MVP/testing | +| **B. Embedded Rust Server** | True native, offline capable | Complex FFI, larger binary | Long-term goal | +| **C. WebAssembly Runtime** | Cross-platform, sandboxed | Performance limitations | Experimental | + +### 2. Terminal Emulation + +**Current State:** +- Uses `ghostty-web` WASM module for terminal rendering +- WebSocket connection to PTY server endpoint +- MobileTerminalInput bridges native keyboard + +**Mobile Challenge:** +- PTY server runs in sidecar (not available on mobile) +- Need alternative for shell access + +**Solution:** +- Phase 1: Connect to remote shuvcode server (existing PWA behavior) +- Phase 2: Explore terminal.js alternatives or WebSocket proxy + +### 3. File System Access + +**Current Desktop:** +- Full filesystem access via Tauri's fs plugin +- Native file/directory pickers + +**Mobile Challenge:** +- iOS: Sandboxed app container + Files app integration +- Android: Scoped storage since Android 11 + +**Solution:** +- Use Tauri's mobile-compatible plugins +- Integrate with system file providers +- In the MVP, rely on the remote server filesystem (no device-local project storage) +- Consider workspace sync via Git or cloud storage + +### 4. Server URL Resolution & Persistence + +**Current State:** +- `defaultServerUrl` is computed synchronously in `packages/app/src/app.tsx` +- Server selection and persistence live in `ServerProvider` (`packages/app/src/context/server.tsx`) +- `DialogSelectServer` already supports add/switch + health checks + +**Mobile Challenge:** +- Mobile needs a remote server URL injected before `App` renders +- Adding a separate mobile server dialog risks divergence + +**Solution:** +- Inject `window.__SHUVCODE__.serverUrl` in the mobile entry before render +- Update `defaultServerUrl` to check this value first +- Reuse `DialogSelectServer` for all server changes + +## Implementation Plan + +### Phase 1: Project Initialization & Configuration + +#### 1.1 Initialize Tauri Mobile Targets + +- [ ] Run `bun tauri android init` in `packages/desktop` +- [ ] Run `bun tauri ios init` in `packages/desktop` +- [ ] Verify generated files: + - `src-tauri/gen/android/` - Android Studio project + - `src-tauri/gen/apple/` - Xcode project + +**Files Created:** +``` +packages/desktop/src-tauri/ + gen/ + android/ + app/ + build.gradle.kts + src/main/ + AndroidManifest.xml + java/ai/shuv/desktop/ + MainActivity.kt + res/ + build.gradle.kts + settings.gradle.kts + apple/ + Shuvcode.xcodeproj/ + Shuvcode/ + Info.plist + Assets.xcassets/ +``` + +#### 1.2 Configure Mobile Identifiers + +- [ ] Update `packages/desktop/src-tauri/tauri.conf.json` with shared mobile identifiers and defaults. +- [ ] Add mobile overrides in `packages/desktop/src-tauri/tauri.android.conf.json` and `packages/desktop/src-tauri/tauri.ios.conf.json` (do not place these at repo root). +- [ ] Update `packages/desktop/src-tauri/tauri.prod.conf.json` so production identifiers and plugin config are correct for mobile (e.g., disable updater on mobile builds). + +```json +{ + "identifier": "ai.shuv.shuvcode", + "bundle": { + "iOS": { + "developmentTeam": "YOUR_TEAM_ID", + "minimumSystemVersion": "13.0" + }, + "android": { + "minSdkVersion": 24 + } + } +} +``` + +**Reference Files:** +- `packages/desktop/src-tauri/tauri.conf.json:1-43` +- `packages/desktop/src-tauri/tauri.prod.conf.json:1-33` + +#### 1.3 Configure App Icons + +- [ ] Verify existing icons in `packages/desktop/src-tauri/icons/prod/android/` +- [ ] Verify existing icons in `packages/desktop/src-tauri/icons/prod/ios/` +- [ ] Add dev variant icons for debug builds +- [ ] Run `bun tauri icon` if regeneration needed + +**Current Icon Structure:** +``` +packages/desktop/src-tauri/icons/ + prod/ + android/ + mipmap-hdpi/ + mipmap-mdpi/ + mipmap-xhdpi/ + mipmap-xxhdpi/ + mipmap-xxxhdpi/ + mipmap-anydpi-v26/ + values/ + ios/ + AppIcon-20x20@*.png + AppIcon-29x29@*.png + AppIcon-40x40@*.png + AppIcon-60x60@*.png + AppIcon-76x76@*.png + AppIcon-83.5x83.5@2x.png + AppIcon-512@2x.png +``` + +### Phase 2: Rust Mobile Adaptation + +#### 2.1 Conditional Compilation for Mobile + +- [ ] Split `run()` into `run_desktop()` and `run_mobile()` and gate all desktop-only modules/commands (sidecar, window customizer, clipboard, updater, shell/process) with `cfg(not(mobile))` so mobile builds compile cleanly. + +```rust +// packages/desktop/src-tauri/src/lib.rs + +#[cfg(not(mobile))] +mod cli; +#[cfg(not(mobile))] +mod window_customizer; + +#[cfg(not(mobile))] +use cli::{get_sidecar_path, install_cli, sync_cli}; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + #[cfg(mobile)] + { + // Mobile-specific initialization + run_mobile(); + } + + #[cfg(not(mobile))] + { + // Existing desktop code + run_desktop(); + } +} + +#[cfg(mobile)] +fn run_mobile() { + tauri::Builder::default() + .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_store::Builder::new().build()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_notification::init()) + // Note: shell plugin limited on mobile + // Note: window-state not needed on mobile + // Note: updater works differently on mobile (app stores) + .invoke_handler(tauri::generate_handler![ + // Mobile-safe commands only + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +**Reference Files:** +- `packages/desktop/src-tauri/src/lib.rs:193-330` + +#### 2.2 Update Cargo.toml for Mobile + +- [ ] Add mobile-specific dependencies: + +```toml +# packages/desktop/src-tauri/Cargo.toml + +[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] +# Mobile-specific deps +log = "0.4" + +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +# Desktop-only deps +gtk = "0.18.2" +webkit2gtk = "=2.0.1" +listeners = "0.3" + +[dependencies] +# Common deps - verify mobile compatibility +tauri = { version = "2", features = ["macos-private-api", "devtools"] } +# Note: Remove features not available on mobile +``` + +- [ ] Remove/conditionally compile desktop-only plugins: + - `tauri-plugin-updater` - App store handles updates on mobile + - `tauri-plugin-window-state` - Not applicable to mobile + - `tauri-plugin-clipboard-manager` - Requires mobile permissions +- [ ] Update `packages/desktop/src-tauri/tauri.prod.conf.json` to ensure updater config remains desktop-only and does not affect mobile builds. + +**Reference Files:** +- `packages/desktop/src-tauri/Cargo.toml:1-43` +- `packages/desktop/src-tauri/tauri.prod.conf.json:1-33` + +#### 2.3 Add Mobile Commands + +- [ ] Create mobile-specific Tauri commands: + +```rust +// packages/desktop/src-tauri/src/mobile.rs (new file) + +#[cfg(mobile)] +use tauri::command; + +#[cfg(mobile)] +#[command] +pub fn get_server_url() -> String { + // Return configured server URL for remote connection + std::env::var("SHUVCODE_SERVER_URL") + .unwrap_or_else(|_| "https://your-server.shuv.ai".to_string()) +} + +#[cfg(mobile)] +#[command] +pub fn is_mobile() -> bool { + true +} +``` + +### Phase 3: Mobile Capabilities & Permissions + +#### 3.1 Create Mobile Capabilities File + +- [ ] Create `packages/desktop/src-tauri/capabilities/mobile.json`: + +```json +{ + "$schema": "../gen/schemas/mobile-schema.json", + "identifier": "mobile", + "description": "Capability for mobile platforms", + "platforms": ["android", "iOS"], + "permissions": [ + "core:default", + "opener:default", + "dialog:default", + "store:default", + "os:default", + "notification:default", + { + "identifier": "http:default", + "allow": [ + { "url": "http://*" }, + { "url": "https://*" } + ] + } + ] +} +``` + +**Reference Files:** +- `packages/desktop/src-tauri/capabilities/default.json:1-29` + +#### 3.2 Configure Android Permissions + +- [ ] Update `AndroidManifest.xml` (generated, may need customization): + +```xml + + + + + + + + + + +``` + +#### 3.3 Configure iOS Permissions + +- [ ] Update `Info.plist` (generated, may need customization): + +```xml + +UIFileSharingEnabled + +LSSupportsOpeningDocumentsInPlace + + + +NSUserNotificationUsageDescription +Notifications for agent completions and errors +``` + +### Phase 4: Frontend Mobile Platform Implementation + +#### 4.1 Create Mobile Platform Context + +- [ ] Create `packages/desktop/src/mobile.tsx`: + +```tsx +// Mobile platform implementation +import { Platform, PlatformProvider } from "@opencode-ai/app" +import { App } from "@opencode-ai/app" +import { AsyncStorage } from "@solid-primitives/storage" +import { Store } from "@tauri-apps/plugin-store" +import { fetch as tauriFetch } from "@tauri-apps/plugin-http" +import { open as shellOpen } from "@tauri-apps/plugin-opener" +import pkg from "../package.json" + +const mobilePlatform: Platform = { + platform: "mobile" as const, // New platform type + version: pkg.version, + + openLink(url: string) { + void shellOpen(url).catch(() => undefined) + }, + + storage: (name = "default.dat") => { + // Reuse the exact AsyncStorage implementation from packages/desktop/src/index.tsx + // so persisted stores (server.v4, notification.v1, etc.) behave identically. + const api: AsyncStorage = { + // ... (copy implementation, no stub) + } + return api + }, + + restart: async () => { + // Mobile apps don't restart - reload webview + window.location.reload() + }, + + notify: async (title, description, href) => { + // Use Tauri notification plugin + // Implementation depends on plugin availability + }, + + // Mobile-specific: No directory picker (use server-side browse) + // Mobile-specific: No file picker (limited) + // Mobile-specific: No updater (app store) + + fetch: tauriFetch as typeof fetch, +} + +export function MobileApp() { + return ( + + + + ) +} +``` + +**Reference Files:** +- `packages/desktop/src/index.tsx:1-207` +- `packages/app/src/context/platform.tsx:1-58` + +#### 4.2 Update Platform Type and Branches + +- [ ] Update `packages/app/src/context/platform.tsx` to include `"mobile"` in the platform union. +- [ ] Extend the `Window.__SHUVCODE__` type in `packages/app/src/app.tsx` to include `serverUrl?: string` for mobile server injection. +- [ ] Audit platform branches (for example, `platform.platform === "desktop"` in `packages/app/src/pages/session.tsx`) and define mobile behavior. Default to web behavior unless a mobile-specific override is required. +- [ ] Keep directory/file picker APIs undefined on mobile so browse buttons are hidden in `DialogCreateProject`. + +```tsx +export type Platform = { + /** Platform discriminator */ + platform: "web" | "desktop" | "mobile" + // ... rest unchanged +} + +declare global { + interface Window { + __SHUVCODE__?: { updaterEnabled?: boolean; port?: number; serverUrl?: string } + } +} +``` + +#### 4.3 Create Mobile Entry Point + +- [ ] Create `packages/desktop/src/mobile-entry.tsx` that resolves the mobile server URL via `invoke("get_server_url")`, injects it into `window.__SHUVCODE__`, then renders `MobileApp`. +- [ ] Ensure this runs before `App` renders so `defaultServerUrl` can read `window.__SHUVCODE__.serverUrl`. +- [ ] If top-level await is not supported by the current build target, wrap the initialization in an async IIFE before calling `render()`. + +```tsx +// Mobile-specific entry point +import { render } from "solid-js/web" +import { invoke } from "@tauri-apps/api/core" +import { MobileApp } from "./mobile" + +const root = document.getElementById("root") +if (!(root instanceof HTMLElement)) { + throw new Error("Root element not found") +} + +const serverUrl = await invoke("get_server_url").catch(() => "") +if (serverUrl) { + window.__SHUVCODE__ = { ...(window.__SHUVCODE__ ?? {}), serverUrl } +} + +render(() => , root) +``` + +#### 4.4 Configure Vite/HTML for Mobile + +- [ ] Validate how `@opencode-ai/app/vite` handles entrypoints; prefer reusing `packages/desktop/index.html` to preserve the theme preload script and current meta tags. +- [ ] If a separate HTML entry is required, duplicate `packages/desktop/index.html` to `packages/desktop/mobile.html` and keep the `oc-theme-preload-script` and existing meta tags. Only then update `packages/desktop/vite.config.ts` to point to the alternate HTML. + +### Phase 5: Server Connection Strategy + +#### 5.1 Remote Server Configuration (reuse existing server flow) + +For the initial mobile release, the app will act as a remote-server client and reuse the existing server selection/persistence system: + +- [ ] Inject the mobile default server URL via `window.__SHUVCODE__.serverUrl` (set in the mobile entry) and update `defaultServerUrl` in `packages/app/src/app.tsx` to check this before localhost/origin. +- [ ] Reuse `ServerProvider` persistence (`server.v4`) and `DialogSelectServer` for adding/switching servers (no mobile-only server dialog). +- [ ] Confirm health checks and requests use `platform.fetch` so Tauri's HTTP plugin is respected on mobile. + +```tsx +// packages/app/src/app.tsx +const defaultServerUrl = iife(() => { + if (window.__SHUVCODE__?.serverUrl) return window.__SHUVCODE__.serverUrl + // existing resolution logic... +}) +``` + +#### 5.2 Mobile UX for Remote Filesystem + +- [ ] Update copy in `DialogCreateProject` to clarify that browsing/creating projects happens on the connected server filesystem when running on mobile. +- [ ] Keep `platform.openDirectoryPickerDialog` undefined on mobile so the Browse buttons remain hidden (already gated by `Show when={platform.openDirectoryPickerDialog}`). +- [ ] Confirm `StatusBar` is visible on mobile (PWA hiding logic should not apply) so `DialogSelectServer` remains reachable. +- [ ] Document the authentication flow for remote servers (OAuth/deep link if required). + +### Phase 6: Build & Test Infrastructure + +#### 6.1 Android Development Setup + +- [ ] Document Android SDK requirements: + - Android Studio + - Android SDK (API 24+) + - NDK (for Rust compilation) + - Java 17+ + +- [ ] Add npm scripts to `packages/desktop/package.json`: + +```json +{ + "scripts": { + "android:init": "tauri android init", + "android:dev": "tauri android dev", + "android:build": "tauri android build", + "android:build:apk": "tauri android build --apk", + "android:build:aab": "tauri android build --aab" + } +} +``` + +#### 6.2 iOS Development Setup + +- [ ] Document iOS development requirements: + - macOS + - Xcode 14+ + - Apple Developer account + - iOS Simulator or device + +- [ ] Add npm scripts: + +```json +{ + "scripts": { + "ios:init": "tauri ios init", + "ios:dev": "tauri ios dev", + "ios:build": "tauri ios build" + } +} +``` + +#### 6.3 Rust Target Installation + +- [ ] Document required Rust targets: + +```bash +# Android targets +rustup target add aarch64-linux-android +rustup target add armv7-linux-androideabi +rustup target add i686-linux-android +rustup target add x86_64-linux-android + +# iOS targets +rustup target add aarch64-apple-ios +rustup target add x86_64-apple-ios +rustup target add aarch64-apple-ios-sim +``` + +### Phase 7: CI/CD Integration + +#### 7.1 Android Build Workflow + +- [ ] Create `.github/workflows/mobile-android.yml`: + +```yaml +name: Android Build + +on: + push: + tags: + - 'android-v*' + workflow_dispatch: + +jobs: + build-android: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Setup Rust + uses: dtolnay/rust-action@stable + with: + targets: aarch64-linux-android,armv7-linux-androideabi + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + + - name: Install dependencies + run: bun install + working-directory: packages/desktop + + - name: Build Android + run: bun tauri android build --apk + working-directory: packages/desktop + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: android-apk + path: packages/desktop/src-tauri/gen/android/app/build/outputs/apk/ +``` + +#### 7.2 iOS Build Workflow + +- [ ] Create `.github/workflows/mobile-ios.yml`: + +```yaml +name: iOS Build + +on: + push: + tags: + - 'ios-v*' + workflow_dispatch: + +jobs: + build-ios: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Setup Rust + uses: dtolnay/rust-action@stable + with: + targets: aarch64-apple-ios,aarch64-apple-ios-sim + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + + - name: Install dependencies + run: bun install + working-directory: packages/desktop + + - name: Build iOS + run: bun tauri ios build + working-directory: packages/desktop + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} +``` + +### Phase 8: Testing Strategy + +#### 8.1 Automated Tests + +- [ ] Add tests (or a minimal harness) for `defaultServerUrl` resolution and server persistence behavior. If no frontend test harness exists, document why and rely on manual validation. + +#### 8.2 Manual Testing Checklist + +- [ ] **App Launch** + - [ ] App launches without crash + - [ ] Server connection established + - [ ] Server selection dialog opens and persists choice + - [ ] Login/authentication works + +- [ ] **Session Management** + - [ ] Create new session + - [ ] View session list + - [ ] Switch between sessions + - [ ] Delete session + +- [ ] **Chat Interface** + - [ ] Send message + - [ ] View AI response + - [ ] Code blocks render correctly + - [ ] Markdown formatting works + +- [ ] **Mobile UI** + - [ ] Mobile sidebar works (drawer) + - [ ] Pull-to-refresh works + - [ ] Keyboard visibility handled + - [ ] Safe area insets correct + - [ ] Orientation changes handled + +- [ ] **Offline Behavior** + - [ ] Graceful error on no connection + - [ ] Reconnection when network restored + - [ ] No offline usage in MVP unless embedded server is implemented + +#### 8.3 Platform-Specific Testing + +**Android:** +- [ ] Back button behavior +- [ ] Recent apps thumbnail +- [ ] Local notifications (non-push) +- [ ] Deep linking + +**iOS:** +- [ ] Home indicator handling +- [ ] Dynamic Island compatibility +- [ ] Face ID/Touch ID (if applicable) +- [ ] Local notifications (non-push) + +### Phase 9: App Store Preparation + +#### 9.1 Android Play Store + +- [ ] Create signing keystore +- [ ] Configure `build.gradle.kts` for release signing +- [ ] Prepare Play Store listing: + - App name: "shuvcode" + - Short description + - Full description + - Screenshots (phone, tablet) + - Feature graphic + - Privacy policy URL + +#### 9.2 iOS App Store + +- [ ] Apple Developer account setup +- [ ] App Store Connect configuration +- [ ] Prepare App Store listing: + - App name + - Description + - Keywords + - Screenshots (all required sizes) + - Privacy policy URL + - App Privacy labels + +## External References + +### Tauri Mobile Documentation + +- https://v2.tauri.app/start/prerequisites/ - Setup requirements +- https://v2.tauri.app/develop/configuration-files/ - Config structure +- https://v2.tauri.app/reference/cli/ - CLI commands (`tauri android`, `tauri ios`) +- https://v2.tauri.app/security/capabilities/ - Mobile capabilities +- https://v2.tauri.app/security/permissions/ - Permission system +- https://v2.tauri.app/develop/plugins/develop-mobile/ - Mobile plugin development +- https://v2.tauri.app/distribute/sign/android/ - Android signing + +### Example Tauri Mobile Projects + +- https://github.com/tauri-apps/cargo-mobile2 - cargo-mobile2 tool +- https://github.com/jbilcke/latent-browser - Example mobile Tauri app +- https://github.com/readest/readest - Production Tauri mobile app +- https://github.com/EasyTier/EasyTier - Cross-platform including mobile + +### Tauri Plugins + +- https://github.com/tauri-apps/plugins-workspace/tree/v2/plugins - Official plugins +- Notable mobile-compatible plugins: + - `tauri-plugin-http` - Network requests + - `tauri-plugin-notification` - Local/system notifications (non-push) + - `tauri-plugin-store` - Key-value storage + - `tauri-plugin-os` - OS information + - `tauri-plugin-dialog` - File dialogs (limited on mobile) + +## Internal File References + +### Core Files to Modify + +| File | Purpose | Changes Required | +|------|---------|------------------| +| `packages/desktop/src-tauri/src/lib.rs` | Tauri app entry | Split desktop vs mobile boot; gate sidecar/plugins | +| `packages/desktop/src-tauri/Cargo.toml` | Rust deps | Target-specific deps for mobile vs desktop | +| `packages/desktop/src-tauri/tauri.conf.json` | App config | Shared identifiers/defaults | +| `packages/desktop/src-tauri/tauri.prod.conf.json` | Prod config | Desktop-only updater config; avoid mobile | +| `packages/desktop/src-tauri/capabilities/default.json` | Desktop permissions | Keep desktop capabilities separate | +| `packages/desktop/vite.config.ts` | Vite config | Optional entrypoint adjustments (validate plugin) | +| `packages/desktop/index.html` | HTML template | Preserve theme preload if duplicated for mobile | +| `packages/desktop/package.json` | NPM scripts | Mobile build commands | +| `packages/app/src/app.tsx` | App bootstrap | `defaultServerUrl` mobile hook + `__SHUVCODE__` typing | +| `packages/app/src/pages/session.tsx` | Platform layout | Confirm mobile vs desktop branching | +| `packages/app/src/context/server.tsx` | Server state | Reuse persisted server list on mobile | +| `packages/app/src/components/dialog-select-server.tsx` | Server UI | Reuse for mobile server selection | +| `packages/app/src/context/platform.tsx` | Platform types | Add "mobile" type | + +### Files to Create + +| File | Purpose | +|------|---------| +| `packages/desktop/src/mobile.tsx` | Mobile platform implementation | +| `packages/desktop/src/mobile-entry.tsx` | Mobile entry point | +| `packages/desktop/mobile.html` | Mobile HTML template (optional) | +| `packages/desktop/src-tauri/capabilities/mobile.json` | Mobile capabilities | +| `packages/desktop/src-tauri/src/mobile.rs` | Mobile Rust commands | +| `packages/desktop/src-tauri/tauri.android.conf.json` | Android config overrides | +| `packages/desktop/src-tauri/tauri.ios.conf.json` | iOS config overrides | +| `.github/workflows/mobile-android.yml` | Android CI | +| `.github/workflows/mobile-ios.yml` | iOS CI | + +### Existing PWA Mobile Components (Reuse) + +| File | What to Reuse | +|------|---------------| +| `packages/app/src/components/mobile-terminal-input.tsx` | Terminal keyboard bridge | +| `packages/app/src/components/pull-to-refresh.tsx` | Pull gesture handler | +| `packages/app/src/hooks/use-keyboard-visibility.tsx` | Keyboard detection | +| `packages/app/src/context/layout.tsx` | mobileSidebar state | +| `packages/app/src/pages/session.tsx` | Mobile tabs, mobileReview | +| `packages/app/src/index.css` | Safe area CSS variables | + +## Risk Assessment + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|------------|------------| +| Sidecar not possible on mobile | High | Certain | Remote server architecture | +| Remote server UX mismatch with local filesystem | Medium | Medium | Update UI copy/flows; hide local browse controls on mobile | +| Server URL resolution/persistence regressions | Medium | Medium | Integrate with `ServerProvider` + add tests for `defaultServerUrl` | +| Performance issues | Medium | Medium | Profile and optimize, reduce bundle | +| App Store rejection | High | Low | Follow guidelines, thorough testing | +| Terminal depends on remote PTY | Medium | Medium | Require healthy remote server; document no offline support | +| iOS signing complexity | Low | Medium | Document process, use CI | + +## Success Criteria + +1. **MVP (Phase 1-5):** + - [ ] App builds for Android and iOS + - [ ] Connects to remote shuvcode server and passes health checks + - [ ] Server selection persists via `ServerProvider` (`server.v4`) + - [ ] Basic chat functionality works + - [ ] Mobile UI renders correctly, with remote filesystem copy in project flows + - [ ] Offline mode shows clear error messaging (no offline support in MVP) + +2. **Beta (Phase 6-7):** + - [ ] CI builds working + - [ ] Internal testing complete + - [ ] Performance acceptable + +3. **Release (Phase 8-9):** + - [ ] All manual tests pass + - [ ] App Store listings prepared + - [ ] First public release + +## Timeline Estimate + +| Phase | Duration | Dependencies | +|-------|----------|--------------| +| Phase 1: Init | 1-2 days | None | +| Phase 2: Rust | 2-3 days | Phase 1 | +| Phase 3: Capabilities | 1 day | Phase 2 | +| Phase 4: Frontend | 2-3 days | Phase 2 | +| Phase 5: Server | 1-2 days | Phase 4 | +| Phase 6: Build | 2-3 days | Phase 5 | +| Phase 7: CI | 2-3 days | Phase 6 | +| Phase 8: Testing | 3-5 days | Phase 7 | +| Phase 9: Release | 2-5 days | Phase 8 | + +**Total Estimate:** 3-4 weeks for MVP, 5-6 weeks for full release + +## Future Enhancements + +After initial release, consider: + +1. **Embedded Server (Long-term)** + - Compile opencode core to static lib + - FFI bridge to Rust + - True offline capability + +2. **Git Integration** + - Clone repos to device + - Commit and push support + - SSH key management + +3. **Voice Input** + - Speech-to-text for prompts + - Hands-free operation + +4. **Widgets** + - iOS widgets for quick access + - Android app shortcuts + +5. **Watch Companion** + - Apple Watch notifications + - Wear OS integration diff --git a/plan.md b/plan.md new file mode 100644 index 00000000000..f17d00538dd --- /dev/null +++ b/plan.md @@ -0,0 +1,240 @@ +# Combined Implementation Backlog + +Consolidated from plans: #264-266 (PWA/Audio), #268 (AskQuestion), #269 (TUI Transparency), #270 (Bash Spinner), #271 (Web Input Padding) + +--- + +## Issue #271: Web Input Bar Bottom Padding (Low Complexity, ~15-30 min) + +- [ ] Edit `packages/app/src/pages/session.tsx:984` - remove `pb-4 md:pb-8` from prompt dock class string +- [ ] Update prompt dock inline style to use `max(1.5rem, env(safe-area-inset-bottom, 0px))` for padding-bottom +- [ ] Verify gradient background (`bg-gradient-to-t`) still displays correctly with increased padding +- [ ] Test on desktop browser (Chrome/Firefox/Safari) at various widths +- [ ] Test on mobile simulator or device (iOS Safari, Chrome Android) +- [ ] Verify input bar has minimum ~24px visible padding from bottom edge on desktop + +--- + +## Issue #268: AskQuestion Tool Dialog Fix (High Severity) + +### Phase 0: Required Fixes + +- [ ] Add callID validation at start of `packages/opencode/src/tool/askquestion.ts` execute function: throw error if `ctx.callID` is undefined +- [ ] Remove non-null assertions (`!`) at askquestion.ts lines 32 and 40, replace with direct `ctx.callID` usage + +### Phase 1: Investigation & Debugging + +- [ ] Add debug logging to `packages/opencode/src/tool/askquestion.ts:28` after `ctx.metadata()` call +- [ ] Add debug logging to Web detection memo `packages/app/src/pages/session.tsx:240-268` +- [ ] Add debug logging to TUI detection memo `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:391-418` +- [ ] Verify SSE delivers `PartUpdated` event with correct structure (browser DevTools) +- [ ] Verify `ctx.callID` is defined when askquestion tool executes (log `options.toolCallId` in prompt.ts:659) + +### Phase 2: Fix Sync/Reactivity Issues + +- [ ] Verify `ctx.metadata()` properly awaits sync propagation in `packages/opencode/src/session/prompt.ts:662-677` +- [ ] If needed: Add explicit sync wait after metadata update (50ms delay or Bus event) + +### Phase 3: Fix Detection Logic (If Needed) + +- [ ] Verify `toolPart.callID` is available (not undefined) in detection at `session.tsx:260` and `session/index.tsx:409` +- [ ] Verify `toolPart.state.metadata` type matches expected schema + +### Phase 4: Server Endpoint Tests + +- [ ] Create `packages/opencode/test/server/askquestion.test.ts` +- [ ] Add test: `POST /askquestion/respond` resolves pending request +- [ ] Add test: `POST /askquestion/cancel` rejects pending request +- [ ] Add test: `POST /askquestion/respond` returns 404/500 for unknown callID + +### Phase 4: Sync Propagation Tests + +- [ ] Create `packages/opencode/test/tool/askquestion-sync.test.ts` +- [ ] Add test: metadata update publishes `PartUpdated` event with correct structure + +### Phase 4: Detection Edge Case Tests + +- [ ] Extend `packages/opencode/test/tool/askquestion.test.ts` with detection edge cases +- [ ] Add test: detects pending when callID is present +- [ ] Add test: returns null when callID is undefined +- [ ] Add test: ignores when part.state.status is not 'running' +- [ ] Add test: ignores when metadata.status is 'completed' + +### Phase 4: Cleanup Tests + +- [ ] Add test: cleanup rejects all pending requests for session on abort + +### Phase 5: Manual Validation + +- [ ] Test in TUI mode: enable `experimental.askquestion_tool`, trigger LLM to use askquestion, verify dialog appears +- [ ] Test in Web mode: desktop and mobile, verify wizard appears +- [ ] Test edge cases: multiple questions, cancel mid-flow, session abort while pending, custom text response +- [ ] Remove debug logging before merge + +--- + +## Issue #266: Plugin Audio Asset Bundling + +### Create Shared Utility + +- [ ] Create `packages/opencode/src/util/asset-copy.ts` +- [ ] Export `ASSET_EXTENSIONS` constant with: `.html`, `.css`, `.json`, `.txt`, `.svg`, `.png`, `.jpg`, `.gif`, `.wav`, `.mp3`, `.ogg`, `.flac`, `.m4a`, `.aac`, `.mp4`, `.webm`, `.mov`, `.woff`, `.woff2`, `.ttf`, `.otf` +- [ ] Export `copyPluginAssets(pluginDir, targetDir)` function with directory structure preservation +- [ ] Export `resolvePluginRoot(entryFilePath)` to find nearest package.json or fallback to dirname +- [ ] Add symlink/path traversal security checks via `fs.promises.realpath()` +- [ ] Add logging for overwrites when copying to shared target directories +- [ ] Ensure `mkdir -p` is called before `Bun.write()` for nested paths + +### Update npm Plugin Bundling + +- [ ] In `packages/opencode/src/bun/index.ts`: import `{ copyPluginAssets, ASSET_EXTENSIONS }` from shared utility +- [ ] Remove inline `copyPluginAssets` function and `assetExtensions` array from bun/index.ts +- [ ] Preserve existing call sites: `copyPluginAssets(mod, bundledDir)` and `copyPluginAssets(mod, Global.Path.cache)` + +### Update Local Plugin Bundling + +- [ ] In `packages/opencode/src/plugin/index.ts`: import `{ copyPluginAssets, resolvePluginRoot }` from shared utility +- [ ] After successful `Bun.build()` in `bundleLocalPlugin()`, resolve plugin root: `const pluginRoot = await resolvePluginRoot(absolutePath)` +- [ ] Call `copyPluginAssets(pluginRoot, bundledDir)` for assets +- [ ] Call `copyPluginAssets(pluginRoot, Global.Path.cache)` for runtime resolution parity + +### Asset Copy Tests + +- [ ] Create `packages/opencode/test/asset-copy.test.ts` +- [ ] Add test: audio file copying (`.wav`, `.mp3`, `.ogg`) +- [ ] Add test: nested directory preservation (`sounds/alerts/beep.wav` -> `targetDir/sounds/alerts/beep.wav`) +- [ ] Add test: `resolvePluginRoot` with package.json present +- [ ] Add test: `resolvePluginRoot` fallback for single-file plugins (no package.json) +- [ ] Add test: symlink/path traversal entries are skipped +- [ ] Add test: overwrite logging when file already exists + +--- + +## Issue #264: PWA Safe Area and Viewport Locking + +### Home Menu Button Fix + +- [ ] Add `.home-menu-button` class to menu button in `packages/app/src/pages/home.tsx:35` + +### Session Header Fix + +- [ ] Add `data-tauri-drag-region` attribute to session header in `packages/app/src/components/session/session-header.tsx:52` + +### Session Scroll Container Fix + +- [ ] Add `.session-scroll-container` class to scroll container in `packages/app/src/pages/session.tsx:906` + +### PWA CSS Rules + +- [ ] Extend PWA CSS rules in `packages/app/src/index.css`: + - [ ] Add `.home-menu-button { top: var(--safe-area-inset-top); }` inside `@media (display-mode: standalone)` + - [ ] Add `.session-scroll-container { overscroll-behavior: contain; }` inside `@media (display-mode: standalone)` + - [ ] Keep existing `header[data-tauri-drag-region]` safe-area rule + +### PullToRefresh Guard + +- [ ] Update `packages/app/src/components/pull-to-refresh.tsx` to accept an `enabled` prop +- [ ] Add early return guards to `handleTouchStart`, `handleTouchMove`, `handleTouchEnd` when `enabled()` is false +- [ ] In `packages/app/src/pages/layout.tsx`: import `isPWA` from `@/context/platform` +- [ ] Pass `enabled={!isPWA()}` to `PullToRefresh` component in layout.tsx + +### PWA Validation + +- [ ] Verify `#root`/body sizing still uses `h-dvh` and appropriate `min-height` values for iOS PWA +- [ ] Manually verify iOS PWA on Dynamic Island device (iPhone 14+) +- [ ] Manually verify iOS PWA on notch device (iPhone X-13) +- [ ] Manually verify Android PWA pull-down behavior in session view +- [ ] Verify no desktop/browser regressions for menu placement and scrolling +- [ ] Verify `@mohak34/opencode-notifier` sounds play correctly after asset bundling + +--- + +## Issue #269: TUI Transparency Toggle Fix + +### Milestone 1: Reproduce and Inspect State + +- [ ] Reproduce in TUI and log `transparent()` before and after toggling +- [ ] Verify `kv.get("theme_transparent", false)` changes and persists across restart +- [ ] Inspect `resolveTheme` output for `background.a` with multiple themes (Night Owl, Nord, lucent-orng) +- [ ] Confirm that the theme memo re-runs on `setTransparent` +- [ ] Verify that `lucent-orng` theme resolves to alpha=0 even when toggle is off (confirms root cause) + +### Milestone 2: Fix Theme Resolution + +- [ ] Create `normalizeBackgrounds(resolved, transparent)` helper function in `packages/opencode/src/cli/cmd/tui/context/theme.tsx` +- [ ] Implement fallback chain: `backgroundMenu` -> `backgroundElement` -> `backgroundPanel` -> derive from `primary` +- [ ] Handle semi-transparent colors (require alpha=1 for fallback eligibility) +- [ ] For last resort fallback: derive from primary at 10% luminance for dark mode, 95% for light mode +- [ ] Call `normalizeBackgrounds()` at the end of `resolveTheme()` before returning + +### Milestone 3: Contrast and Theme UX + +- [ ] Re-validate `selectedForeground` behavior with opaque backgrounds +- [ ] Verify selected list item contrast remains readable for Night Owl, Nord, opencode, and lucent-orng themes +- [ ] Test with light mode themes to ensure derived fallback works for both dark and light modes + +### Milestone 4: Transparency Tests + +- [ ] Add unit tests in `packages/opencode/test/theme.test.ts` +- [ ] Add test: `normalizeBackgrounds` with fully transparent theme +- [ ] Add test: `normalizeBackgrounds` with semi-transparent `backgroundMenu` +- [ ] Add test: `normalizeBackgrounds` fallback derivation from `primary` +- [ ] Add test: full `resolveTheme` with `transparent=false` and lucent-orng fixture +- [ ] Add test: `selectedForeground` verifies readable contrast when transparency is off + +### Milestone 5: Manual Validation + +- [ ] Toggle transparency on/off in TUI and verify immediate updates +- [ ] Switch themes (Night Owl, Nord, lucent-orng) and verify backgrounds are opaque when toggle is off +- [ ] Restart TUI and confirm the last toggle state is restored +- [ ] Test lucent-orng specifically in both dark and light modes with transparency off + +--- + +## Issue #270: TUI Bash Spinner Stops on Completion + +### Milestone 1: Reproduce and Trace State Transitions + +- [ ] Reproduce in TUI and confirm the Bash part status after command completion (server-side) +- [ ] Inspect `Bash` component `isRunning` at `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:2088-2152` for reactivity +- [ ] If stale: convert `isRunning` to `createMemo` or inline reactive check +- [ ] Add logging to `packages/opencode/src/session/processor.ts:171-191` (tool-result case) to verify it fires for Bash tool +- [ ] Add logging to `Session.updatePart` to confirm it's called with `status: "completed"` +- [ ] Log when `ctx.metadata` attempts to write after completion + +### Milestone 2: Trace Event Delivery and Store Updates + +- [ ] Add logging to TUI sync handler when `message.part.updated` is received +- [ ] Add logging when `sync.data.part[messageID]` is updated in the store +- [ ] Identify the TUI component that renders the Bash spinner and trace its props/derivations +- [ ] Confirm the component re-renders on part status change (post `isRunning` fix) + +### Milestone 3: Fix Based on Findings + +- [ ] Fix Bash spinner reactivity (`isRunning` as memo or inline check) +- [ ] If metadata regression confirmed: Prevent `completed`/`error` -> `running` writes (guard in `Session.updatePart` or `ctx.metadata`) +- [ ] If event not delivering: Fix event stream subscription or reconnection logic +- [ ] If store not updating: Fix Solid.js store update (ensure `produce` or proper setter is used) +- [ ] If part lookup wrong: Fix the part ID/callID matching between processor and TUI + +### Milestone 4: Spinner Tests + +- [ ] Add a session-level test to verify tool-result -> part status `completed` transition +- [ ] If regression guard added: Add a test that prevents `completed`/`error` -> `running` status downgrade + +### Milestone 5: Manual Validation + +- [ ] Run a Bash command via the TUI and confirm the spinner stops +- [ ] Verify at least one other tool (Write or Task) still updates correctly +- [ ] Test with both short (<1s) and long (>5s) running commands +- [ ] Test spinner behavior when command errors (non-zero exit) + +--- + +## Final Validation + +- [ ] Run `bun test` in `packages/opencode` +- [ ] Run `bun turbo test` at repo root for full test suite +- [ ] TypeScript compilation succeeds for both `opencode` and `app` packages +- [ ] All acceptance criteria verified +- [ ] Debug logging removed from all files