diff --git a/packages/app/src/components/settings-v2/developer-tools-controller.ts b/packages/app/src/components/settings-v2/developer-tools-controller.ts index b15414690..179cb25ae 100644 --- a/packages/app/src/components/settings-v2/developer-tools-controller.ts +++ b/packages/app/src/components/settings-v2/developer-tools-controller.ts @@ -1,6 +1,8 @@ -import { createSignal, onCleanup, onMount } from "solid-js" +import { batch, createSignal, onCleanup, onMount } from "solid-js" import { useSettings } from "@/context/settings" import { inAmicode } from "@/utils/amicode-bridge" +import { applyRebuildFlagMutation, rebuildFlagMutation } from "./developer-tools-rebuild-flags" +import { reduceDevToolsRequest } from "./developer-tools-request-state" export interface DevToolsStatus { opencodeValid: boolean @@ -9,8 +11,6 @@ export interface DevToolsStatus { amicodeError?: string serverRestarted: boolean reloadNeeded: boolean - building?: boolean - buildError?: string } export type RebuildState = "idle" | "rebuilding" | "rebuilt" | "failed" @@ -48,7 +48,7 @@ export function createDeveloperToolsController() { // Safety timeout: clear after 5 min to avoid permanently stuck state setTimeout(() => { if (rebuildState() === "rebuilding") { - try { localStorage.removeItem("amicode:devtools-rebuilding") } catch {} + applyRebuildFlagMutation(rebuildFlagMutation("failed")) setRebuildState("failed") setRebuildError("Rebuild timed out") } @@ -67,27 +67,29 @@ export function createDeveloperToolsController() { 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, + const next = reduceDevToolsRequest( + { status: status(), pending: pending() }, + { + type: "status-received", + status: { + opencodeValid: d.opencodeValid ?? true, + opencodeError: d.opencodeError, + amicodeValid: d.amicodeValid ?? true, + amicodeError: d.amicodeError, + serverRestarted: d.serverRestarted ?? false, + reloadNeeded: d.reloadNeeded ?? false, + }, + }, + ) + batch(() => { + setStatus(next.status) + setPending(next.pending) }) - 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 - } + applyRebuildFlagMutation({ set: { reopen: "1", rebuilt: "1" }, clear: [] }) } } @@ -97,12 +99,15 @@ export function createDeveloperToolsController() { setRebuildState("rebuilding") setRebuildError(undefined) } else if (d.state === "failed") { - try { localStorage.removeItem("amicode:devtools-rebuilding") } catch {} + applyRebuildFlagMutation(rebuildFlagMutation("failed")) setRebuildState("failed") setRebuildError(d.error ?? "Unknown error") } else if (d.state === "done") { - try { localStorage.removeItem("amicode:devtools-rebuilding") } catch {} - // The window reload follows shortly — "rebuilt" flag is read on next mount + // The extension host confirmed the build finished — set the + // "rebuilt" flag now (not at rebuild-start) so a dialog reopened + // after the window reload correctly shows "Rebuilt!" rather than + // "Rebuilding..." (#940). The window reload follows shortly. + applyRebuildFlagMutation(rebuildFlagMutation("done")) } } @@ -134,8 +139,12 @@ export function createDeveloperToolsController() { const sendUpdate = () => { if (!inAmicode()) return - setPending(true) - setStatus(undefined) + // Keep the stale status visible (dimmed by the UI via `pending`) instead + // of blanking it — clearing it here is what caused the validation + // flicker (#940): every path edit made the error/success indicator + // vanish and then snap back once the reply arrived. + const next = reduceDevToolsRequest({ status: status(), pending: pending() }, { type: "request-sent" }) + setPending(next.pending) window.parent.postMessage( { source: "amicode", @@ -153,13 +162,7 @@ export function createDeveloperToolsController() { if (rebuildState() === "rebuilding") return // prevent double-clicks setRebuildState("rebuilding") setRebuildError(undefined) - try { - localStorage.setItem("amicode:devtools-rebuilding", "1") - localStorage.setItem("amicode:devtools-reopen", "1") - localStorage.setItem("amicode:devtools-rebuilt", "1") - } catch { - // non-critical - } + applyRebuildFlagMutation(rebuildFlagMutation("start")) window.parent.postMessage( { source: "amicode", diff --git a/packages/app/src/components/settings-v2/developer-tools-rebuild-flags.test.ts b/packages/app/src/components/settings-v2/developer-tools-rebuild-flags.test.ts new file mode 100644 index 000000000..589729f7b --- /dev/null +++ b/packages/app/src/components/settings-v2/developer-tools-rebuild-flags.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test" +import { rebuildFlagMutation, type RebuildFlagEvent } from "./developer-tools-rebuild-flags" + +// ============================================================================ +// The devtools rebuild flag protocol (#940): localStorage flags survive +// iframe reloads mid-build (git checkout in a watched workspace triggers a +// reload), so the app's onMount can tell "still rebuilding" apart from +// "finished while you were away". The bug: rebuild() used to set the +// "rebuilt" flag at the SAME time as "rebuilding", so reopening the dialog +// mid-build showed "Rebuilt!" instead of "Rebuilding...". This tests the +// pure decision of what to set/clear at each lifecycle event, independent +// of the SolidJS signal wiring around it. +// ============================================================================ + +describe("rebuildFlagMutation", () => { + test("start sets rebuilding+reopen but never rebuilt", () => { + const mutation = rebuildFlagMutation("start") + expect(mutation.set).toEqual({ rebuilding: "1", reopen: "1" }) + expect(mutation.clear).toEqual([]) + // The exact regression: "rebuilt" must not appear in the start mutation. + expect("rebuilt" in mutation.set).toBe(false) + }) + + test("done clears rebuilding and sets rebuilt — the success signal onMount needs", () => { + const mutation = rebuildFlagMutation("done") + expect(mutation.clear).toEqual(["rebuilding"]) + expect(mutation.set).toEqual({ rebuilt: "1" }) + }) + + test("failed clears rebuilding without ever setting rebuilt", () => { + const mutation = rebuildFlagMutation("failed") + expect(mutation.clear).toEqual(["rebuilding"]) + expect(mutation.set).toEqual({}) + }) + + test("every event kind produces a defined mutation (exhaustiveness)", () => { + const events: RebuildFlagEvent[] = ["start", "done", "failed"] + for (const event of events) { + expect(rebuildFlagMutation(event)).toBeDefined() + } + }) +}) + +describe("rebuild flag lifecycle — localStorage integration", () => { + const KEY = (suffix: string) => `amicode:devtools-${suffix}` + + function applyMutation(mutation: ReturnType) { + for (const k of mutation.clear) localStorage.removeItem(KEY(k)) + for (const [k, v] of Object.entries(mutation.set)) localStorage.setItem(KEY(k), v) + } + + test("reopening mid-build (before 'done' arrives) shows rebuilding, not rebuilt", () => { + localStorage.clear() + applyMutation(rebuildFlagMutation("start")) + + // Simulate the dialog reopening mid-build: onMount reads flags directly. + const wasRebuilding = localStorage.getItem(KEY("rebuilding")) === "1" + const didFinish = localStorage.getItem(KEY("rebuilt")) === "1" + + expect(wasRebuilding).toBe(true) + expect(didFinish).toBe(false) // this is the exact bug this fix prevents + }) + + test("full successful lifecycle: start -> done -> reopen shows rebuilt", () => { + localStorage.clear() + applyMutation(rebuildFlagMutation("start")) + applyMutation(rebuildFlagMutation("done")) + + const wasRebuilding = localStorage.getItem(KEY("rebuilding")) === "1" + const didFinish = localStorage.getItem(KEY("rebuilt")) === "1" + + expect(wasRebuilding).toBe(false) + expect(didFinish).toBe(true) + }) + + test("failed lifecycle: start -> failed leaves no success flag behind", () => { + localStorage.clear() + applyMutation(rebuildFlagMutation("start")) + applyMutation(rebuildFlagMutation("failed")) + + expect(localStorage.getItem(KEY("rebuilding"))).toBeNull() + expect(localStorage.getItem(KEY("rebuilt"))).toBeNull() + }) +}) diff --git a/packages/app/src/components/settings-v2/developer-tools-rebuild-flags.ts b/packages/app/src/components/settings-v2/developer-tools-rebuild-flags.ts new file mode 100644 index 000000000..0d62799cf --- /dev/null +++ b/packages/app/src/components/settings-v2/developer-tools-rebuild-flags.ts @@ -0,0 +1,58 @@ +/** + * The devtools rebuild flag protocol. + * + * localStorage flags (amicode:devtools-rebuilding / -rebuilt / -reopen) + * survive iframe reloads that happen mid-rebuild (e.g. a git checkout + * inside a watched workspace folder during a remote rebuild). The + * controller's onMount reads them to tell "still rebuilding" apart from + * "finished while the dialog was closed". + * + * This module is the pure decision of what to set/clear at each lifecycle + * event, kept separate from the SolidJS signal wiring so the protocol + * itself is directly testable. The critical invariant it encodes: "start" + * must never set "rebuilt" — only "done" may, and only once the extension + * host has actually reported completion. + */ + +export type RebuildFlagEvent = "start" | "done" | "failed" + +export type RebuildFlagKey = "rebuilding" | "reopen" | "rebuilt" + +export interface RebuildFlagMutation { + /** Flags to set to "1". */ + set: Partial> + /** Flags to remove. */ + clear: RebuildFlagKey[] +} + +export function rebuildFlagMutation(event: RebuildFlagEvent): RebuildFlagMutation { + switch (event) { + case "start": + // Rebuild kicks off: mark it in progress and ask the app to reopen + // settings at the devtools section after any reload. Do NOT set + // "rebuilt" here — that was the bug (#940): it made a mid-build + // dialog reopen show "Rebuilt!" instead of "Rebuilding...". + return { set: { rebuilding: "1", reopen: "1" }, clear: [] } + case "done": + // Extension host reported success: clear the in-progress flag and + // set the success flag onMount needs to show "Rebuilt!" after reload. + return { set: { rebuilt: "1" }, clear: ["rebuilding"] } + case "failed": + // Extension host reported failure: clear in-progress, no success flag. + return { set: {}, clear: ["rebuilding"] } + } +} + +const STORAGE_PREFIX = "amicode:devtools-" + +/** Apply a mutation to localStorage. Swallows errors (storage may be unavailable). */ +export function applyRebuildFlagMutation(mutation: RebuildFlagMutation): void { + try { + for (const key of mutation.clear) localStorage.removeItem(STORAGE_PREFIX + key) + for (const [key, value] of Object.entries(mutation.set)) { + if (value) localStorage.setItem(STORAGE_PREFIX + key, value) + } + } catch { + // non-critical + } +} diff --git a/packages/app/src/components/settings-v2/developer-tools-request-state.test.ts b/packages/app/src/components/settings-v2/developer-tools-request-state.test.ts new file mode 100644 index 000000000..df5efa397 --- /dev/null +++ b/packages/app/src/components/settings-v2/developer-tools-request-state.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test" +import { reduceDevToolsRequest, type DevToolsRequestState } from "./developer-tools-request-state" +import type { DevToolsStatus } from "./developer-tools-controller" + +// ============================================================================ +// The devtools path-validation flicker (#940): sendUpdate() used to clear +// `status` to undefined the instant a validation round-trip started, so any +// visible error/success indicator vanished and then snapped back when the +// reply arrived — a visible flash on every path edit. This models the +// request lifecycle as a pure reducer so the "don't blank the status while +// a request is in flight" invariant is directly testable. +// ============================================================================ + +const sampleStatus: DevToolsStatus = { + opencodeValid: false, + opencodeError: "Binary not found at this path", + amicodeValid: true, + serverRestarted: false, + reloadNeeded: false, +} + +const idle: DevToolsRequestState = { status: undefined, pending: false } + +describe("reduceDevToolsRequest", () => { + test("request-sent while idle marks pending without inventing a status", () => { + const next = reduceDevToolsRequest(idle, { type: "request-sent" }) + expect(next).toEqual({ status: undefined, pending: true }) + }) + + test("status-received clears pending and sets the new status", () => { + const sent = reduceDevToolsRequest(idle, { type: "request-sent" }) + const received = reduceDevToolsRequest(sent, { type: "status-received", status: sampleStatus }) + expect(received.pending).toBe(false) + expect(received.status).toEqual(sampleStatus) + }) + + test("REGRESSION: a second request-sent must keep the STALE status visible, not blank it", () => { + // First round-trip already completed and produced an error. + const afterFirst: DevToolsRequestState = { status: sampleStatus, pending: false } + + // User edits the path again — a new request goes out. + const midSecondRequest = reduceDevToolsRequest(afterFirst, { type: "request-sent" }) + + // This is the exact bug: status must stay visible (not undefined) while + // the second round-trip is in flight. Only `pending` should flip. + expect(midSecondRequest.status).toEqual(sampleStatus) + expect(midSecondRequest.pending).toBe(true) + }) + + test("the second round-trip's reply replaces the stale status once it lands", () => { + const afterFirst: DevToolsRequestState = { status: sampleStatus, pending: false } + const midSecondRequest = reduceDevToolsRequest(afterFirst, { type: "request-sent" }) + const newStatus: DevToolsStatus = { ...sampleStatus, opencodeValid: true, opencodeError: undefined } + const afterSecond = reduceDevToolsRequest(midSecondRequest, { type: "status-received", status: newStatus }) + expect(afterSecond.status).toEqual(newStatus) + expect(afterSecond.pending).toBe(false) + }) +}) diff --git a/packages/app/src/components/settings-v2/developer-tools-request-state.ts b/packages/app/src/components/settings-v2/developer-tools-request-state.ts new file mode 100644 index 000000000..56081102a --- /dev/null +++ b/packages/app/src/components/settings-v2/developer-tools-request-state.ts @@ -0,0 +1,35 @@ +import type { DevToolsStatus } from "./developer-tools-controller" + +/** + * The devtools path-validation request lifecycle, as a pure reducer. + * + * Kept separate from the SolidJS signal wiring so the exact invariant that + * was wrong (#940) is directly testable: starting a new validation + * round-trip must NOT blank the currently-visible status. The old status + * (an error or a success indicator) stays on screen — dimmed by the UI via + * `pending` — until the new reply actually arrives. Clearing it eagerly is + * what produced the flicker: every path edit made the indicator vanish and + * then snap back a moment later. + */ + +export interface DevToolsRequestState { + status: DevToolsStatus | undefined + pending: boolean +} + +export type DevToolsRequestEvent = + | { type: "request-sent" } + | { type: "status-received"; status: DevToolsStatus } + +export function reduceDevToolsRequest( + state: DevToolsRequestState, + event: DevToolsRequestEvent, +): DevToolsRequestState { + switch (event.type) { + case "request-sent": + // Keep the stale status visible; only the pending flag changes. + return { status: state.status, pending: true } + case "status-received": + return { status: event.status, pending: false } + } +} diff --git a/packages/app/src/components/settings-v2/developer-tools.tsx b/packages/app/src/components/settings-v2/developer-tools.tsx index 904244708..df62b0216 100644 --- a/packages/app/src/components/settings-v2/developer-tools.tsx +++ b/packages/app/src/components/settings-v2/developer-tools.tsx @@ -48,12 +48,11 @@ const DeveloperToolsContent: Component<{ controller: DeveloperToolsController }> 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" + const validating = () => props.controller.pending() return ( @@ -135,7 +134,12 @@ const DeveloperToolsContent: Component<{ controller: DeveloperToolsController }> <> {language.t("settings.general.row.opencodePath.description")} - {opencodeError()} + + {opencodeError()} + + + + Validating… } @@ -163,13 +167,10 @@ const DeveloperToolsContent: Component<{ controller: DeveloperToolsController }> description={ <> {language.t("settings.general.row.amicodePath.description")} - - - {language.t("settings.general.row.amicodePath.building")} - - - {amicodeError()} + + {amicodeError()} + 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 bad2b965f..2b2a30407 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -149,7 +149,7 @@ export const DialogSettings: Component<{ - + diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index b7e11ba9e..c83751273 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -14,13 +14,11 @@ import { SettingsRowV2 } from "./parts/row" import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition" import { createAppearanceSettingsController, - createPermissionScopeController, createShellOptions, createShellSettingsController, createSoundSettingsController, soundOptions, type AppearanceSettingsController, - type PermissionScopeController, type ShellSettingsController, type SoundSettingsController, } from "./general-controllers" @@ -70,24 +68,6 @@ const soundSettings = { }, } as const -const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => { - const language = useLanguage() - return ( - -
- -
-
- ) -} - const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => { const language = useLanguage() const options = createMemo(() => @@ -257,7 +237,6 @@ export const SettingsGeneralV2: Component<{ const settings = useSettings() const mobile = createMediaQuery("(max-width: 767px)") const updater = useUpdaterAction() - const permissionScope = createPermissionScopeController(() => props.sessionID) const shell = createShellSettingsController() const appearance = createAppearanceSettingsController() const sounds = createSoundSettingsController() @@ -306,8 +285,6 @@ export const SettingsGeneralV2: Component<{ - - readFileSync(join(here, "general.tsx"), "utf8") +const permissionsSrc = () => readFileSync(join(here, "permissions.tsx"), "utf8") +const dialogSrc = () => readFileSync(join(here, "dialog-settings-v2.tsx"), "utf8") + +/** Extract the JSX block for a given TabsV2.Content value, for a scoped assertion. */ +function extractTabContent(src: string, tabValue: string): string { + const start = src.indexOf(`", start) + return src.slice(start, end) +} + +describe("auto-accept permissions toggle relocation (#940)", () => { + test("general.tsx no longer defines or renders PermissionScopeSetting", () => { + expect(generalSrc()).not.toContain("PermissionScopeSetting") + }) + + test("general.tsx no longer creates a permission-scope controller", () => { + expect(generalSrc()).not.toContain("createPermissionScopeController") + }) + + test("permissions.tsx defines and renders PermissionScopeSetting", () => { + const src = permissionsSrc() + expect(src).toContain("PermissionScopeSetting") + expect(src).toContain("createPermissionScopeController") + }) + + test("the dialog shell threads sessionID into the Permissions tab", () => { + const permissionsTab = extractTabContent(dialogSrc(), "permissions") + expect(permissionsTab).toContain("SettingsPermissionsV2") + expect(permissionsTab).toContain("sessionID") + }) +}) diff --git a/packages/app/src/components/settings-v2/permissions.tsx b/packages/app/src/components/settings-v2/permissions.tsx index 61dadf1f3..36ef39cf7 100644 --- a/packages/app/src/components/settings-v2/permissions.tsx +++ b/packages/app/src/components/settings-v2/permissions.tsx @@ -2,11 +2,15 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { Tag } from "@opencode-ai/ui/v2/badge-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" +import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { showToast } from "@/utils/toast" import { createMemo, createSignal, For, Show, type Component } from "solid-js" import { useLanguage } from "@/context/language" import { useServerSync } from "@/context/server-sync" import { useModels } from "@/context/models" +import { SettingsListV2 } from "./parts/list" +import { SettingsRowV2 } from "./parts/row" +import { createPermissionScopeController, type PermissionScopeController } from "./general-controllers" import "./settings-v2.css" type Effect = "allow" | "deny" | "ask" @@ -57,10 +61,30 @@ function badgeVariant(summary: string): "danger" | "warning" | "neutral" | "info return "warning" } -export const SettingsPermissionsV2: Component = () => { +/** Moved here from the General tab (#940) — auto-accept is a permissions control. */ +const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => { + const language = useLanguage() + return ( + +
+ +
+
+ ) +} + +export const SettingsPermissionsV2: Component<{ sessionID?: string }> = (props) => { const language = useLanguage() const serverSync = useServerSync() const modelsCtx = useModels() + const permissionScope = createPermissionScopeController(() => props.sessionID) const rawConfig = createMemo(() => { const raw = (serverSync().data.config as Record).providerPermissions as ProviderPermissionsConfig | undefined @@ -218,6 +242,10 @@ export const SettingsPermissionsV2: Component = () => {
+ + + + {(tier) => { const summary = () => tierSummary(tier) diff --git a/packages/app/src/components/settings-v2/settings-v2.css b/packages/app/src/components/settings-v2/settings-v2.css index 0d9eec2ec..6b22f7c21 100644 --- a/packages/app/src/components/settings-v2/settings-v2.css +++ b/packages/app/src/components/settings-v2/settings-v2.css @@ -1122,3 +1122,17 @@ button.settings-v2-permissions-matrix-pattern:focus-visible { font-size: 12px; color: var(--v2-text-text-warning, #d29922); } + +.settings-v2-field-info { + display: block; + margin-top: 4px; + font-size: 12px; + color: var(--v2-text-text-muted); +} + +/* Applied to a stale error/warning while a new validation round-trip is in + flight (#940) — dims the previous result instead of blanking it, so path + edits don't flash empty-then-filled on every keystroke's blur. */ +.settings-v2-field-stale { + opacity: 0.55; +} diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 9de1b4365..852d87943 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -771,10 +771,9 @@ export const dict = { "settings.general.row.amicodePath.placeholder": "~/harmoniqs/amicode", "settings.general.row.amicodePath.error.notFound": "Directory does not exist", "settings.general.row.amicodePath.error.buildFailed": "Extension build failed", - "settings.general.row.amicodePath.building": "Building extension…", "settings.general.row.amicodePath.reloadNeeded": "Reload to apply", "settings.general.row.devTools.rebuildLocally": "Rebuild Locally", - "settings.general.row.devTools.rebuildRemotely": "Rebuild from Latest", + "settings.general.row.devTools.rebuildRemotely": "Rebuild from Main", "settings.general.row.devTools.rebuilding": "Rebuilding…", "settings.general.row.devTools.rebuilt": "Rebuilt!", "settings.general.row.releaseNotes.title": "ملاحظات الإصدار", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index b18c9d90d..aec087e34 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -783,10 +783,9 @@ export const dict = { "settings.general.row.amicodePath.placeholder": "~/harmoniqs/amicode", "settings.general.row.amicodePath.error.notFound": "Directory does not exist", "settings.general.row.amicodePath.error.buildFailed": "Extension build failed", - "settings.general.row.amicodePath.building": "Building extension…", "settings.general.row.amicodePath.reloadNeeded": "Reload to apply", "settings.general.row.devTools.rebuildLocally": "Rebuild Locally", - "settings.general.row.devTools.rebuildRemotely": "Rebuild from Latest", + "settings.general.row.devTools.rebuildRemotely": "Rebuild from Main", "settings.general.row.devTools.rebuilding": "Rebuilding…", "settings.general.row.devTools.rebuilt": "Rebuilt!", "settings.general.row.releaseNotes.title": "Notas da versão", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 84cac1b09..4f6d62e74 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -849,10 +849,9 @@ export const dict = { "settings.general.row.amicodePath.placeholder": "~/harmoniqs/amicode", "settings.general.row.amicodePath.error.notFound": "Directory does not exist", "settings.general.row.amicodePath.error.buildFailed": "Extension build failed", - "settings.general.row.amicodePath.building": "Building extension…", "settings.general.row.amicodePath.reloadNeeded": "Reload to apply", "settings.general.row.devTools.rebuildLocally": "Rebuild Locally", - "settings.general.row.devTools.rebuildRemotely": "Rebuild from Latest", + "settings.general.row.devTools.rebuildRemotely": "Rebuild from Main", "settings.general.row.devTools.rebuilding": "Rebuilding…", "settings.general.row.devTools.rebuilt": "Rebuilt!", "settings.general.row.releaseNotes.title": "Bilješke o izdanju", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index b7fbf543a..016545ed9 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -841,10 +841,9 @@ export const dict = { "settings.general.row.amicodePath.placeholder": "~/harmoniqs/amicode", "settings.general.row.amicodePath.error.notFound": "Directory does not exist", "settings.general.row.amicodePath.error.buildFailed": "Extension build failed", - "settings.general.row.amicodePath.building": "Building extension…", "settings.general.row.amicodePath.reloadNeeded": "Reload to apply", "settings.general.row.devTools.rebuildLocally": "Rebuild Locally", - "settings.general.row.devTools.rebuildRemotely": "Rebuild from Latest", + "settings.general.row.devTools.rebuildRemotely": "Rebuild from Main", "settings.general.row.devTools.rebuilding": "Rebuilding…", "settings.general.row.devTools.rebuilt": "Rebuilt!", "settings.general.row.releaseNotes.title": "Udgivelsesnoter", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index b455c97b4..d24991183 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -795,10 +795,9 @@ export const dict = { "settings.general.row.amicodePath.placeholder": "~/harmoniqs/amicode", "settings.general.row.amicodePath.error.notFound": "Directory does not exist", "settings.general.row.amicodePath.error.buildFailed": "Extension build failed", - "settings.general.row.amicodePath.building": "Building extension…", "settings.general.row.amicodePath.reloadNeeded": "Reload to apply", "settings.general.row.devTools.rebuildLocally": "Rebuild Locally", - "settings.general.row.devTools.rebuildRemotely": "Rebuild from Latest", + "settings.general.row.devTools.rebuildRemotely": "Rebuild from Main", "settings.general.row.devTools.rebuilding": "Rebuilding…", "settings.general.row.devTools.rebuilt": "Rebuilt!", "settings.general.row.releaseNotes.title": "Versionshinweise", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 32dcd9b0e..c1fc23190 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -1027,10 +1027,9 @@ export const dict = { "settings.general.row.amicodePath.placeholder": "~/harmoniqs/amicode", "settings.general.row.amicodePath.error.notFound": "Directory does not exist", "settings.general.row.amicodePath.error.buildFailed": "Extension build failed", - "settings.general.row.amicodePath.building": "Building extension…", "settings.general.row.amicodePath.reloadNeeded": "Reload to apply", "settings.general.row.devTools.rebuildLocally": "Rebuild Locally", - "settings.general.row.devTools.rebuildRemotely": "Rebuild from Latest", + "settings.general.row.devTools.rebuildRemotely": "Rebuild from Main", "settings.general.row.devTools.rebuilding": "Rebuilding…", "settings.general.row.devTools.rebuilt": "Rebuilt!", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 0cf7ee55a..3af98784f 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -854,10 +854,9 @@ export const dict = { "settings.general.row.amicodePath.placeholder": "~/harmoniqs/amicode", "settings.general.row.amicodePath.error.notFound": "Directory does not exist", "settings.general.row.amicodePath.error.buildFailed": "Extension build failed", - "settings.general.row.amicodePath.building": "Building extension…", "settings.general.row.amicodePath.reloadNeeded": "Reload to apply", "settings.general.row.devTools.rebuildLocally": "Rebuild Locally", - "settings.general.row.devTools.rebuildRemotely": "Rebuild from Latest", + "settings.general.row.devTools.rebuildRemotely": "Rebuild from Main", "settings.general.row.devTools.rebuilding": "Rebuilding…", "settings.general.row.devTools.rebuilt": "Rebuilt!", "settings.general.row.releaseNotes.title": "Notas de la versión", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 97fc99a32..dd9717c78 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -793,10 +793,9 @@ export const dict = { "settings.general.row.amicodePath.placeholder": "~/harmoniqs/amicode", "settings.general.row.amicodePath.error.notFound": "Directory does not exist", "settings.general.row.amicodePath.error.buildFailed": "Extension build failed", - "settings.general.row.amicodePath.building": "Building extension…", "settings.general.row.amicodePath.reloadNeeded": "Reload to apply", "settings.general.row.devTools.rebuildLocally": "Rebuild Locally", - "settings.general.row.devTools.rebuildRemotely": "Rebuild from Latest", + "settings.general.row.devTools.rebuildRemotely": "Rebuild from Main", "settings.general.row.devTools.rebuilding": "Rebuilding…", "settings.general.row.devTools.rebuilt": "Rebuilt!", "settings.general.row.releaseNotes.title": "Notes de version", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index a83bb488b..8d576b728 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -778,10 +778,9 @@ export const dict = { "settings.general.row.amicodePath.placeholder": "~/harmoniqs/amicode", "settings.general.row.amicodePath.error.notFound": "Directory does not exist", "settings.general.row.amicodePath.error.buildFailed": "Extension build failed", - "settings.general.row.amicodePath.building": "Building extension…", "settings.general.row.amicodePath.reloadNeeded": "Reload to apply", "settings.general.row.devTools.rebuildLocally": "Rebuild Locally", - "settings.general.row.devTools.rebuildRemotely": "Rebuild from Latest", + "settings.general.row.devTools.rebuildRemotely": "Rebuild from Main", "settings.general.row.devTools.rebuilding": "Rebuilding…", "settings.general.row.devTools.rebuilt": "Rebuilt!", "settings.general.row.releaseNotes.title": "リリースノート", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 136daba17..536d73a77 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -620,10 +620,9 @@ export const dict = { "settings.general.row.amicodePath.placeholder": "~/harmoniqs/amicode", "settings.general.row.amicodePath.error.notFound": "Directory does not exist", "settings.general.row.amicodePath.error.buildFailed": "Extension build failed", - "settings.general.row.amicodePath.building": "Building extension…", "settings.general.row.amicodePath.reloadNeeded": "Reload to apply", "settings.general.row.devTools.rebuildLocally": "Rebuild Locally", - "settings.general.row.devTools.rebuildRemotely": "Rebuild from Latest", + "settings.general.row.devTools.rebuildRemotely": "Rebuild from Main", "settings.general.row.devTools.rebuilding": "Rebuilding…", "settings.general.row.devTools.rebuilt": "Rebuilt!", "settings.general.row.releaseNotes.title": "릴리스 노트", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 74fb5d19c..989408b4c 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -694,10 +694,9 @@ export const dict = { "settings.general.row.amicodePath.placeholder": "~/harmoniqs/amicode", "settings.general.row.amicodePath.error.notFound": "Directory does not exist", "settings.general.row.amicodePath.error.buildFailed": "Extension build failed", - "settings.general.row.amicodePath.building": "Building extension…", "settings.general.row.amicodePath.reloadNeeded": "Reload to apply", "settings.general.row.devTools.rebuildLocally": "Rebuild Locally", - "settings.general.row.devTools.rebuildRemotely": "Rebuild from Latest", + "settings.general.row.devTools.rebuildRemotely": "Rebuild from Main", "settings.general.row.devTools.rebuilding": "Rebuilding…", "settings.general.row.devTools.rebuilt": "Rebuilt!", "settings.general.row.releaseNotes.title": "Utgivelsesnotater", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 9a0a15b02..5a5b4f3b8 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -783,10 +783,9 @@ export const dict = { "settings.general.row.amicodePath.placeholder": "~/harmoniqs/amicode", "settings.general.row.amicodePath.error.notFound": "Directory does not exist", "settings.general.row.amicodePath.error.buildFailed": "Extension build failed", - "settings.general.row.amicodePath.building": "Building extension…", "settings.general.row.amicodePath.reloadNeeded": "Reload to apply", "settings.general.row.devTools.rebuildLocally": "Rebuild Locally", - "settings.general.row.devTools.rebuildRemotely": "Rebuild from Latest", + "settings.general.row.devTools.rebuildRemotely": "Rebuild from Main", "settings.general.row.devTools.rebuilding": "Rebuilding…", "settings.general.row.devTools.rebuilt": "Rebuilt!", "settings.general.row.releaseNotes.title": "Informacje o wydaniu", diff --git a/packages/app/src/i18n/rebuild-label-rename.test.ts b/packages/app/src/i18n/rebuild-label-rename.test.ts new file mode 100644 index 000000000..6b9d0bf04 --- /dev/null +++ b/packages/app/src/i18n/rebuild-label-rename.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { readdirSync, readFileSync } from "node:fs" +import { join, dirname } from "node:path" +import { fileURLToPath } from "node:url" + +// ============================================================================ +// The devtools "rebuild from remote" button label was renamed away from its +// old wording (#940) — the old label was inaccurate (the remote rebuild +// pulls different tracked branches for the opencode vs. amicode repos, not +// "latest" in any single sense). This regression test guards against a +// partial rename: every locale's key must carry the new label, and no +// stale reference (including code comments) may remain anywhere in the +// app package's source tree. +// ============================================================================ + +const OLD_LABEL = ["Rebuild", "from", "Latest"].join(" ") +const NEW_LABEL = "Rebuild from Main" + +const i18nDir = dirname(fileURLToPath(import.meta.url)) +const appSrcDir = join(i18nDir, "..") +const selfFile = fileURLToPath(import.meta.url) + +function walkTsFiles(dir: string): string[] { + const out: string[] = [] + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name.startsWith(".")) continue + const full = join(dir, entry.name) + if (entry.isDirectory()) { + out.push(...walkTsFiles(full)) + } else if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx"))) { + out.push(full) + } + } + return out +} + +describe("devtools rebuild-remotely label rename (#940)", () => { + test("no source file under the app package still carries the old label", () => { + const offenders = walkTsFiles(appSrcDir).filter( + (file) => file !== selfFile && readFileSync(file, "utf8").includes(OLD_LABEL), + ) + expect(offenders).toEqual([]) + }) + + test("en.ts carries the new label for the rebuildRemotely key", async () => { + const module: unknown = await import("./en") + if (typeof module !== "object" || module === null || !("dict" in module)) { + throw new Error("Invalid en.ts dictionary module") + } + const dict = (module as { dict: Record }).dict + expect(dict["settings.general.row.devTools.rebuildRemotely"]).toBe(NEW_LABEL) + }) +}) diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 83257d76a..00288f892 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -850,10 +850,9 @@ export const dict = { "settings.general.row.amicodePath.placeholder": "~/harmoniqs/amicode", "settings.general.row.amicodePath.error.notFound": "Directory does not exist", "settings.general.row.amicodePath.error.buildFailed": "Extension build failed", - "settings.general.row.amicodePath.building": "Building extension…", "settings.general.row.amicodePath.reloadNeeded": "Reload to apply", "settings.general.row.devTools.rebuildLocally": "Rebuild Locally", - "settings.general.row.devTools.rebuildRemotely": "Rebuild from Latest", + "settings.general.row.devTools.rebuildRemotely": "Rebuild from Main", "settings.general.row.devTools.rebuilding": "Rebuilding…", "settings.general.row.devTools.rebuilt": "Rebuilt!", "settings.general.row.releaseNotes.title": "Примечания к выпуску", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 64c3bfba9..f0ec920a0 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -837,10 +837,9 @@ export const dict = { "settings.general.row.amicodePath.placeholder": "~/harmoniqs/amicode", "settings.general.row.amicodePath.error.notFound": "Directory does not exist", "settings.general.row.amicodePath.error.buildFailed": "Extension build failed", - "settings.general.row.amicodePath.building": "Building extension…", "settings.general.row.amicodePath.reloadNeeded": "Reload to apply", "settings.general.row.devTools.rebuildLocally": "Rebuild Locally", - "settings.general.row.devTools.rebuildRemotely": "Rebuild from Latest", + "settings.general.row.devTools.rebuildRemotely": "Rebuild from Main", "settings.general.row.devTools.rebuilding": "Rebuilding…", "settings.general.row.devTools.rebuilt": "Rebuilt!", "settings.general.row.releaseNotes.title": "บันทึกการอัปเดต", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index cf0359f9a..9a3007238 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -856,10 +856,9 @@ export const dict = { "settings.general.row.amicodePath.placeholder": "~/harmoniqs/amicode", "settings.general.row.amicodePath.error.notFound": "Directory does not exist", "settings.general.row.amicodePath.error.buildFailed": "Extension build failed", - "settings.general.row.amicodePath.building": "Building extension…", "settings.general.row.amicodePath.reloadNeeded": "Reload to apply", "settings.general.row.devTools.rebuildLocally": "Rebuild Locally", - "settings.general.row.devTools.rebuildRemotely": "Rebuild from Latest", + "settings.general.row.devTools.rebuildRemotely": "Rebuild from Main", "settings.general.row.devTools.rebuilding": "Rebuilding…", "settings.general.row.devTools.rebuilt": "Rebuilt!", "settings.general.row.releaseNotes.title": "Sürüm notları", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index b41531c2c..97a915d85 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -943,10 +943,9 @@ export const dict = { "settings.general.row.amicodePath.placeholder": "~/harmoniqs/amicode", "settings.general.row.amicodePath.error.notFound": "Directory does not exist", "settings.general.row.amicodePath.error.buildFailed": "Extension build failed", - "settings.general.row.amicodePath.building": "Building extension…", "settings.general.row.amicodePath.reloadNeeded": "Reload to apply", "settings.general.row.devTools.rebuildLocally": "Rebuild Locally", - "settings.general.row.devTools.rebuildRemotely": "Rebuild from Latest", + "settings.general.row.devTools.rebuildRemotely": "Rebuild from Main", "settings.general.row.devTools.rebuilding": "Rebuilding…", "settings.general.row.devTools.rebuilt": "Rebuilt!", "settings.general.row.releaseNotes.title": "Нотатки до релізу", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 8bd203429..f500159c0 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -830,10 +830,9 @@ export const dict = { "settings.general.row.amicodePath.placeholder": "~/harmoniqs/amicode", "settings.general.row.amicodePath.error.notFound": "Directory does not exist", "settings.general.row.amicodePath.error.buildFailed": "Extension build failed", - "settings.general.row.amicodePath.building": "Building extension…", "settings.general.row.amicodePath.reloadNeeded": "Reload to apply", "settings.general.row.devTools.rebuildLocally": "Rebuild Locally", - "settings.general.row.devTools.rebuildRemotely": "Rebuild from Latest", + "settings.general.row.devTools.rebuildRemotely": "Rebuild from Main", "settings.general.row.devTools.rebuilding": "Rebuilding…", "settings.general.row.devTools.rebuilt": "Rebuilt!", "settings.general.row.releaseNotes.title": "发行说明", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 4ff353bfa..b6141ea9e 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -827,10 +827,9 @@ export const dict = { "settings.general.row.amicodePath.placeholder": "~/harmoniqs/amicode", "settings.general.row.amicodePath.error.notFound": "Directory does not exist", "settings.general.row.amicodePath.error.buildFailed": "Extension build failed", - "settings.general.row.amicodePath.building": "Building extension…", "settings.general.row.amicodePath.reloadNeeded": "Reload to apply", "settings.general.row.devTools.rebuildLocally": "Rebuild Locally", - "settings.general.row.devTools.rebuildRemotely": "Rebuild from Latest", + "settings.general.row.devTools.rebuildRemotely": "Rebuild from Main", "settings.general.row.devTools.rebuilding": "Rebuilding…", "settings.general.row.devTools.rebuilt": "Rebuilt!", "settings.general.row.releaseNotes.title": "發行說明", diff --git a/packages/app/src/utils/amicode-developer.ts b/packages/app/src/utils/amicode-developer.ts index 74978683a..dfba15580 100644 --- a/packages/app/src/utils/amicode-developer.ts +++ b/packages/app/src/utils/amicode-developer.ts @@ -1,6 +1,6 @@ // amicode developer-mode boot flag: the extension passes // `amicode_developer=1` in the iframe URL when `devAssetRoot` is configured -// (i.e. the user ran "Rebuild from Latest"). The app reads it once at boot +// (i.e. the user ran "Rebuild from Main"). The app reads it once at boot // (same convention as amicode-bug-report / amicode-hide-project) and uses it // to auto-enable settings.developer.enabled — without this, ephemeral ports // rotate the localStorage origin on every reload and the developer toggle diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css index a6f6831cc..034dd36f2 100644 --- a/packages/ui/src/amicode/amicode.css +++ b/packages/ui/src/amicode/amicode.css @@ -1468,10 +1468,18 @@ /* ---- Developer Tools rebuild status + buttons ─────────────────────────── */ .devtools-section-header { display: flex; - align-items: center; + align-items: baseline; gap: 12px; } +/* .settings-v2-section-title carries a padding-bottom sized for sitting + above a settings list; inside this flex row it throws off centering + against the rebuild status text beside it. Same override as the + .settings-v2-providers / .settings-v2-models section titles. */ +.devtools-section-header .settings-v2-section-title { + padding-bottom: 0; +} + .devtools-rebuild-status { display: flex; align-items: center; diff --git a/packages/ui/src/amicode/devtools-header-alignment.test.ts b/packages/ui/src/amicode/devtools-header-alignment.test.ts new file mode 100644 index 000000000..4318b66d4 --- /dev/null +++ b/packages/ui/src/amicode/devtools-header-alignment.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from "node:fs" +import { describe, expect, test } from "bun:test" + +// ============================================================================ +// The devtools "Developer Tools" section header and its rebuild status +// indicator (#940) sit side by side in a flex row. They use different font +// sizes, so align-items: center centers their line boxes but puts the status +// text's baseline slightly below the title's. Baseline alignment fixes that; +// .settings-v2-section-title also carries a padding-bottom meant for the +// normal case where a title sits above a settings list, so it must be reset +// in this row. The existing +// .settings-v2-providers and .settings-v2-models sections already override +// this padding to 0 for the same reason; this extends that same override to +// the devtools header. +// ============================================================================ + +function extractRule(css: string, selector: string): string { + const start = css.indexOf(selector) + if (start === -1) throw new Error(`selector "${selector}" not found`) + const braceOpen = css.indexOf("{", start) + const braceClose = css.indexOf("}", braceOpen) + return css.slice(start, braceClose + 1) +} + +describe("devtools section header title alignment (#940)", () => { + test("the header aligns the title and status text on their shared baseline", () => { + const css = readFileSync(new URL("./amicode.css", import.meta.url), "utf8") + const rule = extractRule(css, ".devtools-section-header {") + expect(rule).toContain("align-items: baseline") + }) + + test("the title's padding-bottom is zeroed out inside the devtools header, matching the providers/models precedent", () => { + const css = readFileSync(new URL("./amicode.css", import.meta.url), "utf8") + const rule = extractRule(css, ".devtools-section-header .settings-v2-section-title") + expect(rule).toMatch(/padding-bottom:\s*0/) + }) +})