From 3008687adfa282c96529162ee9eea226b6f1b0ba Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 14 Aug 2026 12:47:46 +0200 Subject: [PATCH 1/9] feat: Developer Tools section in General Settings (#377) Add a Developer Tools section at the bottom of the General tab in the settings dialog. A master toggle switches between release and developer mode; two path fields (opencode binary root, Amicode extension asset root) become editable when the toggle is ON. - New settings: developer.enabled, developer.opencodePath, developer.amicodePath - Controller dispatches dev-tools-update bridge messages on blur - Inline validation shows errors from the extension host reply - CSS for field-error and field-warning inline styles App-side of harmoniqs/amicode#377. --- .../settings-v2/developer-tools-controller.ts | 78 ++++++++++++ .../settings-v2/developer-tools.tsx | 119 ++++++++++++++++++ .../src/components/settings-v2/general.tsx | 3 + .../components/settings-v2/settings-v2.css | 15 +++ packages/app/src/context/settings.tsx | 24 ++++ packages/app/src/i18n/en.ts | 17 +++ 6 files changed, 256 insertions(+) create mode 100644 packages/app/src/components/settings-v2/developer-tools-controller.ts create mode 100644 packages/app/src/components/settings-v2/developer-tools.tsx diff --git a/packages/app/src/components/settings-v2/developer-tools-controller.ts b/packages/app/src/components/settings-v2/developer-tools-controller.ts new file mode 100644 index 000000000..e3aecde22 --- /dev/null +++ b/packages/app/src/components/settings-v2/developer-tools-controller.ts @@ -0,0 +1,78 @@ +import { createSignal, onCleanup } from "solid-js" +import { useSettings } from "@/context/settings" +import { inAmicode } from "@/utils/amicode-bridge" + +export interface DevToolsStatus { + opencodeValid: boolean + opencodeError?: string + amicodeValid: boolean + amicodeError?: string + serverRestarted: boolean + reloadNeeded: boolean +} + +export function createDeveloperToolsController() { + const settings = useSettings() + const [status, setStatus] = createSignal(undefined) + const [pending, setPending] = createSignal(false) + + // Listen for the extension host's validation reply + const handleMessage = (event: MessageEvent) => { + const d = event.data + if (d && d.source === "amicode" && d.kind === "dev-tools-status") { + setStatus({ + opencodeValid: d.opencodeValid ?? true, + opencodeError: d.opencodeError, + amicodeValid: d.amicodeValid ?? true, + amicodeError: d.amicodeError, + serverRestarted: d.serverRestarted ?? false, + reloadNeeded: d.reloadNeeded ?? false, + }) + setPending(false) + } + } + + if (typeof window !== "undefined") { + window.addEventListener("message", handleMessage) + onCleanup(() => window.removeEventListener("message", handleMessage)) + } + + const sendUpdate = () => { + if (!inAmicode()) return + setPending(true) + setStatus(undefined) + window.parent.postMessage( + { + source: "amicode", + kind: "dev-tools-update", + enabled: settings.developer.enabled(), + opencodePath: settings.developer.opencodePath(), + amicodePath: settings.developer.amicodePath(), + }, + "*", + ) + } + + return { + enabled: settings.developer.enabled, + setEnabled: (value: boolean) => { + settings.developer.setEnabled(value) + sendUpdate() + }, + opencodePath: settings.developer.opencodePath, + setOpencodePath: (value: string) => { + settings.developer.setOpencodePath(value) + }, + amicodePath: settings.developer.amicodePath, + setAmicodePath: (value: string) => { + settings.developer.setAmicodePath(value) + }, + /** Trigger validation + apply on blur */ + commitOpencodePath: () => sendUpdate(), + commitAmicodePath: () => sendUpdate(), + status, + pending, + } +} + +export type DeveloperToolsController = ReturnType diff --git a/packages/app/src/components/settings-v2/developer-tools.tsx b/packages/app/src/components/settings-v2/developer-tools.tsx new file mode 100644 index 000000000..5f253348b --- /dev/null +++ b/packages/app/src/components/settings-v2/developer-tools.tsx @@ -0,0 +1,119 @@ +import { Component, Show } from "solid-js" +import { Switch } from "@opencode-ai/ui/v2/switch-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { useLanguage } from "@/context/language" +import { SettingsListV2 } from "./parts/list" +import { SettingsRowV2 } from "./parts/row" +import { + createDeveloperToolsController, + type DeveloperToolsController, +} from "./developer-tools-controller" + +const DeveloperToolsContent: Component<{ controller: DeveloperToolsController }> = (props) => { + const language = useLanguage() + const opencodeError = () => { + const s = props.controller.status() + if (!s || s.opencodeValid) return undefined + return s.opencodeError ?? language.t("settings.general.row.opencodePath.error.notFound") + } + const amicodeError = () => { + const s = props.controller.status() + if (!s || s.amicodeValid) return undefined + return s.amicodeError ?? language.t("settings.general.row.amicodePath.error.notFound") + } + const reloadNeeded = () => props.controller.status()?.reloadNeeded ?? false + + return ( + + +
+ props.controller.setEnabled(checked)} + /> +
+
+ + + {language.t("settings.general.row.opencodePath.description")} + + {opencodeError()} + + + } + > +
+ props.controller.setOpencodePath(event.currentTarget.value)} + onBlur={() => props.controller.commitOpencodePath()} + placeholder={language.t("settings.general.row.opencodePath.placeholder")} + disabled={!props.controller.enabled()} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + aria-label={language.t("settings.general.row.opencodePath.title")} + /> +
+
+ + + {language.t("settings.general.row.amicodePath.description")} + + {amicodeError()} + + + + {language.t("settings.general.row.amicodePath.reloadNeeded")} + + + + } + > +
+ props.controller.setAmicodePath(event.currentTarget.value)} + onBlur={() => props.controller.commitAmicodePath()} + placeholder={language.t("settings.general.row.amicodePath.placeholder")} + disabled={!props.controller.enabled()} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + aria-label={language.t("settings.general.row.amicodePath.title")} + /> +
+
+
+ ) +} + +export const DeveloperToolsSection: Component = () => { + const language = useLanguage() + const controller = createDeveloperToolsController() + return ( +
+

+ {language.t("settings.general.section.developerTools")} +

+ +
+ ) +} diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index c0a856c22..c56fe699a 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -25,6 +25,7 @@ import { type ShellSettingsController, type SoundSettingsController, } from "./general-controllers" +import { DeveloperToolsSection } from "./developer-tools" import "./settings-v2.css" const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"] @@ -568,6 +569,8 @@ export const SettingsGeneralV2: Component<{ + + ) diff --git a/packages/app/src/components/settings-v2/settings-v2.css b/packages/app/src/components/settings-v2/settings-v2.css index 494f4d3e2..f6f1528ec 100644 --- a/packages/app/src/components/settings-v2/settings-v2.css +++ b/packages/app/src/components/settings-v2/settings-v2.css @@ -924,3 +924,18 @@ text-overflow: ellipsis; white-space: nowrap; } + +/* Developer Tools / Data & Storage inline validation */ +.settings-v2-field-error { + display: block; + margin-top: 4px; + font-size: 12px; + color: var(--v2-text-text-danger, #e5534b); +} + +.settings-v2-field-warning { + display: block; + margin-top: 4px; + font-size: 12px; + color: var(--v2-text-text-warning, #d29922); +} diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 218860397..abb129e8a 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -46,6 +46,11 @@ export interface Settings { sans: string terminal: string } + developer: { + enabled: boolean + opencodePath: string + amicodePath: string + } keybinds: Record permissions: { autoApprove: boolean @@ -202,6 +207,11 @@ const defaultSettings: Settings = { sans: "", terminal: "", }, + developer: { + enabled: false, + opencodePath: "", + amicodePath: "", + }, keybinds: {}, permissions: { autoApprove: false, @@ -533,6 +543,20 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setStore("sounds", "errors", value) }, }, + developer: { + enabled: withFallback(() => store.developer?.enabled, defaultSettings.developer.enabled), + setEnabled(value: boolean) { + setStore("developer", "enabled", value) + }, + opencodePath: withFallback(() => store.developer?.opencodePath, defaultSettings.developer.opencodePath), + setOpencodePath(value: string) { + setStore("developer", "opencodePath", value) + }, + amicodePath: withFallback(() => store.developer?.amicodePath, defaultSettings.developer.amicodePath), + setAmicodePath(value: string) { + setStore("developer", "amicodePath", value) + }, + }, } }, }) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index def3084c2..29b4ade79 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -1002,6 +1002,23 @@ export const dict = { "settings.general.row.wayland.tooltip": "On Linux with mixed refresh-rate monitors, native Wayland can be more stable.", + "settings.general.section.developerTools": "Developer Tools", + "settings.general.row.developerMode.title": "Use developer build", + "settings.general.row.developerMode.description": + "Switch between the release and a local developer build of the opencode server and Amicode extension assets", + "settings.general.row.opencodePath.title": "opencode binary path", + "settings.general.row.opencodePath.description": + "Path to your local opencode repository root (the binary is resolved from here)", + "settings.general.row.opencodePath.placeholder": "e.g. ~/harmoniqs/opencode", + "settings.general.row.opencodePath.error.notFound": "Binary not found at this path", + "settings.general.row.opencodePath.error.notExecutable": "Binary exists but is not executable", + "settings.general.row.amicodePath.title": "Amicode extension path", + "settings.general.row.amicodePath.description": + "Path to your local Amicode extension source root (scores, templates, bin/ resolve from here)", + "settings.general.row.amicodePath.placeholder": "e.g. ~/harmoniqs/amicode/packages/extension", + "settings.general.row.amicodePath.error.notFound": "Directory does not exist", + "settings.general.row.amicodePath.reloadNeeded": "Reload to apply", + "settings.general.row.releaseNotes.title": "Release notes", "settings.general.row.releaseNotes.description": "Show What's New popups after updates", From 1039f431ff8a844d7c7ccedeccd7193a93bd50a6 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Fri, 14 Aug 2026 14:25:17 +0200 Subject: [PATCH 2/9] feat: show green DEV badge in titlebar when developer mode is enabled The titlebar channel indicator now reads the developer.enabled setting: - Developer mode ON: green DEV pill (amicode-dev-tag slot) - Developer mode OFF: amber BETA pill on beta/dev channels (unchanged) - Prod channel, dev OFF: no badge (unchanged) Extracted channelBadgeText() as a pure testable function. Added missing i18n keys for developer tools section to all locales. --- .../src/components/titlebar-channel.test.ts | 19 ++++++++++++++++ .../app/src/components/titlebar-channel.ts | 12 ++++++++++ packages/app/src/components/titlebar.tsx | 22 +++++++++++++------ packages/app/src/i18n/ar.ts | 16 ++++++++++++++ packages/app/src/i18n/br.ts | 16 ++++++++++++++ packages/app/src/i18n/bs.ts | 16 ++++++++++++++ packages/app/src/i18n/da.ts | 16 ++++++++++++++ packages/app/src/i18n/de.ts | 16 ++++++++++++++ packages/app/src/i18n/es.ts | 16 ++++++++++++++ packages/app/src/i18n/fr.ts | 16 ++++++++++++++ packages/app/src/i18n/ja.ts | 16 ++++++++++++++ packages/app/src/i18n/ko.ts | 16 ++++++++++++++ packages/app/src/i18n/no.ts | 16 ++++++++++++++ packages/app/src/i18n/pl.ts | 16 ++++++++++++++ packages/app/src/i18n/ru.ts | 16 ++++++++++++++ packages/app/src/i18n/th.ts | 16 ++++++++++++++ packages/app/src/i18n/tr.ts | 16 ++++++++++++++ packages/app/src/i18n/uk.ts | 16 ++++++++++++++ packages/app/src/i18n/zh.ts | 16 ++++++++++++++ packages/app/src/i18n/zht.ts | 16 ++++++++++++++ packages/ui/src/amicode/amicode.css | 9 ++++++++ 21 files changed, 327 insertions(+), 7 deletions(-) create mode 100644 packages/app/src/components/titlebar-channel.test.ts create mode 100644 packages/app/src/components/titlebar-channel.ts diff --git a/packages/app/src/components/titlebar-channel.test.ts b/packages/app/src/components/titlebar-channel.test.ts new file mode 100644 index 000000000..54370b7df --- /dev/null +++ b/packages/app/src/components/titlebar-channel.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test" +import { channelBadgeText } from "./titlebar-channel" + +describe("channelBadgeText", () => { + test("returns DEV when developer mode is enabled, regardless of channel", () => { + expect(channelBadgeText("beta", true)).toBe("DEV") + expect(channelBadgeText("dev", true)).toBe("DEV") + expect(channelBadgeText("prod", true)).toBe("DEV") + }) + + test("returns BETA on beta or dev channel when developer mode is off", () => { + expect(channelBadgeText("beta", false)).toBe("BETA") + expect(channelBadgeText("dev", false)).toBe("BETA") + }) + + test("returns null on prod channel when developer mode is off", () => { + expect(channelBadgeText("prod", false)).toBeNull() + }) +}) diff --git a/packages/app/src/components/titlebar-channel.ts b/packages/app/src/components/titlebar-channel.ts new file mode 100644 index 000000000..3b873e9b1 --- /dev/null +++ b/packages/app/src/components/titlebar-channel.ts @@ -0,0 +1,12 @@ +/** + * Determines the badge text shown in the titlebar channel indicator. + * + * - Developer mode ON → "DEV" (regardless of build channel) + * - Developer mode OFF, beta/dev channel → "BETA" + * - Developer mode OFF, prod channel → null (no badge) + */ +export function channelBadgeText(channel: string, developerEnabled: boolean): "DEV" | "BETA" | null { + if (developerEnabled) return "DEV" + if (["beta", "dev"].includes(channel)) return "BETA" + return null +} diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index f8d226dee..c94f3ce92 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -26,6 +26,7 @@ import { ServerConnection, useServer } from "@/context/server" import { tabHref, useTabs, type Tab } from "@/context/tabs" import type { PromptSession } from "@/context/prompt" import { normalizeSessionInfo } from "@/utils/session" +import { channelBadgeText } from "./titlebar-channel" import "./titlebar.css" const legacyTitlebarHeight = 40 @@ -616,6 +617,11 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) { function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) { const channel = import.meta.env.VITE_OPENCODE_CHANNEL + const settings = useSettings() + + // When the build channel is "dev" AND debug tools are available, render + // the interactive DEV button (toggles the debug panel). This is the + // build-plumbing path for local opencode development. if (channel === "dev" && props.debugTools) { return (