diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index a3cb88aea..002090491 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -77,6 +77,7 @@ import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } fro import { NewHome } from "@/pages/home" import { LegacyHome } from "@/pages/home/legacy-home" import { AmicodeFileRefBridge } from "@/components/amicode-file-ref-bridge" +import { DevToolsReopenBridge } from "@/components/settings-dialog" const NewSession = lazy(() => import("@/pages/new-session")) @@ -501,6 +502,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { + {props.children} diff --git a/packages/app/src/components/settings-dialog.tsx b/packages/app/src/components/settings-dialog.tsx index 46dd89ade..3140df21b 100644 --- a/packages/app/src/components/settings-dialog.tsx +++ b/packages/app/src/components/settings-dialog.tsx @@ -4,7 +4,7 @@ import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useDialog } from "@opencode-ai/ui/context/dialog" -export function useSettingsDialog(defaultValue?: string) { +export function useSettingsDialog(defaultValue?: string, scrollTo?: string) { const dialog = useDialog() const params = useParams<{ id?: string }>() let run = 0 @@ -19,7 +19,7 @@ export function useSettingsDialog(defaultValue?: string) { const sessionID = params.id void import("@/components/settings-v2").then((module) => { if (dead || run !== current) return - void dialog.show(() => ) + void dialog.show(() => ) }) } } @@ -41,3 +41,8 @@ export function useSettingsCommand() { return show } + +/** @deprecated — reopen logic moved into useSettingsCommand which has full context */ +export function DevToolsReopenBridge() { + return null +} 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..162e99e1e --- /dev/null +++ b/packages/app/src/components/settings-v2/developer-tools-controller.ts @@ -0,0 +1,189 @@ +import { createSignal, onCleanup, onMount } 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 + building?: boolean + buildError?: string +} + +export type RebuildState = "idle" | "rebuilding" | "rebuilt" | "failed" + +/** Default repo paths autofilled when the toggle is turned ON with empty fields. */ +const DEFAULT_OPENCODE_PATH = "~/harmoniqs/opencode" +const DEFAULT_AMICODE_PATH = "~/harmoniqs/amicode" + +export function createDeveloperToolsController() { + const settings = useSettings() + const [status, setStatus] = createSignal(undefined) + const [pending, setPending] = createSignal(false) + const [rebuildState, setRebuildState] = createSignal("idle") + const [rebuildError, setRebuildError] = createSignal(undefined) + + // On mount, check if we just came back from a successful rebuild reload + onMount(() => { + try { + if (localStorage.getItem("amicode:devtools-rebuilt") === "1") { + localStorage.removeItem("amicode:devtools-rebuilt") + setRebuildState("rebuilt") + // Clear the "rebuilt" badge after 5 seconds + setTimeout(() => setRebuildState("idle"), 5000) + } + } catch { + // non-critical + } + }) + + // Listen for the extension host's replies + 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, + building: d.building ?? false, + buildError: d.buildError, + }) + setPending(false) + + // When a reload is needed (extension was rebuilt), set a flag so the app + // reopens settings at the developer tools section after the reload. + if (d.reloadNeeded) { + try { + localStorage.setItem("amicode:devtools-reopen", "1") + localStorage.setItem("amicode:devtools-rebuilt", "1") + } catch { + // localStorage unavailable — non-critical + } + } + } + + // Rebuild status messages + if (d && d.source === "amicode" && d.kind === "dev-tools-rebuild-status") { + if (d.state === "rebuilding") { + setRebuildState("rebuilding") + setRebuildError(undefined) + } else if (d.state === "failed") { + setRebuildState("failed") + setRebuildError(d.error ?? "Unknown error") + } + // "done" state triggers a reload — the "rebuilt" flag is read on next mount + } + } + + 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(), + }, + "*", + ) + } + + const rebuild = (mode: "local" | "remote") => { + if (!inAmicode()) return + if (rebuildState() === "rebuilding") return // prevent double-clicks + setRebuildState("rebuilding") + setRebuildError(undefined) + try { + localStorage.setItem("amicode:devtools-reopen", "1") + localStorage.setItem("amicode:devtools-rebuilt", "1") + } catch { + // non-critical + } + window.parent.postMessage( + { + source: "amicode", + kind: "dev-tools-rebuild", + mode, + opencodePath: settings.developer.opencodePath(), + amicodePath: settings.developer.amicodePath(), + }, + "*", + ) + } + + return { + enabled: settings.developer.enabled, + setEnabled: (value: boolean) => { + // Autofill paths with defaults when toggling ON with empty fields + if (value) { + if (!settings.developer.opencodePath()) { + settings.developer.setOpencodePath(DEFAULT_OPENCODE_PATH) + } + if (!settings.developer.amicodePath()) { + settings.developer.setAmicodePath(DEFAULT_AMICODE_PATH) + } + } + // Don't persist enabled=false — the marketplace build doesn't render + // Developer Tools at all, and persisting false prevents the dev build + // from showing it after a bash-script bootstrap without toggle interaction. + if (value) { + settings.developer.setEnabled(value) + // Toggle ON: trigger a full rebuild (shows "Rebuilding..." status) + rebuild("local") + } else { + // Toggle OFF: show switching status, then the extension restores + reloads. + // Don't persist false — the reload brings up the marketplace build which + // doesn't have this section anyway. + setRebuildState("rebuilding") + setRebuildError(undefined) + // Send enabled=false explicitly (can't rely on the signal since we didn't persist it) + if (!inAmicode()) return + setPending(true) + setStatus(undefined) + window.parent.postMessage( + { + source: "amicode", + kind: "dev-tools-update", + enabled: false, + opencodePath: settings.developer.opencodePath(), + amicodePath: settings.developer.amicodePath(), + }, + "*", + ) + } + }, + 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(), + /** Trigger a full rebuild (local = from disk, remote = git pull first) */ + rebuild, + status, + pending, + rebuildState, + rebuildError, + } +} + +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..1f53e7c8f --- /dev/null +++ b/packages/app/src/components/settings-v2/developer-tools.tsx @@ -0,0 +1,181 @@ +import { Component, Match, Show, Switch } from "solid-js" +import { Switch as ToggleSwitch } from "@opencode-ai/ui/v2/switch-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { useLanguage } from "@/context/language" +import { SettingsListV2 } from "./parts/list" +import { SettingsRowV2 } from "./parts/row" +import { + createDeveloperToolsController, + type DeveloperToolsController, +} from "./developer-tools-controller" + +/** Status indicator shown below the section title during/after rebuilds. */ +const RebuildStatusIndicator: Component<{ controller: DeveloperToolsController }> = (props) => { + const language = useLanguage() + return ( + + +
+ + {language.t("settings.general.row.devTools.rebuilding")} +
+
+ +
+ + {language.t("settings.general.row.devTools.rebuilt")} +
+
+ +
+ + {props.controller.rebuildError() ?? "Build failed"} +
+
+
+ ) +} + +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 + if (s.buildError) return s.buildError + return s.amicodeError ?? language.t("settings.general.row.amicodePath.error.notFound") + } + const building = () => props.controller.status()?.building ?? false + const reloadNeeded = () => props.controller.status()?.reloadNeeded ?? false + const isRebuilding = () => props.controller.rebuildState() === "rebuilding" + + return ( + + {/* Rebuild buttons */} + +
+ props.controller.rebuild("local")} + disabled={isRebuilding()} + > + {language.t("settings.general.row.devTools.rebuildLocally")} + + props.controller.rebuild("remote")} + disabled={isRebuilding()} + > + {language.t("settings.general.row.devTools.rebuildRemotely")} + +
+
+ + +
+ 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")} + + + {language.t("settings.general.row.amicodePath.building")} + + + + {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/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index eeb5565fa..4a4e10e4d 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -1,4 +1,4 @@ -import { Component, createMemo, createSignal, startTransition } from "solid-js" +import { Component, createMemo, createSignal, onMount, startTransition } from "solid-js" import { Dialog } from "@opencode-ai/ui/v2/dialog-v2" import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" import { Icon } from "@opencode-ai/ui/icon" @@ -19,6 +19,7 @@ import { useServerSync } from "@/context/server-sync" export const DialogSettings: Component<{ sessionID?: string defaultValue?: string + scrollTo?: string }> = (props) => { const language = useLanguage() const platform = usePlatform() @@ -27,6 +28,17 @@ export const DialogSettings: Component<{ const tabs = useTabs() const serverSync = useServerSync() const [tab, setTab] = createSignal(props.defaultValue ?? "general") + + // After mount, scroll to the target section if specified + onMount(() => { + if (props.scrollTo) { + // Delay to ensure DOM is rendered after tab switch + setTimeout(() => { + const el = document.getElementById(props.scrollTo!) + el?.scrollIntoView({ behavior: "smooth", block: "start" }) + }, 100) + } + }) const directory = createMemo(() => { const route = layout.route() if (route.type === "dir-new-sesssion") return route.dir 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/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..f69e7db2d 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -15,6 +15,7 @@ import { usePlatform } from "@/context/platform" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" +import { useSettingsDialog } from "@/components/settings-dialog" import { WindowsAppMenu } from "./windows-app-menu" import { applyPath, backPath, forwardPath } from "./titlebar-history" import { TitlebarTabStrip } from "@/components/titlebar-tab-strip" @@ -26,6 +27,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 @@ -76,6 +78,19 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl } const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px` + // After a dev-tools rebuild + reload, auto-open settings at Developer Tools + const showDevTools = useSettingsDialog("general", "settings-developer-tools") + onMount(() => { + try { + if (localStorage.getItem("amicode:devtools-reopen") === "1") { + localStorage.removeItem("amicode:devtools-reopen") + setTimeout(showDevTools, 300) + } + } catch { + // non-critical + } + }) + const [history, setHistory] = createStore({ stack: [] as string[], index: 0, @@ -616,6 +631,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 (