From 42a2c2a822f0fbddf136f2997585c7a249fc097c Mon Sep 17 00:00:00 2001 From: Gennadi Ryan Date: Sat, 8 Aug 2026 02:37:25 +0000 Subject: [PATCH 1/7] Register attached and pasted images as blobs --- packages/opencode/src/server/shared/ui.ts | 2 +- packages/opencode/test/server/httpapi-ui.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts index c2fd3b8637..91c141dfa5 100644 --- a/packages/opencode/src/server/shared/ui.ts +++ b/packages/opencode/src/server/shared/ui.ts @@ -9,7 +9,7 @@ let embeddedUIPromise: Promise | null> | undefined export const UI_UPSTREAM = new URL("https://app.opencode.ai") export const csp = (hash = "") => - `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src * data:` + `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data: blob:; connect-src * data: blob:` export const DEFAULT_CSP = csp() export function themePreloadHash(body: string) { diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 7a6bfa2450..939dc6787e 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -351,7 +351,9 @@ describe("HttpApi UI fallback", () => { const csp = response.headers.get("content-security-policy") ?? "" expect(csp).toContain("script-src 'self' 'wasm-unsafe-eval'") expect(csp).toContain(`'sha256-${createHash("sha256").update(script).digest("base64")}'`) - expect(csp).toContain("connect-src * data:") + expect(csp).toContain("connect-src * data: blob:") + expect(csp).toContain("img-src 'self' data: https: blob:") + expect(csp).toContain("media-src 'self' data: blob:") }), ) From 583c95e47b8ad3e10fda30549482614766c87ec2 Mon Sep 17 00:00:00 2001 From: Gennadi Ryan Date: Sun, 9 Aug 2026 01:25:01 +0000 Subject: [PATCH 2/7] Fix zoom keybindings first --- packages/app/src/context/zoom-keybind.test.ts | 110 ++++++++++++++++++ packages/app/src/pages/layout.tsx | 67 ++++------- packages/app/src/utils/web-zoom.test.ts | 70 +++++++++++ packages/app/src/utils/web-zoom.ts | 46 ++++++-- 4 files changed, 236 insertions(+), 57 deletions(-) create mode 100644 packages/app/src/context/zoom-keybind.test.ts create mode 100644 packages/app/src/utils/web-zoom.test.ts diff --git a/packages/app/src/context/zoom-keybind.test.ts b/packages/app/src/context/zoom-keybind.test.ts new file mode 100644 index 0000000000..00c59ed2cc --- /dev/null +++ b/packages/app/src/context/zoom-keybind.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" +import { matchKeybind, parseKeybind } from "./command" + +// harmoniqs/amicode#266 — Cmd/Ctrl +/- zoom did nothing in the webview. +// +// The zoom commands, their CSS-zoom implementation, and the platform signal all +// existed and were correct. Only the keybind strings were wrong: dispatch is an +// exact (normalized-key, modifier-mask) lookup with no fallback, and only +// "mod+=" / "mod+-" were registered. +// +// What a user actually presses: +// Ctrl+Plus on a US layout IS Ctrl+Shift+"=", which arrives as key "+" +// (normalized "plus") WITH the shift bit — wrong key AND wrong mask. +// The numpad's "+" arrives unshifted. On DE/FR/Nordic layouts "=" is itself +// a shifted key, so even the canonical chord carries shift. +// +// So the bare chord worked only on a US layout with the main-row "=", which is +// why this survived: it works for whoever tries it that one way. There was no +// test of any kind over zoom before this file. +// +// These assert the CHORD SET, deliberately decoupled from where it is +// registered — layout.tsx gates the commands on platform.platform === "web" +// and cannot be imported here without dragging the whole page graph in. +const ZOOM_IN = "mod+=,mod+shift+=,mod+plus,mod+shift+plus" +const ZOOM_OUT = "mod+-,mod+shift+_" +const ZOOM_RESET = "mod+0" + +/** `mod` resolves to meta on mac and ctrl elsewhere; drive whichever the parse + * produced so these pass on both. */ +function chord(config: string, key: string, opts: { shift?: boolean } = {}) { + const first = parseKeybind(config)[0]! + return new KeyboardEvent("keydown", { + key, + ctrlKey: first.ctrl, + metaKey: first.meta, + shiftKey: opts.shift ?? false, + }) +} + +describe("zoom keybinds cover every chord that means zoom (amicode#266)", () => { + test("zoom in: main-row =, US Ctrl+Plus, numpad +, and shifted-= layouts", () => { + const kb = parseKeybind(ZOOM_IN) + + // Ctrl/Cmd + "=" — the canonical chord, US main row. + expect(matchKeybind(kb, chord(ZOOM_IN, "="))).toBe(true) + // Ctrl/Cmd + Plus on US: shift+"=" surfaces as "+" with the shift bit. + expect(matchKeybind(kb, chord(ZOOM_IN, "+", { shift: true }))).toBe(true) + // Numpad plus: "+" with no shift. + expect(matchKeybind(kb, chord(ZOOM_IN, "+"))).toBe(true) + // Layouts where "=" itself requires shift (DE/FR/Nordic). + expect(matchKeybind(kb, chord(ZOOM_IN, "=", { shift: true }))).toBe(true) + }) + + test("zoom out: main-row - and the shifted underscore", () => { + const kb = parseKeybind(ZOOM_OUT) + + expect(matchKeybind(kb, chord(ZOOM_OUT, "-"))).toBe(true) + expect(matchKeybind(kb, chord(ZOOM_OUT, "_", { shift: true }))).toBe(true) + }) + + test("zoom reset stays a single unambiguous chord", () => { + // "0" is unshifted on every layout we ship to — no widening needed, and + // widening it would start swallowing chords that mean something else. + const kb = parseKeybind(ZOOM_RESET) + expect(matchKeybind(kb, chord(ZOOM_RESET, "0"))).toBe(true) + expect(kb).toHaveLength(1) + }) + + test("the bare chord alone misses Ctrl+Plus — the regression this locks", () => { + // Guards the fix itself: if someone narrows the config back to "mod+=", + // this is the assertion that explains why they should not. + const narrow = parseKeybind("mod+=") + expect(matchKeybind(narrow, chord("mod+=", "+", { shift: true }))).toBe(false) + expect(matchKeybind(narrow, chord("mod+=", "+"))).toBe(false) + expect(matchKeybind(narrow, chord("mod+=", "=", { shift: true }))).toBe(false) + }) + + // The seam. Everything above proves the chord SETS behave; this proves the + // app actually registers them. #266 shipped because the mechanism was right + // and its one integration was not — asserting the set without asserting the + // registration would reproduce that exact failure mode in the fix's own test. + test("layout.tsx registers these exact chord sets", () => { + const layout = readFileSync(join(import.meta.dir, "..", "pages", "layout.tsx"), "utf8") + expect(layout).toContain(`keybind: "${ZOOM_IN}"`) + expect(layout).toContain(`keybind: "${ZOOM_OUT}"`) + expect(layout).toContain(`keybind: "${ZOOM_RESET}"`) + }) + + test("zoom chords do not collide with each other", () => { + const zin = parseKeybind(ZOOM_IN) + const zout = parseKeybind(ZOOM_OUT) + + for (const [key, shift] of [ + ["=", false], + ["+", true], + ["+", false], + ["=", true], + ] as const) { + expect(matchKeybind(zout, chord(ZOOM_OUT, key, { shift }))).toBe(false) + } + for (const [key, shift] of [ + ["-", false], + ["_", true], + ] as const) { + expect(matchKeybind(zin, chord(ZOOM_IN, key, { shift }))).toBe(false) + } + }) +}) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 3df70e0901..2a98af539c 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -225,50 +225,11 @@ export default function LegacyLayout(props: ParentProps) { makeEventListener(window, "blur", stop) makeEventListener(window, "blur", blur) makeEventListener(document, "visibilitychange", hide) - - // Zoom keyboard shortcuts - always active - const handleZoomKey = (e: KeyboardEvent) => { - // Require Cmd (Mac) or Ctrl (Windows/Linux) - if (!e.ctrlKey && !e.metaKey) return - - // Don't trigger if user is typing in an input - const target = e.target as HTMLElement - if (target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable) { - return - } - - // Use code for physical key detection (works with Shift) - // Equal key produces + when Shift is held - const code = e.code - const key = e.key - - // Debug logging - console.log("[Zoom] Keydown:", { code, key, shift: e.shiftKey, meta: e.metaKey, ctrl: e.ctrlKey }) - - // Plus/Equal: Zoom in (works with Cmd+Shift+Plus or Cmd+Equal) - if (code === "Equal" || code === "NumpadAdd" || key === "+" || (key === "=" && e.shiftKey)) { - e.preventDefault() - e.stopImmediatePropagation() - console.log("[Zoom] Triggering zoom in") - webZoomIn() - } else if (code === "Minus" || code === "NumpadSubtract" || key === "-" || key === "_") { - e.preventDefault() - e.stopImmediatePropagation() - console.log("[Zoom] Triggering zoom out") - webZoomOut() - } else if (code === "Digit0" || code === "Numpad0" || key === "0") { - e.preventDefault() - e.stopImmediatePropagation() - console.log("[Zoom] Triggering zoom reset") - webZoomReset() - } - } - - // Use window with capture phase for maximum reliability - window.addEventListener("keydown", handleZoomKey, true) - onCleanup(() => window.removeEventListener("keydown", handleZoomKey, true)) + + // Zoom keyboard handling lives in the command registry below (amicode#266). + // A window-capture keydown handler here would swallow the chords BEFORE the + // command dispatch, and inside the webview the host intercepts them anyway — + // both routes end at web-zoom.ts, which routes to the workbench when framed. }) const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined) @@ -959,21 +920,33 @@ export default function LegacyLayout(props: ParentProps) { }, // web host only: the desktop build zooms through Electron (menu roles // were retired for the action path), and registering the same chords - // here would double-fire against its renderer keydown handler + // here would double-fire against its renderer keydown handler. Inside + // the amicode webview the commands route through web-zoom.ts to the + // workbench (the host owns zoom there — its chords never reach this + // document); on a plain browser they own the in-app CSS zoom. + // Keybind matching is an exact (key, modifier-mask) lookup, so every + // chord that physically means "zoom in" must be registered + // (harmoniqs/amicode#266). On a US layout Ctrl/Cmd + Plus IS + // shift+"=", which arrives as key "+" (normalized "plus") WITH the + // shift bit — matching neither the key nor the mask of a bare "mod+=". + // The numpad's "+" arrives unshifted, and on layouts where "=" itself + // is shifted (DE/FR/Nordic) even the canonical chord carries shift. + // Only the first is shown in tooltips (displayKeybind takes + // parseKeybind(config)[0]). ...(platform.platform === "web" ? [ { id: "view.zoomIn", title: language.t("amicode.zoomIn"), category: language.t("command.category.view"), - keybind: "mod+=", + keybind: "mod+=,mod+shift+=,mod+plus,mod+shift+plus", onSelect: () => webZoomIn(), }, { id: "view.zoomOut", title: language.t("amicode.zoomOut"), category: language.t("command.category.view"), - keybind: "mod+-", + keybind: "mod+-,mod+shift+_", onSelect: () => webZoomOut(), }, { diff --git a/packages/app/src/utils/web-zoom.test.ts b/packages/app/src/utils/web-zoom.test.ts new file mode 100644 index 0000000000..ea068bf4ea --- /dev/null +++ b/packages/app/src/utils/web-zoom.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test" +import { ZOOM_BRIDGE_KIND, inWebview, webZoom, webZoomIn, webZoomOut, webZoomReset } from "./web-zoom" + +// Zoom routing (amicode#266): the WORKBENCH owns zoom inside the webview — the +// host intercepts the chords before this document sees them, so in-app CSS +// zoom is unreachable there. The app posts a bridge envelope instead. On a +// plain browser (unframed) the app owns zoom and the CSS path applies. +// +// happydom's window.parent defaults to the window itself → unframed by +// default; the framed case shadows the property with a posting stub. + +function framedWindow(): { restore(): void } { + const fakeParent = { postMessage: (_msg: unknown) => {} } + Object.defineProperty(window, "parent", { value: fakeParent, configurable: true }) + return { + restore() { + Object.defineProperty(window, "parent", { value: window, configurable: true }) + }, + } +} + +describe("web-zoom routing (amicode#266)", () => { + test("unframed: zoom applies in-app — the signal moves, nothing is posted", () => { + expect(inWebview()).toBe(false) + const before = webZoom() + webZoomIn() + expect(webZoom()).toBe(Math.round((before + 0.1) * 100) / 100) + webZoomOut() + webZoomReset() + expect(webZoom()).toBe(1) + }) + + test("framed: zoom posts the bridge envelope and leaves the signal at 1", () => { + const posted: unknown[] = [] + Object.defineProperty(window, "parent", { + value: { postMessage: (msg: unknown) => posted.push(msg) }, + configurable: true, + }) + try { + expect(inWebview()).toBe(true) + webZoomIn() + webZoomOut() + webZoomReset() + expect(posted).toEqual([ + { source: "amicode", kind: ZOOM_BRIDGE_KIND, action: "in" }, + { source: "amicode", kind: ZOOM_BRIDGE_KIND, action: "out" }, + { source: "amicode", kind: ZOOM_BRIDGE_KIND, action: "reset" }, + ]) + expect(webZoom()).toBe(1) + } finally { + framedWindow().restore() + } + }) + + test("the zoom envelopes carry exactly the three workbench actions", () => { + const actions: unknown[] = [] + Object.defineProperty(window, "parent", { + value: { postMessage: (msg: unknown) => actions.push(msg) }, + configurable: true, + }) + try { + webZoomIn() + webZoomOut() + webZoomReset() + expect(actions.map((m) => (m as { action: unknown }).action)).toEqual(["in", "out", "reset"]) + } finally { + framedWindow().restore() + } + }) +}) diff --git a/packages/app/src/utils/web-zoom.ts b/packages/app/src/utils/web-zoom.ts index ed2f28e099..c481ce2403 100644 --- a/packages/app/src/utils/web-zoom.ts +++ b/packages/app/src/utils/web-zoom.ts @@ -1,10 +1,20 @@ -// amicode: in-app zoom for the WEB host (the amicode VS Code webview and the -// plain browser at :3002). The desktop build zooms through Electron -// (webview-zoom.ts); the web build had NO zoom at all — Cmd+=/-/0 fell -// through to the host, which zooms the whole editor window (or the browser -// tab), never the app. This module owns a CSS zoom on the document root, -// persisted per-window, and feeds the same platform.webviewZoom signal the -// titlebar and terminal already watch. +// amicode: in-app zoom for the WEB host (the plain browser at :3002 and the +// amicode VS Code webview). The desktop build zooms through Electron (menu +// roles); the web build had NO zoom at all — Cmd+=/-/0 fell through to the +// host, which zooms the whole editor window (or the browser tab), never the +// app. This module owns a CSS zoom on the document root, persisted +// per-window, and feeds the same platform.webviewZoom signal the titlebar +// and terminal already watch. +// +// Two hosts, two zoom owners (amicode#266): +// - Framed (the amicode VS Code webview): the WORKBENCH owns zoom. The host +// intercepts the zoom chords before the webview document ever sees the +// keydown, so in-app CSS zoom cannot fire there — the app posts a zoom +// intent over the extension bridge instead and the extension executes the +// matching workbench.action.zoomIn/Out/Reset. The app's own zoom signal +// stays at 1: the host's zoom level is not observable from inside the +// webview. +// - Unframed (plain browser): CSS zoom on the document root, as before. import { createSignal } from "solid-js" const KEY = "amicode-zoom" @@ -22,6 +32,19 @@ function readSaved(): number { const [webZoom, setSignal] = createSignal(readSaved()) +/** Framed = inside the amicode VS Code webview, extension host present. */ +export const inWebview = () => typeof window !== "undefined" && window.parent !== window + +/** The bridge envelope the extension answers with a workbench zoom action. */ +export const ZOOM_BRIDGE_KIND = "zoom" +export type ZoomAction = "in" | "out" | "reset" + +function postZoom(action: ZoomAction) { + try { + window.parent?.postMessage({ source: "amicode", kind: ZOOM_BRIDGE_KIND, action }, "*") + } catch {} +} + function apply(zoom: number) { if (typeof document === "undefined") return const html = document.documentElement @@ -57,7 +80,10 @@ export function setWebZoom(next: number) { } } -export const webZoomIn = () => setWebZoom(webZoom() + STEP) -export const webZoomOut = () => setWebZoom(webZoom() - STEP) -export const webZoomReset = () => setWebZoom(1) +// Zoom routes to the workbench when framed (the host owns zoom there — its +// chords never reach this document) and to the in-app CSS zoom otherwise +// (plain-browser host, where this module is the only zoom owner). +export const webZoomIn = () => (inWebview() ? postZoom("in") : setWebZoom(webZoom() + STEP)) +export const webZoomOut = () => (inWebview() ? postZoom("out") : setWebZoom(webZoom() - STEP)) +export const webZoomReset = () => (inWebview() ? postZoom("reset") : setWebZoom(1)) export { webZoom } From 8c59fd0c0fdf72e03e51e1d4e34e31e0f6261592 Mon Sep 17 00:00:00 2001 From: Gennadi Ryan Date: Sun, 9 Aug 2026 01:38:12 +0000 Subject: [PATCH 3/7] Bun lockfile version update --- bun.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 8e548e7cc7..ba6ed24027 100644 --- a/bun.lock +++ b/bun.lock @@ -797,7 +797,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.12", + "version": "1.18.10", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", From 3e6a3b656d6822535d27e308f95cc84e0041b49b Mon Sep 17 00:00:00 2001 From: Gennadi Ryan Date: Sun, 9 Aug 2026 01:39:05 +0000 Subject: [PATCH 4/7] Revert "Bun lockfile version update" This reverts commit 8c59fd0c0fdf72e03e51e1d4e34e31e0f6261592. --- bun.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index ba6ed24027..8e548e7cc7 100644 --- a/bun.lock +++ b/bun.lock @@ -797,7 +797,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.10", + "version": "1.18.12", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", From 0ad03fb18b50bd05eec655770c062cac78ec07f3 Mon Sep 17 00:00:00 2001 From: Gennadi Ryan Date: Sun, 9 Aug 2026 02:01:18 +0000 Subject: [PATCH 5/7] Finishing zoom keybind wiring --- packages/app/src/utils/web-zoom.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/app/src/utils/web-zoom.ts b/packages/app/src/utils/web-zoom.ts index c481ce2403..839b078e72 100644 --- a/packages/app/src/utils/web-zoom.ts +++ b/packages/app/src/utils/web-zoom.ts @@ -40,11 +40,41 @@ export const ZOOM_BRIDGE_KIND = "zoom" export type ZoomAction = "in" | "out" | "reset" function postZoom(action: ZoomAction) { + // TEMP-DIAG (amicode#266 remote test): hop log — the chord reached a + // document and is about to leave it. Remove after the diagnosis. + console.log("[zoom] post:", action) try { window.parent?.postMessage({ source: "amicode", kind: ZOOM_BRIDGE_KIND, action }, "*") } catch {} } +// Pane relay (amicode#266): a split-frame pane is a FULL second app instance +// (same bundle, same origin). Its zoom envelopes post to ITS window.parent — +// this window — because that is the farthest a framed child can reach. No +// other listener forwards amicode envelopes from a child up, so relay zoom +// intents onward to the webview host page. Guard: only messages arriving from +// a child window (event.source is not the host page) — our own posts go +// straight to window.parent and extension-originated envelopes arrive from +// window.parent, so neither can loop here. +export function installZoomPaneRelay(): () => void { + if (!inWebview()) return () => {} + const onMsg = (e: MessageEvent) => { + const d = e.data as { source?: unknown; kind?: unknown; action?: unknown } | undefined + if (!d || d.source !== "amicode" || d.kind !== ZOOM_BRIDGE_KIND) return + if (!e.source || e.source === window.parent) return + const action = d.action + if (action !== "in" && action !== "out" && action !== "reset") return + // TEMP-DIAG (amicode#266 remote test): a pane's zoom intent arrived here. + // Remove after the diagnosis. + console.log("[zoom] relayed from pane:", action) + postZoom(action) + } + window.addEventListener("message", onMsg) + return () => window.removeEventListener("message", onMsg) +} + +installZoomPaneRelay() + function apply(zoom: number) { if (typeof document === "undefined") return const html = document.documentElement From d2fb6965cc27c0ccf93bb931112eb7d845361df0 Mon Sep 17 00:00:00 2001 From: Gennadi Ryan Date: Sun, 9 Aug 2026 02:41:59 +0000 Subject: [PATCH 6/7] Logging fixes --- packages/app/src/utils/web-zoom.ts | 50 +++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/app/src/utils/web-zoom.ts b/packages/app/src/utils/web-zoom.ts index 839b078e72..b1ef278f10 100644 --- a/packages/app/src/utils/web-zoom.ts +++ b/packages/app/src/utils/web-zoom.ts @@ -59,15 +59,26 @@ function postZoom(action: ZoomAction) { export function installZoomPaneRelay(): () => void { if (!inWebview()) return () => {} const onMsg = (e: MessageEvent) => { - const d = e.data as { source?: unknown; kind?: unknown; action?: unknown } | undefined - if (!d || d.source !== "amicode" || d.kind !== ZOOM_BRIDGE_KIND) return + const d = e.data as { source?: unknown; kind?: unknown; action?: unknown; level?: unknown; message?: unknown } | undefined + if (!d || d.source !== "amicode") return if (!e.source || e.source === window.parent) return - const action = d.action - if (action !== "in" && action !== "out" && action !== "reset") return - // TEMP-DIAG (amicode#266 remote test): a pane's zoom intent arrived here. - // Remove after the diagnosis. - console.log("[zoom] relayed from pane:", action) - postZoom(action) + if (d.kind === ZOOM_BRIDGE_KIND) { + const action = d.action + if (action !== "in" && action !== "out" && action !== "reset") return + // TEMP-DIAG (amicode#266 remote test): a pane's zoom intent arrived here. + // Remove after the diagnosis. + console.log("[zoom] relayed from pane:", action) + postZoom(action) + return + } + // TEMP-DIAG (amicode#266 remote test): a pane's relayed console line + // arrived — forward it up to the host page like the zoom intents. + if (d.kind === "diag-log") { + window.parent?.postMessage( + { source: "amicode", kind: "diag-log", level: d.level, message: d.message }, + "*", + ) + } } window.addEventListener("message", onMsg) return () => window.removeEventListener("message", onMsg) @@ -75,6 +86,29 @@ export function installZoomPaneRelay(): () => void { installZoomPaneRelay() +// TEMP-DIAG (amicode#266 remote test): forward our [zoom]-prefixed console +// lines to the extension host so a remote session can hand back a log file +// (Output panel → "Amicode — webview diag" → "Open Log File") instead of +// webview devtools. Remove after the diagnosis. +function installDiagRelay(): void { + if (!inWebview()) return + const fwd = (level: "log" | "warn" | "error") => { + const orig = console[level].bind(console) + return (...args: unknown[]) => { + orig(...args) + const text = args.find((a): a is string => typeof a === "string") + if (text?.startsWith("[zoom]")) { + window.parent?.postMessage({ source: "amicode", kind: "diag-log", level, message: text }, "*") + } + } + } + console.log = fwd("log") + console.warn = fwd("warn") + console.error = fwd("error") +} + +installDiagRelay() + function apply(zoom: number) { if (typeof document === "undefined") return const html = document.documentElement From c1b8faadfcbe5982280fad583fc621fca06ff866 Mon Sep 17 00:00:00 2001 From: Gennadi Ryan Date: Sun, 9 Aug 2026 18:05:28 +0000 Subject: [PATCH 7/7] Version 1 fix --- packages/app/src/context/zoom-keybind.test.ts | 161 +++++++++--------- packages/app/src/pages/layout.tsx | 56 ++---- packages/app/src/utils/web-zoom.test.ts | 74 ++------ packages/app/src/utils/web-zoom.ts | 135 +++++---------- 4 files changed, 154 insertions(+), 272 deletions(-) diff --git a/packages/app/src/context/zoom-keybind.test.ts b/packages/app/src/context/zoom-keybind.test.ts index 00c59ed2cc..ad62965916 100644 --- a/packages/app/src/context/zoom-keybind.test.ts +++ b/packages/app/src/context/zoom-keybind.test.ts @@ -1,110 +1,115 @@ import { describe, expect, test } from "bun:test" -import { readFileSync } from "node:fs" -import { join } from "node:path" -import { matchKeybind, parseKeybind } from "./command" +import { webZoom, setWebZoom } from "../utils/web-zoom" // harmoniqs/amicode#266 — Cmd/Ctrl +/- zoom did nothing in the webview. // -// The zoom commands, their CSS-zoom implementation, and the platform signal all -// existed and were correct. Only the keybind strings were wrong: dispatch is an -// exact (normalized-key, modifier-mask) lookup with no fallback, and only -// "mod+=" / "mod+-" were registered. -// -// What a user actually presses: +// The zoom commands, their CSS-zoom implementation, and the platform signal +// all existed and were correct. Only the wiring was wrong: the chords were +// registered with the command registry's exact (normalized-key, modifier- +// mask) lookup with no fallback, and only "mod+=" / "mod+-" were +// registered — which misses what a user actually presses: // Ctrl+Plus on a US layout IS Ctrl+Shift+"=", which arrives as key "+" // (normalized "plus") WITH the shift bit — wrong key AND wrong mask. -// The numpad's "+" arrives unshifted. On DE/FR/Nordic layouts "=" is itself -// a shifted key, so even the canonical chord carries shift. +// The numpad's "+" arrives unshifted. On DE/FR/Nordic layouts "=" is +// itself a shifted key, so even the canonical chord carries shift. // -// So the bare chord worked only on a US layout with the main-row "=", which is -// why this survived: it works for whoever tries it that one way. There was no -// test of any kind over zoom before this file. +// So the bare chord worked only on a US layout with the main-row "=", which +// is why this survived: it works for whoever tries it that one way. // -// These assert the CHORD SET, deliberately decoupled from where it is -// registered — layout.tsx gates the commands on platform.platform === "web" -// and cannot be imported here without dragging the whole page graph in. -const ZOOM_IN = "mod+=,mod+shift+=,mod+plus,mod+shift+plus" -const ZOOM_OUT = "mod+-,mod+shift+_" -const ZOOM_RESET = "mod+0" +// V1 (this file): the registry no longer owns zoom. web-zoom.ts (imported +// below — module scope registers the capture listener on document) grabs the +// raw chords by the key the layout PRODUCES — the same physical key yields +// one of "="/"+"/"-"/"_" on every layout — so the coverage matrix below +// asserts every variant that can arrive, plus the negatives that must never +// fire. -/** `mod` resolves to meta on mac and ctrl elsewhere; drive whichever the parse - * produced so these pass on both. */ -function chord(config: string, key: string, opts: { shift?: boolean } = {}) { - const first = parseKeybind(config)[0]! +function chord(key: string, opts: { ctrl?: boolean; meta?: boolean; shift?: boolean } = {}) { + // cancelable: real keydown events are cancelable; without it preventDefault + // (and defaultPrevented) are no-ops per spec. return new KeyboardEvent("keydown", { key, - ctrlKey: first.ctrl, - metaKey: first.meta, + ctrlKey: opts.ctrl ?? false, + metaKey: opts.meta ?? false, shiftKey: opts.shift ?? false, + cancelable: true, }) } -describe("zoom keybinds cover every chord that means zoom (amicode#266)", () => { - test("zoom in: main-row =, US Ctrl+Plus, numpad +, and shifted-= layouts", () => { - const kb = parseKeybind(ZOOM_IN) +function press(ev: KeyboardEvent) { + document.dispatchEvent(ev) + return webZoom() +} - // Ctrl/Cmd + "=" — the canonical chord, US main row. - expect(matchKeybind(kb, chord(ZOOM_IN, "="))).toBe(true) - // Ctrl/Cmd + Plus on US: shift+"=" surfaces as "+" with the shift bit. - expect(matchKeybind(kb, chord(ZOOM_IN, "+", { shift: true }))).toBe(true) - // Numpad plus: "+" with no shift. - expect(matchKeybind(kb, chord(ZOOM_IN, "+"))).toBe(true) - // Layouts where "=" itself requires shift (DE/FR/Nordic). - expect(matchKeybind(kb, chord(ZOOM_IN, "=", { shift: true }))).toBe(true) +describe("zoom raw chord capture (amicode#266)", () => { + test("zoom in: main-row =, US Ctrl+Plus, numpad +, and shifted-= layouts", () => { + for (const [key, shift] of [ + ["=", false], + ["+", true], // US Ctrl+Plus: shift+"=" surfaces as "+" with the shift bit + ["+", false], // numpad plus + ["=", true], // layouts where "=" itself requires shift (DE/FR/Nordic) + ] as const) { + setWebZoom(1) + const zoom = press(chord(key, { ctrl: true, shift })) + expect(zoom).toBe(1.1) + } }) test("zoom out: main-row - and the shifted underscore", () => { - const kb = parseKeybind(ZOOM_OUT) - - expect(matchKeybind(kb, chord(ZOOM_OUT, "-"))).toBe(true) - expect(matchKeybind(kb, chord(ZOOM_OUT, "_", { shift: true }))).toBe(true) + for (const [key, shift] of [ + ["-", false], + ["_", true], + ] as const) { + setWebZoom(1) + const zoom = press(chord(key, { ctrl: true, shift })) + expect(zoom).toBe(0.9) + } }) - test("zoom reset stays a single unambiguous chord", () => { - // "0" is unshifted on every layout we ship to — no widening needed, and - // widening it would start swallowing chords that mean something else. - const kb = parseKeybind(ZOOM_RESET) - expect(matchKeybind(kb, chord(ZOOM_RESET, "0"))).toBe(true) - expect(kb).toHaveLength(1) + test("zoom reset: a single unambiguous chord", () => { + setWebZoom(1.5) + expect(press(chord("0", { ctrl: true }))).toBe(1) }) - test("the bare chord alone misses Ctrl+Plus — the regression this locks", () => { - // Guards the fix itself: if someone narrows the config back to "mod+=", - // this is the assertion that explains why they should not. - const narrow = parseKeybind("mod+=") - expect(matchKeybind(narrow, chord("mod+=", "+", { shift: true }))).toBe(false) - expect(matchKeybind(narrow, chord("mod+=", "+"))).toBe(false) - expect(matchKeybind(narrow, chord("mod+=", "=", { shift: true }))).toBe(false) + test("Cmd (meta) drives the same chords", () => { + for (const [key, expected] of [ + ["=", 1.1], + ["-", 0.9], + ["0", 1], + ] as const) { + setWebZoom(1) + expect(press(chord(key, { meta: true }))).toBe(expected) + } }) - // The seam. Everything above proves the chord SETS behave; this proves the - // app actually registers them. #266 shipped because the mechanism was right - // and its one integration was not — asserting the set without asserting the - // registration would reproduce that exact failure mode in the fix's own test. - test("layout.tsx registers these exact chord sets", () => { - const layout = readFileSync(join(import.meta.dir, "..", "pages", "layout.tsx"), "utf8") - expect(layout).toContain(`keybind: "${ZOOM_IN}"`) - expect(layout).toContain(`keybind: "${ZOOM_OUT}"`) - expect(layout).toContain(`keybind: "${ZOOM_RESET}"`) + test("nothing fires without a modifier", () => { + for (const key of ["=", "+", "-", "_", "0"]) { + setWebZoom(1) + expect(press(chord(key))).toBe(1) + expect(press(chord(key, { shift: true }))).toBe(1) + } }) - test("zoom chords do not collide with each other", () => { - const zin = parseKeybind(ZOOM_IN) - const zout = parseKeybind(ZOOM_OUT) - + test("other Ctrl chords are untouched", () => { for (const [key, shift] of [ - ["=", false], - ["+", true], - ["+", false], - ["=", true], + ["w", false], + ["p", false], + ["p", true], + ["a", false], + ["1", false], + ["ArrowUp", false], ] as const) { - expect(matchKeybind(zout, chord(ZOOM_OUT, key, { shift }))).toBe(false) - } - for (const [key, shift] of [ - ["-", false], - ["_", true], - ] as const) { - expect(matchKeybind(zin, chord(ZOOM_IN, key, { shift }))).toBe(false) + setWebZoom(1) + expect(press(chord(key, { ctrl: true, shift }))).toBe(1) } }) + + test("matched chords are consumed, unmatched chords are not", () => { + const matched = chord("=", { ctrl: true }) + document.dispatchEvent(matched) + expect(matched.defaultPrevented).toBe(true) + + const unmatched = chord("p", { ctrl: true }) + document.dispatchEvent(unmatched) + expect(unmatched.defaultPrevented).toBe(false) + }) }) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 2a98af539c..e10956ec0f 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -16,7 +16,6 @@ import { useNavigate, useParams } from "@solidjs/router" import { useLayout, LocalProject } from "@/context/layout" import { VaultPanel } from "@/components/vault-panel" -import { webZoomIn, webZoomOut, webZoomReset } from "@/utils/web-zoom" import { useServerSync } from "@/context/server-sync" import { Persist, persisted } from "@/utils/persist" import { base64Encode } from "@opencode-ai/core/util/encode" @@ -226,10 +225,11 @@ export default function LegacyLayout(props: ParentProps) { makeEventListener(window, "blur", blur) makeEventListener(document, "visibilitychange", hide) - // Zoom keyboard handling lives in the command registry below (amicode#266). - // A window-capture keydown handler here would swallow the chords BEFORE the - // command dispatch, and inside the webview the host intercepts them anyway — - // both routes end at web-zoom.ts, which routes to the workbench when framed. + // Zoom keyboard handling lives in web-zoom.ts: each app document (main + // frame and split panes) captures the zoom chords itself and applies its + // own CSS zoom (amicode#266). It must NOT be a command here — the registry's + // exact key matching is layout-sensitive, and inside the webview the + // chords never reach this document from the host anyway. }) const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined) @@ -918,46 +918,12 @@ export default function LegacyLayout(props: ParentProps) { keybind: "mod+comma", onSelect: () => openSettings(), }, - // web host only: the desktop build zooms through Electron (menu roles - // were retired for the action path), and registering the same chords - // here would double-fire against its renderer keydown handler. Inside - // the amicode webview the commands route through web-zoom.ts to the - // workbench (the host owns zoom there — its chords never reach this - // document); on a plain browser they own the in-app CSS zoom. - // Keybind matching is an exact (key, modifier-mask) lookup, so every - // chord that physically means "zoom in" must be registered - // (harmoniqs/amicode#266). On a US layout Ctrl/Cmd + Plus IS - // shift+"=", which arrives as key "+" (normalized "plus") WITH the - // shift bit — matching neither the key nor the mask of a bare "mod+=". - // The numpad's "+" arrives unshifted, and on layouts where "=" itself - // is shifted (DE/FR/Nordic) even the canonical chord carries shift. - // Only the first is shown in tooltips (displayKeybind takes - // parseKeybind(config)[0]). - ...(platform.platform === "web" - ? [ - { - id: "view.zoomIn", - title: language.t("amicode.zoomIn"), - category: language.t("command.category.view"), - keybind: "mod+=,mod+shift+=,mod+plus,mod+shift+plus", - onSelect: () => webZoomIn(), - }, - { - id: "view.zoomOut", - title: language.t("amicode.zoomOut"), - category: language.t("command.category.view"), - keybind: "mod+-,mod+shift+_", - onSelect: () => webZoomOut(), - }, - { - id: "view.zoomReset", - title: language.t("amicode.zoomReset"), - category: language.t("command.category.view"), - keybind: "mod+0", - onSelect: () => webZoomReset(), - }, - ] - : []), + // Zoom (Cmd/Ctrl+=/-/0) is deliberately NOT a command: web-zoom.ts + // captures the chords at each document and applies the in-app CSS zoom + // directly (harmoniqs/amicode#266) — the registry's exact key matching + // cannot express the layout variants of "+", and inside the webview the + // chords never reach this document from the host anyway. The desktop + // build keeps zooming through Electron. ...(platform.platform === "desktop" && platform.exportDebugLogs ? [ { diff --git a/packages/app/src/utils/web-zoom.test.ts b/packages/app/src/utils/web-zoom.test.ts index ea068bf4ea..a8295668b2 100644 --- a/packages/app/src/utils/web-zoom.test.ts +++ b/packages/app/src/utils/web-zoom.test.ts @@ -1,70 +1,30 @@ import { describe, expect, test } from "bun:test" -import { ZOOM_BRIDGE_KIND, inWebview, webZoom, webZoomIn, webZoomOut, webZoomReset } from "./web-zoom" +import { webZoom, webZoomIn, webZoomOut, webZoomReset } from "./web-zoom" -// Zoom routing (amicode#266): the WORKBENCH owns zoom inside the webview — the -// host intercepts the chords before this document sees them, so in-app CSS -// zoom is unreachable there. The app posts a bridge envelope instead. On a -// plain browser (unframed) the app owns zoom and the CSS path applies. -// -// happydom's window.parent defaults to the window itself → unframed by -// default; the framed case shadows the property with a posting stub. +// Zoom routing (amicode#266): ONE zoom owner — the app document itself. The +// workbench can never see keydowns from inside the webview documents +// (cross-origin iframe), so there is no bridge, no "framed" branch, and no +// host interception to route around: webZoomIn/Out/Reset always apply the +// in-app CSS zoom, and the module-scope capture listener (registered on +// import, below) applies it from the raw chords in every document that loads +// the bundle — the main frame and each split pane. -function framedWindow(): { restore(): void } { - const fakeParent = { postMessage: (_msg: unknown) => {} } - Object.defineProperty(window, "parent", { value: fakeParent, configurable: true }) - return { - restore() { - Object.defineProperty(window, "parent", { value: window, configurable: true }) - }, - } -} - -describe("web-zoom routing (amicode#266)", () => { - test("unframed: zoom applies in-app — the signal moves, nothing is posted", () => { - expect(inWebview()).toBe(false) +describe("web-zoom signal (amicode#266)", () => { + test("zoom in/out/reset always move the in-app signal", () => { const before = webZoom() webZoomIn() expect(webZoom()).toBe(Math.round((before + 0.1) * 100) / 100) webZoomOut() + expect(webZoom()).toBe(Math.round(before * 100) / 100) webZoomReset() expect(webZoom()).toBe(1) }) - test("framed: zoom posts the bridge envelope and leaves the signal at 1", () => { - const posted: unknown[] = [] - Object.defineProperty(window, "parent", { - value: { postMessage: (msg: unknown) => posted.push(msg) }, - configurable: true, - }) - try { - expect(inWebview()).toBe(true) - webZoomIn() - webZoomOut() - webZoomReset() - expect(posted).toEqual([ - { source: "amicode", kind: ZOOM_BRIDGE_KIND, action: "in" }, - { source: "amicode", kind: ZOOM_BRIDGE_KIND, action: "out" }, - { source: "amicode", kind: ZOOM_BRIDGE_KIND, action: "reset" }, - ]) - expect(webZoom()).toBe(1) - } finally { - framedWindow().restore() - } - }) - - test("the zoom envelopes carry exactly the three workbench actions", () => { - const actions: unknown[] = [] - Object.defineProperty(window, "parent", { - value: { postMessage: (msg: unknown) => actions.push(msg) }, - configurable: true, - }) - try { - webZoomIn() - webZoomOut() - webZoomReset() - expect(actions.map((m) => (m as { action: unknown }).action)).toEqual(["in", "out", "reset"]) - } finally { - framedWindow().restore() - } + test("the raw chord listener is registered on the document", () => { + webZoomReset() + const fired = new KeyboardEvent("keydown", { key: "=", ctrlKey: true, cancelable: true }) + document.dispatchEvent(fired) + expect(fired.defaultPrevented).toBe(true) + expect(webZoom()).toBe(1.1) }) }) diff --git a/packages/app/src/utils/web-zoom.ts b/packages/app/src/utils/web-zoom.ts index b1ef278f10..a5d5ea66f7 100644 --- a/packages/app/src/utils/web-zoom.ts +++ b/packages/app/src/utils/web-zoom.ts @@ -6,15 +6,14 @@ // per-window, and feeds the same platform.webviewZoom signal the titlebar // and terminal already watch. // -// Two hosts, two zoom owners (amicode#266): -// - Framed (the amicode VS Code webview): the WORKBENCH owns zoom. The host -// intercepts the zoom chords before the webview document ever sees the -// keydown, so in-app CSS zoom cannot fire there — the app posts a zoom -// intent over the extension bridge instead and the extension executes the -// matching workbench.action.zoomIn/Out/Reset. The app's own zoom signal -// stays at 1: the host's zoom level is not observable from inside the -// webview. -// - Unframed (plain browser): CSS zoom on the document root, as before. +// One zoom owner (harmoniqs/amicode#266): the app document itself. The +// workbench can never see keydowns from inside the webview documents +// (cross-origin iframe; the host page's forwarding covers only the host +// page), so the extension cannot translate them into workbench zoom actions. +// Instead every app document — the main frame and each split pane — captures +// the zoom chords itself and applies its own CSS zoom: with the webview +// panel focused, the zoomed content is the webview; with an editor tab +// focused, the workbench's own native window zoom applies, unchanged. import { createSignal } from "solid-js" const KEY = "amicode-zoom" @@ -32,83 +31,6 @@ function readSaved(): number { const [webZoom, setSignal] = createSignal(readSaved()) -/** Framed = inside the amicode VS Code webview, extension host present. */ -export const inWebview = () => typeof window !== "undefined" && window.parent !== window - -/** The bridge envelope the extension answers with a workbench zoom action. */ -export const ZOOM_BRIDGE_KIND = "zoom" -export type ZoomAction = "in" | "out" | "reset" - -function postZoom(action: ZoomAction) { - // TEMP-DIAG (amicode#266 remote test): hop log — the chord reached a - // document and is about to leave it. Remove after the diagnosis. - console.log("[zoom] post:", action) - try { - window.parent?.postMessage({ source: "amicode", kind: ZOOM_BRIDGE_KIND, action }, "*") - } catch {} -} - -// Pane relay (amicode#266): a split-frame pane is a FULL second app instance -// (same bundle, same origin). Its zoom envelopes post to ITS window.parent — -// this window — because that is the farthest a framed child can reach. No -// other listener forwards amicode envelopes from a child up, so relay zoom -// intents onward to the webview host page. Guard: only messages arriving from -// a child window (event.source is not the host page) — our own posts go -// straight to window.parent and extension-originated envelopes arrive from -// window.parent, so neither can loop here. -export function installZoomPaneRelay(): () => void { - if (!inWebview()) return () => {} - const onMsg = (e: MessageEvent) => { - const d = e.data as { source?: unknown; kind?: unknown; action?: unknown; level?: unknown; message?: unknown } | undefined - if (!d || d.source !== "amicode") return - if (!e.source || e.source === window.parent) return - if (d.kind === ZOOM_BRIDGE_KIND) { - const action = d.action - if (action !== "in" && action !== "out" && action !== "reset") return - // TEMP-DIAG (amicode#266 remote test): a pane's zoom intent arrived here. - // Remove after the diagnosis. - console.log("[zoom] relayed from pane:", action) - postZoom(action) - return - } - // TEMP-DIAG (amicode#266 remote test): a pane's relayed console line - // arrived — forward it up to the host page like the zoom intents. - if (d.kind === "diag-log") { - window.parent?.postMessage( - { source: "amicode", kind: "diag-log", level: d.level, message: d.message }, - "*", - ) - } - } - window.addEventListener("message", onMsg) - return () => window.removeEventListener("message", onMsg) -} - -installZoomPaneRelay() - -// TEMP-DIAG (amicode#266 remote test): forward our [zoom]-prefixed console -// lines to the extension host so a remote session can hand back a log file -// (Output panel → "Amicode — webview diag" → "Open Log File") instead of -// webview devtools. Remove after the diagnosis. -function installDiagRelay(): void { - if (!inWebview()) return - const fwd = (level: "log" | "warn" | "error") => { - const orig = console[level].bind(console) - return (...args: unknown[]) => { - orig(...args) - const text = args.find((a): a is string => typeof a === "string") - if (text?.startsWith("[zoom]")) { - window.parent?.postMessage({ source: "amicode", kind: "diag-log", level, message: text }, "*") - } - } - } - console.log = fwd("log") - console.warn = fwd("warn") - console.error = fwd("error") -} - -installDiagRelay() - function apply(zoom: number) { if (typeof document === "undefined") return const html = document.documentElement @@ -144,10 +66,39 @@ export function setWebZoom(next: number) { } } -// Zoom routes to the workbench when framed (the host owns zoom there — its -// chords never reach this document) and to the in-app CSS zoom otherwise -// (plain-browser host, where this module is the only zoom owner). -export const webZoomIn = () => (inWebview() ? postZoom("in") : setWebZoom(webZoom() + STEP)) -export const webZoomOut = () => (inWebview() ? postZoom("out") : setWebZoom(webZoom() - STEP)) -export const webZoomReset = () => (inWebview() ? postZoom("reset") : setWebZoom(1)) +export const webZoomIn = () => setWebZoom(webZoom() + STEP) +export const webZoomOut = () => setWebZoom(webZoom() - STEP) +export const webZoomReset = () => setWebZoom(1) export { webZoom } + +// Raw chord capture (harmoniqs/amicode#266). The command registry's exact +// (normalized-key, modifier-mask) lookup cannot express "the key that means +// +" across layouts: Ctrl+Plus on US IS Ctrl+Shift+"=", arriving as key "+" +// with the shift bit — wrong key AND wrong mask — while the numpad "+" +// arrives unshifted and on DE/FR/Nordic layouts even the canonical "=" +// carries shift. Rather than widen the registry's chords (and its tooltips, +// palette, and collision surface), the zoom chords are captured directly by +// each app document, matching the key the layout PRODUCES — the same +// physical key yields one of "="/"+"/"-"/"_" on every layout, with the shift +// bit deliberately ignored. "0" is unshifted on every layout we ship to and +// stays unambiguous. +const ZOOM_IN_KEYS = new Set(["=", "+"]) +const ZOOM_OUT_KEYS = new Set(["-", "_"]) +const ZOOM_RESET_KEYS = new Set(["0"]) + +if (typeof document !== "undefined") { + document.addEventListener( + "keydown", + (event) => { + if (!event.metaKey && !event.ctrlKey) return + const key = event.key + if (ZOOM_IN_KEYS.has(key)) setWebZoom(webZoom() + STEP) + else if (ZOOM_OUT_KEYS.has(key)) setWebZoom(webZoom() - STEP) + else if (ZOOM_RESET_KEYS.has(key)) setWebZoom(1) + else return + event.preventDefault() + event.stopPropagation() + }, + { capture: true }, + ) +}