From c0ae9fd8261cd5ee73774603770463661ba96391 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Wed, 26 Aug 2026 18:05:33 +0900 Subject: [PATCH 01/10] feat(gui): Codex Auth becomes Codex Set, with a working Prompt panel The page now configures Codex as a whole rather than only its accounts. Two exclusive tabpanels shaped like Logs/Debug: Multi-auth is today's content moved verbatim, Prompt is new. The choice lives in the hash, so refresh, bookmark, and back-forward keep it, and Prompt lazy-mounts on first visit then stays mounted - Multi-auth polls on a 30s timer that has no business running while someone is editing prompts. The route id changed and a shipped URL did not, so #codex-auth resolves to codex-set through both the hash reader and the passive redirect. use-app-route-state reads the initial page straight from the hash, so without that entry an old bookmark lands on an unknown page. The backend namespace /api/codex-auth/* does not move; a dozen test files bind to it and renaming it buys nothing. The session cache key stays too, for the same reason. The Prompt panel ships working, not as a placeholder: five config-toggle rows rendered from the live inventory, each PUTting with the snapshot revision and republishing the echoed snapshot. A stale-revision 409 re-reads rather than retrying blindly, because a retry would overwrite whatever moved the file, and a rejected write reverts to server truth rather than leaving a switch showing a state the file does not have. The other four layer classes are absent, not stubbed. A panel that renders half a taxonomy invites a reader to assume the rest does not exist; WP4 adds them with the dialog. The timing copy is pinned by a test: new sessions, never 'immediately' and never 'restart required'. Neither is proven, and the frontend reload path is UNKNOWN upstream, so a later copy edit cannot quietly promise something the runtime does not do. All nine locales carry the codexSet.* keys - the plan said six, the repository has nine, and Record makes a gap a typecheck failure rather than a runtime blank. devlog: 260802_codex_set_prompt_composer/030 --- gui/src/App.tsx | 8 +- gui/src/app-routing.ts | 15 +- .../provider-catalog/ProviderCatalog.tsx | 2 +- gui/src/i18n/de.ts | 15 +- gui/src/i18n/en.ts | 15 +- gui/src/i18n/fr.ts | 15 +- gui/src/i18n/ja.ts | 15 +- gui/src/i18n/ko.ts | 15 +- gui/src/i18n/ru.ts | 15 +- gui/src/i18n/tr.ts | 15 +- gui/src/i18n/zh-TW.ts | 15 +- gui/src/i18n/zh.ts | 15 +- gui/src/pages/CodexSet.tsx | 84 ++++++ ...{CodexAuth.tsx => codex-set-multiauth.tsx} | 15 +- gui/src/pages/codex-set-prompt.tsx | 179 ++++++++++++ gui/src/pages/codex-set-tab.ts | 30 ++ gui/src/pages/providers-page-utils.ts | 2 +- ...tsx => codex-set-provider-enable.test.tsx} | 2 +- ...> codex-set-recovery-interaction.test.tsx} | 6 +- gui/tests/codex-set-shell.test.tsx | 267 ++++++++++++++++++ gui/tests/dashboard-tabs.test.ts | 4 +- gui/tests/page-loading-contract.test.tsx | 1 + ...auth.test.ts => sidebar-codex-set.test.ts} | 17 +- gui/tests/sidebar-rows.test.ts | 2 +- 24 files changed, 738 insertions(+), 31 deletions(-) create mode 100644 gui/src/pages/CodexSet.tsx rename gui/src/pages/{CodexAuth.tsx => codex-set-multiauth.tsx} (92%) create mode 100644 gui/src/pages/codex-set-prompt.tsx create mode 100644 gui/src/pages/codex-set-tab.ts rename gui/tests/{codex-auth-provider-enable.test.tsx => codex-set-provider-enable.test.tsx} (96%) rename gui/tests/{codex-auth-recovery-interaction.test.tsx => codex-set-recovery-interaction.test.tsx} (97%) create mode 100644 gui/tests/codex-set-shell.test.tsx rename gui/tests/{sidebar-codex-auth.test.ts => sidebar-codex-set.test.ts} (56%) diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 2e53a362a21..73648675e84 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -7,7 +7,7 @@ import Subagents from "./pages/Subagents"; import Logs from "./pages/Logs"; import Usage from "./pages/Usage"; import Storage from "./pages/Storage"; -import CodexAuth from "./pages/CodexAuth"; +import CodexSet from "./pages/CodexSet"; import Integrations from "./pages/Integrations"; import Startup from "./pages/Startup"; import ErrorBoundary from "./components/ErrorBoundary"; @@ -35,7 +35,7 @@ const PAGE_TKEY: Record = { logs: "nav.logs", usage: "nav.usage", storage: "nav.storage", - "codex-auth": "nav.codexAuth", + "codex-set": "nav.codexSet", integrations: "nav.integrations", }; @@ -58,7 +58,7 @@ type NavEntry = { const NAV: NavEntry[] = [ { id: "dashboard", tkey: "nav.dashboard", Icon: IconGrid }, - { id: "codex-auth", tkey: "nav.codexAuth", Icon: IconKey }, + { id: "codex-set", tkey: "nav.codexSet", Icon: IconKey }, { id: "providers", tkey: "nav.providers", Icon: IconServer }, { id: "models", tkey: "nav.models", Icon: IconBoxes }, { id: "subagents", tkey: "nav.subagents", Icon: IconBot }, @@ -336,7 +336,7 @@ export default function App() { {page === "logs" && } {page === "usage" && } {page === "storage" && } - {page === "codex-auth" && } + {page === "codex-set" && } {page === "integrations" && } diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index be8fa2a84b8..7eb44e2091a 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -11,7 +11,7 @@ export type Page = | "logs" | "usage" | "storage" - | "codex-auth" + | "codex-set" | "integrations"; export const VALID_PAGES = new Set([ @@ -23,7 +23,7 @@ export const VALID_PAGES = new Set([ "logs", "usage", "storage", - "codex-auth", + "codex-set", "integrations", ]); @@ -35,6 +35,11 @@ export function readPageFromHash(hash?: string): Page { const pageId = raw.split("/")[0] as Page; // Legacy: Debug used to be a standalone page; it now lives as a tab on Logs. if (pageId === ("debug" as Page)) return "logs"; + // Legacy: Codex Auth became the Multi-auth tab of Codex Set. #codex-auth is a + // bookmarkable URL that has shipped, and use-app-route-state reads the initial + // page straight from the hash, so without this an old bookmark lands on an + // unknown page. + if (pageId === ("codex-auth" as Page)) return "codex-set"; // Legacy: Combos, Routing, and Lab are Models tabs now. Resolve the destination page // immediately so a cold legacy hash never flashes Dashboard before replacement. if (pageId === ("combos" as Page) @@ -98,6 +103,7 @@ export const INTEGRATION_TAB_HASHES = [ export function hashBelongsToPage(rawHash: string, page: Page): boolean { return rawHash === page || (page === "logs" && rawHash === "logs/debug") + || (page === "codex-set" && rawHash === "codex-set/prompt") || (page === "models" && (MODELS_TAB_HASHES as readonly string[]).includes(rawHash)) || (page === "dashboard" && (rawHash === DASHBOARD_UPDATE_HASH || (DASHBOARD_TAB_HASHES as readonly string[]).includes(rawHash))) @@ -125,6 +131,11 @@ export function resolveAppHashChange(rawHash: string): AppHashChangeAction { return { page: "logs", replaceTo: "logs/debug" }; } + /* Legacy: Codex Auth is now the Multi-auth tab of Codex Set. */ + if (rawHash === "codex-auth" || rawHash.startsWith("codex-auth/")) { + return { page: "codex-set", replaceTo: "codex-set" }; + } + /* Legacy Models pages. Delimiter-aware prefix arms preserve nested legacy bookmarks. */ if (rawHash === "combos" || rawHash.startsWith("combos/")) { return { page: "models", replaceTo: "models/combos" }; diff --git a/gui/src/components/provider-catalog/ProviderCatalog.tsx b/gui/src/components/provider-catalog/ProviderCatalog.tsx index 14973d44f95..d08612d7c7d 100644 --- a/gui/src/components/provider-catalog/ProviderCatalog.tsx +++ b/gui/src/components/provider-catalog/ProviderCatalog.tsx @@ -182,7 +182,7 @@ export default function ProviderCatalog({ {row.kind === "key" ? null : row.kind === "codex" ? ( <> {loggedIn && ( - {t("modal.accountManage")} + {t("modal.accountManage")} )} {onLogin && ( + + + + {promptMounted && ( + + )} + + + + ); +} + diff --git a/gui/src/pages/CodexAuth.tsx b/gui/src/pages/codex-set-multiauth.tsx similarity index 92% rename from gui/src/pages/CodexAuth.tsx rename to gui/src/pages/codex-set-multiauth.tsx index 17239f6acda..5f00b9c48a5 100644 --- a/gui/src/pages/CodexAuth.tsx +++ b/gui/src/pages/codex-set-multiauth.tsx @@ -102,11 +102,18 @@ type CachedMode = { }; /** - * Codex Auth page — a thin wrapper around CodexAccountPool (WP060 extraction). - * The page owns the /api/config fetch feeding the account-mode banner and - * passes the mode down so the pool renders mode-aware copy. + * The Multi-auth panel of Codex Set — a thin wrapper around CodexAccountPool + * (WP060 extraction). The panel owns the /api/config fetch feeding the + * account-mode banner and passes the mode down so the pool renders mode-aware + * copy. + * + * This is a MOVE, not a rewrite: every line below arrived here from + * `CodexAuth.tsx` unchanged, including the session cache key + * `ocx.codex-auth.config.v1`. Renaming that key would discard every user's warm + * cache to gain nothing, and the backend namespace /api/codex-auth/* does not + * move either (devlog 004 §C: a dozen test files bind to it). */ -export default function CodexAuth({ apiBase }: { apiBase: string }) { +export default function CodexSetMultiauth({ apiBase }: { apiBase: string }) { const t = useT(); const configCacheKey = `ocx.codex-auth.config.v1:${apiBase}`; const cached = readSessionListCache(configCacheKey); diff --git a/gui/src/pages/codex-set-prompt.tsx b/gui/src/pages/codex-set-prompt.tsx new file mode 100644 index 00000000000..cbac62ed670 --- /dev/null +++ b/gui/src/pages/codex-set-prompt.tsx @@ -0,0 +1,179 @@ +import { useCallback, useState } from "react"; +import { useT } from "../i18n/shared"; +import { useDataSurface } from "../data-surface"; +import { setClientResourceData } from "../client-resource"; +import { DataSurfaceSkeleton, DataSurfaceStatus } from "../components/data-surface"; + +/** + * The Prompt panel of Codex Set (WP3). + * + * This phase renders the five config-toggle rows and nothing else. The other + * four layer classes are deliberately ABSENT rather than stubbed: a panel that + * renders half a taxonomy invites a reader to assume the rest does not exist. + * WP4 adds them together with the read-only dialog. + * + * No polling. The file changes when the user changes it, and a 30s timer would + * fight the editor for no gain. + */ +export type LayerClass = + | "base" + | "config-toggle" + | "feature-gated" + | "runtime-conditional" + | "extension-unknown"; + +export interface LayerDescriptorDto { + id: string; + class: LayerClass; + key: string | null; + default: boolean | null; + order: number | null; +} + +export interface ToggleStateDto { + id: string; + key: string; + userFileValue: boolean | null; + defaultedUserValue: boolean; + default: boolean; +} + +export interface CustomLayerDto { + id: string; + title: string; + body: string; + enabled: boolean; +} + +export interface PromptSnapshotDto { + configPath: string; + storePath: string; + configExists: boolean; + readable: boolean; + developerInstructionsOwned: boolean; + drift: "journal-present" | "projection-stale" | "store-missing" | "owned-malformed" | null; + revision: string; + inventory: LayerDescriptorDto[]; + toggles: ToggleStateDto[]; + extensionLayersEnumerable: boolean; + custom: CustomLayerDto[]; + modelInstructionsFile: string | null; +} + +export function codexPromptResourceKey(apiBase: string): string { + return "codex-prompt:" + apiBase; +} + +export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { + const t = useT(); + const resourceKey = codexPromptResourceKey(apiBase); + const [error, setError] = useState(""); + const [busyId, setBusyId] = useState(null); + + const load = useCallback(async (signal: AbortSignal): Promise => { + const res = await fetch(apiBase + "/api/codex-prompt", { signal }); + if (!res.ok) throw new Error(String(res.status)); + return await res.json() as PromptSnapshotDto; + }, [apiBase]); + + const resource = useDataSurface(resourceKey, [apiBase], load, { + isEmpty: snapshot => snapshot.inventory.length === 0, + }); + const snapshot = resource.data; + const state = resource.state; + + const onToggle = async (id: string, enabled: boolean) => { + if (!snapshot) return; + setBusyId(id); + setError(""); + try { + const res = await fetch(apiBase + "/api/codex-prompt/toggle", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id, enabled, revision: snapshot.revision }), + }); + const body = await res.json() as { ok?: boolean; code?: string; message?: string; snapshot?: PromptSnapshotDto }; + if (!res.ok || !body.ok || !body.snapshot) { + // A stale revision means another tab or a hand edit moved the file. Re-read + // rather than retrying blindly: a retry would overwrite whatever moved it. + if (body.code === "stale_revision") { + resource.refresh(); + setError(t("codexSet.prompt.staleRevision")); + return; + } + setError(body.message ?? t("codexSet.prompt.writeFailed")); + // Never leave a switch showing a state the file does not have. + resource.refresh(); + return; + } + setClientResourceData(resourceKey, body.snapshot); + } catch { + setError(t("codexSet.prompt.writeFailed")); + resource.refresh(); + } finally { + setBusyId(null); + } + }; + + const toggleRows = (snapshot?.inventory ?? []) + .filter(d => d.class === "config-toggle") + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + + return ( +
+
+ {t("codexSet.prompt.title")} +
+ {/* + Fixed copy from devlog 003 section 3. Neither "applies immediately" nor + "restart required" is proven, and the frontend reload path is UNKNOWN + upstream, so the panel promises only what the runtime actually does. + */} +

{t("codexSet.prompt.timing")}

+ + {/* + One announcement per transition: the status line yields its live region to + the error notice so a screen reader is not told the same thing twice. + */} + {state.refreshing && ( + + {t("common.loading")} + + )} + + {state.showSkeleton && ( + + )} + + {snapshot && !snapshot.readable && ( +
{t("codexSet.prompt.unreadable")}
+ )} + {error &&
{error}
} + +
    + {toggleRows.map(descriptor => { + const state = snapshot?.toggles.find(s => s.id === descriptor.id); + const checked = state?.defaultedUserValue ?? descriptor.default ?? true; + const label = t(("codexSet.layer." + descriptor.id) as never); + return ( +
  • + {label} + {descriptor.key} + +
  • + ); + })} +
+
+ ); +} diff --git a/gui/src/pages/codex-set-tab.ts b/gui/src/pages/codex-set-tab.ts new file mode 100644 index 00000000000..06efaa893ab --- /dev/null +++ b/gui/src/pages/codex-set-tab.ts @@ -0,0 +1,30 @@ +import type { KeyboardEvent } from "react"; + +/** + * Tab state for the Codex Set page, shaped exactly like Logs/Debug: two exclusive + * tabpanels whose choice lives in the hash, not in component state alone. That is + * what makes the tab survive a refresh, a bookmark, and back/forward — and it is + * the pattern devlog 004 §A3 identifies as the one the ask actually names. + */ +export type CodexSetTab = "multiauth" | "prompt"; + +export function readCodexSetTabFromHash(): CodexSetTab { + return window.location.hash.replace(/^#\/?/, "") === "codex-set/prompt" ? "prompt" : "multiauth"; +} + +export function selectCodexSetTab(next: CodexSetTab): void { + window.location.hash = next === "prompt" ? "codex-set/prompt" : "codex-set"; +} + +export function codexSetTabKeyDown(e: KeyboardEvent): void { + if (e.key === "ArrowLeft" || e.key === "Home") { + e.preventDefault(); + selectCodexSetTab("multiauth"); + document.getElementById("codex-set-tab-multiauth")?.focus(); + } else if (e.key === "ArrowRight" || e.key === "End") { + e.preventDefault(); + selectCodexSetTab("prompt"); + document.getElementById("codex-set-tab-prompt")?.focus(); + } +} + diff --git a/gui/src/pages/providers-page-utils.ts b/gui/src/pages/providers-page-utils.ts index 1b4ef8a3461..ba5a2e1339f 100644 --- a/gui/src/pages/providers-page-utils.ts +++ b/gui/src/pages/providers-page-utils.ts @@ -16,7 +16,7 @@ export function buildAddModalAccountRows( id: name, label: formatProviderDisplayName(name, t), kind: "codex" as const, - href: "#codex-auth", + href: "#codex-set", })), ...oauthProviders .toSorted((a, b) => a.localeCompare(b)) diff --git a/gui/tests/codex-auth-provider-enable.test.tsx b/gui/tests/codex-set-provider-enable.test.tsx similarity index 96% rename from gui/tests/codex-auth-provider-enable.test.tsx rename to gui/tests/codex-set-provider-enable.test.tsx index 216b21edf6c..c271eb21cc2 100644 --- a/gui/tests/codex-auth-provider-enable.test.tsx +++ b/gui/tests/codex-set-provider-enable.test.tsx @@ -1,7 +1,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import { LanguageProvider } from "../src/i18n/provider"; -import { OpenAiAccountModeBanner } from "../src/pages/CodexAuth"; +import { OpenAiAccountModeBanner } from "../src/pages/codex-set-multiauth"; let previousLanguageDescriptor: PropertyDescriptor | undefined; diff --git a/gui/tests/codex-auth-recovery-interaction.test.tsx b/gui/tests/codex-set-recovery-interaction.test.tsx similarity index 97% rename from gui/tests/codex-auth-recovery-interaction.test.tsx rename to gui/tests/codex-set-recovery-interaction.test.tsx index e41f38a85eb..24050945dff 100644 --- a/gui/tests/codex-auth-recovery-interaction.test.tsx +++ b/gui/tests/codex-set-recovery-interaction.test.tsx @@ -3,7 +3,7 @@ import { Window } from "happy-dom"; import { act } from "react"; import type { Root } from "react-dom/client"; import { LanguageProvider } from "../src/i18n/provider"; -import CodexAuth from "../src/pages/CodexAuth"; +import CodexSetMultiauth from "../src/pages/codex-set-multiauth"; /** * Interaction-level recovery journey for Codex Auth. @@ -111,11 +111,11 @@ async function mountPage() { root = createRoot(host); root.render( - + , ); }); - // CodexAuth loads mode via setTimeout(0); pool controller settles shortly after. + // The Multi-auth panel loads mode via setTimeout(0); the pool controller settles shortly after. await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); } diff --git a/gui/tests/codex-set-shell.test.tsx b/gui/tests/codex-set-shell.test.tsx new file mode 100644 index 00000000000..cf4dbe8a37f --- /dev/null +++ b/gui/tests/codex-set-shell.test.tsx @@ -0,0 +1,267 @@ +/** + * Codex Set shell (devlog 260802_codex_set_prompt_composer/030). + * + * Two exclusive tabpanels shaped like Logs/Debug, a legacy hash that still + * resolves, and a Prompt panel that renders the five config-toggle rows from the + * live inventory rather than a locally rebuilt list. + */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import CodexSet from "../src/pages/CodexSet"; +import CodexSetPrompt from "../src/pages/codex-set-prompt"; +import { readPageFromHash, resolveAppHashChange, hashBelongsToPage } from "../src/app-routing"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +const originalFetch = globalThis.fetch; + +const INVENTORY = [ + { id: "base-instructions", class: "base", key: null, default: null, order: 0 }, + { id: "model-switch", class: "runtime-conditional", key: null, default: null, order: 1 }, + { id: "personality", class: "feature-gated", key: "features.personality", default: true, order: 2 }, + { id: "permissions", class: "config-toggle", key: "include_permissions_instructions", default: true, order: 6 }, + { id: "collaboration", class: "config-toggle", key: "include_collaboration_mode_instructions", default: true, order: 7 }, + { id: "environment", class: "config-toggle", key: "include_environment_context", default: true, order: 8 }, + { id: "apps", class: "config-toggle", key: "include_apps_instructions", default: true, order: 10 }, + { id: "skills", class: "config-toggle", key: "skills.include_instructions", default: true, order: 13 }, +]; + +function snapshot(over: Record = {}) { + return { + configPath: "/tmp/config.toml", + storePath: "/tmp/opencodex-prompt.json", + configExists: true, + readable: true, + developerInstructionsOwned: false, + drift: null, + revision: "sha256:one", + inventory: INVENTORY, + toggles: INVENTORY.filter(d => d.class === "config-toggle").map(d => ({ + id: d.id, key: d.key as string, userFileValue: null, defaultedUserValue: true, default: true, + })), + extensionLayersEnumerable: false, + custom: [], + modelInstructionsFile: null, + ...over, + }; +} + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#codex-set" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + // The resource store is keyed by apiBase and outlives a single test. Without + // this, a later case renders the PREVIOUS case's snapshot from cache and its + // fetch stub is never consulted. + clearClientResourceStoresForTests(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +interface StubCall { url: string; method: string; body: unknown } + +function stubRoutes(handler: (call: StubCall) => Response | Promise) { + const calls: StubCall[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const call: StubCall = { + url: String(input), + method: init?.method ?? "GET", + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }; + calls.push(call); + return handler(call); + }) as typeof fetch; + return calls; +} + +function json(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); +} + +async function mountShell(): Promise<{ root: Root; container: HTMLElement }> { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + return { root, container }; +} + +async function mountPrompt(): Promise<{ root: Root; container: HTMLElement }> { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render(); + }); + return { root, container }; +} + +function panel(container: HTMLElement, name: "multiauth" | "prompt"): HTMLElement | null { + return container.querySelector("#codex-set-panel-" + name); +} + +test("1. #codex-set renders Multi-auth, and Prompt is not mounted", async () => { + stubRoutes(() => json({})); + const { container, root } = await mountShell(); + const multi = panel(container, "multiauth"); + expect(multi).not.toBeNull(); + expect(multi!.hasAttribute("hidden")).toBe(false); + // Case 4: Prompt does not mount until first visited. + expect(panel(container, "prompt")).toBeNull(); + await act(async () => { root.unmount(); }); +}); + +test("2. #codex-set/prompt renders the Prompt panel", async () => { + testWindow.location.hash = "#codex-set/prompt"; + stubRoutes(() => json(snapshot())); + const { container, root } = await mountShell(); + const prompt = panel(container, "prompt"); + expect(prompt).not.toBeNull(); + expect(prompt!.hasAttribute("hidden")).toBe(false); + expect(panel(container, "multiauth")!.hasAttribute("hidden")).toBe(true); + await act(async () => { root.unmount(); }); +}); + +test("3. the shipped #codex-auth bookmark redirects to #codex-set", () => { + expect(readPageFromHash("codex-auth")).toBe("codex-set"); + const action = resolveAppHashChange("codex-auth"); + expect(action.page).toBe("codex-set"); + expect(action.replaceTo).toBe("codex-set"); + // A nested legacy bookmark resolves too, rather than landing on an unknown page. + expect(resolveAppHashChange("codex-auth/anything").page).toBe("codex-set"); + expect(hashBelongsToPage("codex-set/prompt", "codex-set")).toBe(true); +}); + +test("5. Prompt stays mounted after switching away", async () => { + testWindow.location.hash = "#codex-set/prompt"; + stubRoutes(() => json(snapshot())); + const { container, root } = await mountShell(); + expect(panel(container, "prompt")).not.toBeNull(); + await act(async () => { + testWindow.location.hash = "#codex-set"; + testWindow.dispatchEvent(new testWindow.Event("hashchange")); + }); + // Still in the tree, just hidden: a remount would refetch for nothing. + const prompt = panel(container, "prompt"); + expect(prompt).not.toBeNull(); + expect(prompt!.hasAttribute("hidden")).toBe(true); + await act(async () => { root.unmount(); }); +}); + +test("6. clicking a tab moves the hash, and a hash change moves the tab", async () => { + stubRoutes(() => json(snapshot())); + const { container, root } = await mountShell(); + const promptTab = container.querySelector("#codex-set-tab-prompt") as HTMLButtonElement; + await act(async () => { promptTab.click(); }); + expect(testWindow.location.hash).toBe("#codex-set/prompt"); + await act(async () => { + testWindow.location.hash = "#codex-set"; + testWindow.dispatchEvent(new testWindow.Event("hashchange")); + }); + expect(container.querySelector("#codex-set-tab-multiauth")!.getAttribute("aria-selected")).toBe("true"); + await act(async () => { root.unmount(); }); +}); + +test("7. the timing copy promises new sessions, not an immediate or restarted apply", async () => { + // devlog 003 section 3 leaves the frontend reload path UNKNOWN, so the panel + // must not claim either. Pinned here so a later copy edit cannot quietly + // promise something the runtime does not do. + stubRoutes(() => json(snapshot())); + const { container, root } = await mountPrompt(); + const text = container.textContent ?? ""; + expect(text).toContain("newly started sessions"); + expect(text).not.toContain("immediately"); + expect(text).not.toContain("restart"); + await act(async () => { root.unmount(); }); +}); + +test("8. the five toggle rows render from the inventory, and no other class does", async () => { + stubRoutes(() => json(snapshot())); + const { container, root } = await mountPrompt(); + const switches = container.querySelectorAll("input[role=\"switch\"]"); + expect(switches).toHaveLength(5); + const text = container.textContent ?? ""; + expect(text).toContain("include_apps_instructions"); + // WP4 owns the other four classes. Their absence here is the contract. + expect(text).not.toContain("features.personality"); + await act(async () => { root.unmount(); }); +}); + +test("9. toggling PUTs once with the current revision", async () => { + const calls = stubRoutes(call => { + if (call.method === "PUT") { + return json({ ok: true, changed: true, snapshot: snapshot({ revision: "sha256:two" }) }); + } + return json(snapshot()); + }); + const { container, root } = await mountPrompt(); + const first = container.querySelector("input[role=\"switch\"]") as HTMLInputElement; + await act(async () => { + first.click(); + }); + const puts = calls.filter(c => c.method === "PUT"); + expect(puts).toHaveLength(1); + expect((puts[0]!.body as { revision: string }).revision).toBe("sha256:one"); + expect((puts[0]!.body as { enabled: boolean }).enabled).toBe(false); + await act(async () => { root.unmount(); }); +}); + +test("10. a stale-revision 409 re-reads instead of retrying blindly", async () => { + let gets = 0; + const calls = stubRoutes(call => { + if (call.method === "PUT") return json({ ok: false, code: "stale_revision" }, 409); + gets += 1; + return json(snapshot({ revision: gets > 1 ? "sha256:fresh" : "sha256:one" })); + }); + const { container, root } = await mountPrompt(); + const first = container.querySelector("input[role=\"switch\"]") as HTMLInputElement; + await act(async () => { + first.click(); + }); + // Exactly one write attempt: a blind retry would overwrite whatever moved the file. + expect(calls.filter(c => c.method === "PUT")).toHaveLength(1); + expect(gets).toBeGreaterThan(1); + await act(async () => { root.unmount(); }); +}); + +test("11. configExists false leaves the switches live, not disabled", async () => { + // First run: the file does not exist yet and the first write creates it. + stubRoutes(() => json(snapshot({ configExists: false }))); + const { container, root } = await mountPrompt(); + const switches = [...container.querySelectorAll("input[role=\"switch\"]")] as HTMLInputElement[]; + expect(switches).toHaveLength(5); + for (const input of switches) expect(input.disabled).toBe(false); + await act(async () => { root.unmount(); }); +}); + +test("an unreadable config refuses writes and says so", async () => { + stubRoutes(() => json(snapshot({ readable: false }))); + const { container, root } = await mountPrompt(); + expect(container.querySelector("[role=\"alert\"]")).not.toBeNull(); + const switches = [...container.querySelectorAll("input[role=\"switch\"]")] as HTMLInputElement[]; + for (const input of switches) expect(input.disabled).toBe(true); + await act(async () => { root.unmount(); }); +}); diff --git a/gui/tests/dashboard-tabs.test.ts b/gui/tests/dashboard-tabs.test.ts index b1b4b9808f3..c289eceada6 100644 --- a/gui/tests/dashboard-tabs.test.ts +++ b/gui/tests/dashboard-tabs.test.ts @@ -42,12 +42,12 @@ test("registering Dashboard tabs does not disturb the Logs or Providers contract expect(hashBelongsToPage("logs/debug", "dashboard")).toBe(false); }); -test("Codex Auth sits directly after Dashboard in the sidebar", async () => { +test("Codex Set sits directly after Dashboard in the sidebar", async () => { const app = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); const nav = app.slice(app.indexOf("const NAV"), app.indexOf("];", app.indexOf("const NAV"))); const order = [...nav.matchAll(/id: "([a-z-]+)"/g)].map((m) => m[1]); expect(order[0]).toBe("dashboard"); - expect(order[1]).toBe("codex-auth"); + expect(order[1]).toBe("codex-set"); // Order only — no divider markup was introduced (Q3). expect(app).not.toContain("nav-divider"); }); diff --git a/gui/tests/page-loading-contract.test.tsx b/gui/tests/page-loading-contract.test.tsx index 452363f9965..2ab6b88385d 100644 --- a/gui/tests/page-loading-contract.test.tsx +++ b/gui/tests/page-loading-contract.test.tsx @@ -43,6 +43,7 @@ const MIGRATED = [ { name: "Storage", file: "../src/pages/Storage.tsx" }, { name: "ApiKeys", file: "../src/pages/ApiKeys.tsx" }, { name: "Models", file: "../src/pages/Models.tsx" }, + { name: "CodexSetPrompt", file: "../src/pages/codex-set-prompt.tsx" }, ] as const; test("every migrated surface subscribes through the shared resource layer", async () => { diff --git a/gui/tests/sidebar-codex-auth.test.ts b/gui/tests/sidebar-codex-set.test.ts similarity index 56% rename from gui/tests/sidebar-codex-auth.test.ts rename to gui/tests/sidebar-codex-set.test.ts index c172cfd8a71..9c726942d20 100644 --- a/gui/tests/sidebar-codex-auth.test.ts +++ b/gui/tests/sidebar-codex-set.test.ts @@ -11,11 +11,12 @@ import { expect, test } from "bun:test"; * page permanently. */ -test("Codex Auth is always present in the sidebar, never filtered by view mode", async () => { +test("Codex Set is always present in the sidebar, never filtered by view mode", async () => { const src = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); // The old conditional filter must not come back. expect(src).not.toContain('viewMode === "workspace" && id === "codex-auth"'); + expect(src).not.toContain('viewMode === "workspace" && id === "codex-set"'); /* * The subject here is "the sidebar renders the whole table", not the shape of * one line. Pinning the exact destructuring made this fail the moment an @@ -24,6 +25,16 @@ test("Codex Auth is always present in the sidebar, never filtered by view mode", expect(src).toContain("NAV.map("); // It stays in the nav table and remains routable for deep links. - expect(src).toContain('{ id: "codex-auth", tkey: "nav.codexAuth", Icon: IconKey }'); - expect(src).toContain('{page === "codex-auth" && }'); + expect(src).toContain('{ id: "codex-set", tkey: "nav.codexSet", Icon: IconKey }'); + expect(src).toContain('{page === "codex-set" && }'); +}); + +test("the shipped #codex-auth bookmark still resolves", async () => { + // The page id changed; a URL a user saved did not. WP3 renames the route and + // keeps the legacy hash working rather than stranding it on an unknown page. + const { readPageFromHash, resolveAppHashChange } = await import("../src/app-routing"); + expect(readPageFromHash("codex-auth")).toBe("codex-set"); + const action = resolveAppHashChange("codex-auth"); + expect(action.page).toBe("codex-set"); + expect(action.replaceTo).toBe("codex-set"); }); diff --git a/gui/tests/sidebar-rows.test.ts b/gui/tests/sidebar-rows.test.ts index c53243c42c7..308c08e21e3 100644 --- a/gui/tests/sidebar-rows.test.ts +++ b/gui/tests/sidebar-rows.test.ts @@ -30,7 +30,7 @@ test("every row maps one-to-one onto a page", () => { // The exact nine, in order. A count alone would pass if a row were swapped for // another, and Routing folding into Models is precisely that kind of change. expect(ids).toEqual([ - "dashboard", "codex-auth", "providers", "models", "subagents", + "dashboard", "codex-set", "providers", "models", "subagents", "logs", "usage", "storage", "integrations", ]); // No two rows share a page id, which is what made the correction helper necessary. From d3ed06359918323a6714a9f5f0ba6b732a693ac5 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Wed, 26 Aug 2026 18:14:28 +0900 Subject: [PATCH 02/10] fix(gui): show a failed prompt read, and lazy-mount both Codex Set panels Two defects an audit found in WP3. A failed GET rendered as a title, a timing line, and an empty list. showError was consulted only to suppress the status line's live region, so a cold failure looked like an empty result and a failed refresh over existing rows looked settled - precisely the two states the loading contract exists to keep apart. The page-loading-contract test passed anyway because it scans source for .showError and cannot tell whether anything renders. There is now a notice, and deleting it turns the new test red. Multi-auth mounted unconditionally, so arriving straight at #codex-set/prompt still started its /api/config fetch and 30s account poll behind a hidden panel - the exact cost the two-panel shell was shaped to avoid, contradicted by its own comment. Both panels lazy-mount on first selection now and stay mounted after. Four tests were strengthened where they could pass through a regression: the tab switch pins history growth and the absence of replaceState rather than a hand-driven hash; the row filter asserts over every non-toggle class in the inventory instead of one hand-picked key; the write pins its endpoint and layer id, not only the payload; and the stale-revision case proves the re-read is installed rather than merely issued. devlog: 260802_codex_set_prompt_composer/030 --- gui/src/i18n/de.ts | 3 +- gui/src/i18n/en.ts | 3 +- gui/src/i18n/fr.ts | 3 +- gui/src/i18n/ja.ts | 3 +- gui/src/i18n/ko.ts | 3 +- gui/src/i18n/ru.ts | 3 +- gui/src/i18n/tr.ts | 3 +- gui/src/i18n/zh-TW.ts | 3 +- gui/src/i18n/zh.ts | 3 +- gui/src/pages/CodexSet.tsx | 25 +++++++----- gui/src/pages/codex-set-prompt.tsx | 10 ++++- gui/tests/codex-set-shell.test.tsx | 64 +++++++++++++++++++++++++++--- 12 files changed, 101 insertions(+), 25 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 58a9738d99b..5611dc17702 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -852,7 +852,8 @@ export const de: Record = { "codexSet.prompt.title": "Prompt-Ebenen", "codexSet.prompt.timing": "Gilt für neu gestartete Sitzungen. Laufende Sitzungen behalten ihre aktuellen Prompt-Einstellungen.", "codexSet.prompt.staleRevision": "Die Konfiguration wurde anderswo geändert. Die Liste wurde neu geladen.", - "codexSet.prompt.writeFailed": "Die Änderung konnte nicht gespeichert werden.", +"codexSet.prompt.writeFailed": "Die Änderung konnte nicht gespeichert werden.", + "codexSet.prompt.loadFailed": "Die Prompt-Ebenen konnten nicht geladen werden.", "codexSet.prompt.unreadable": "Die Codex-Konfigurationsdatei existiert, konnte aber nicht gelesen werden, daher wurden Änderungen abgelehnt.", "codexSet.layer.permissions": "Berechtigungen", "codexSet.layer.collaboration": "Kollaborationsmodus", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index fe4048571ab..44fb330fff3 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1346,7 +1346,8 @@ export const en = { "codexSet.prompt.title": "Prompt layers", "codexSet.prompt.timing": "Applies to newly started sessions. Running sessions keep their current prompt settings.", "codexSet.prompt.staleRevision": "The configuration changed elsewhere. The list was reloaded.", - "codexSet.prompt.writeFailed": "The change could not be saved.", +"codexSet.prompt.writeFailed": "The change could not be saved.", + "codexSet.prompt.loadFailed": "The prompt layers could not be loaded.", "codexSet.prompt.unreadable": "The Codex configuration file exists but could not be read, so changes are refused.", "codexSet.layer.permissions": "Permissions", "codexSet.layer.collaboration": "Collaboration mode", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 7cba155b21d..7005ee49672 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1319,7 +1319,8 @@ export const fr: Record = { "codexSet.prompt.title": "Couches d'invite", "codexSet.prompt.timing": "S'applique aux sessions nouvellement démarrées. Les sessions en cours conservent leurs réglages d'invite actuels.", "codexSet.prompt.staleRevision": "La configuration a changé ailleurs. La liste a été rechargée.", - "codexSet.prompt.writeFailed": "La modification n'a pas pu être enregistrée.", +"codexSet.prompt.writeFailed": "La modification n'a pas pu être enregistrée.", + "codexSet.prompt.loadFailed": "Les couches d'invite n'ont pas pu être chargées.", "codexSet.prompt.unreadable": "Le fichier de configuration Codex existe mais n'a pas pu être lu, les modifications sont donc refusées.", "codexSet.layer.permissions": "Autorisations", "codexSet.layer.collaboration": "Mode collaboration", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3f448f3c2a0..963612dffe7 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1279,7 +1279,8 @@ export const ja: Record = { "codexSet.prompt.title": "プロンプトレイヤー", "codexSet.prompt.timing": "新しく開始したセッションから適用されます。実行中のセッションは現在の設定を保持します。", "codexSet.prompt.staleRevision": "他の場所で設定が変更されたため、一覧を再読み込みしました。", - "codexSet.prompt.writeFailed": "変更を保存できませんでした。", +"codexSet.prompt.writeFailed": "変更を保存できませんでした。", + "codexSet.prompt.loadFailed": "プロンプトレイヤーを読み込めませんでした。", "codexSet.prompt.unreadable": "Codex の設定ファイルは存在しますが読み取れないため、変更を拒否しました。", "codexSet.layer.permissions": "権限", "codexSet.layer.collaboration": "コラボレーションモード", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index eb96ea63f21..f79383e00c1 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -878,7 +878,8 @@ export const ko: Record = { "codexSet.prompt.title": "프롬프트 레이어", "codexSet.prompt.timing": "새 세션부터 적용됩니다. 실행 중인 세션은 현재 설정을 유지합니다.", "codexSet.prompt.staleRevision": "다른 곳에서 설정이 바뀌어 목록을 다시 불러왔습니다.", - "codexSet.prompt.writeFailed": "변경 내용을 저장하지 못했습니다.", +"codexSet.prompt.writeFailed": "변경 내용을 저장하지 못했습니다.", + "codexSet.prompt.loadFailed": "프롬프트 레이어를 불러오지 못했습니다.", "codexSet.prompt.unreadable": "Codex 설정 파일이 있지만 읽을 수 없어 변경을 거부했습니다.", "codexSet.layer.permissions": "권한", "codexSet.layer.collaboration": "협업 모드", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 713c55877a3..359204964b6 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1330,7 +1330,8 @@ export const ru: Record = { "codexSet.prompt.title": "Слои промпта", "codexSet.prompt.timing": "Применяется к новым сессиям. Запущенные сессии сохраняют текущие настройки промпта.", "codexSet.prompt.staleRevision": "Конфигурация изменилась в другом месте. Список перезагружен.", - "codexSet.prompt.writeFailed": "Не удалось сохранить изменение.", +"codexSet.prompt.writeFailed": "Не удалось сохранить изменение.", + "codexSet.prompt.loadFailed": "Не удалось загрузить слои промпта.", "codexSet.prompt.unreadable": "Файл конфигурации Codex существует, но его не удалось прочитать, поэтому изменения отклонены.", "codexSet.layer.permissions": "Разрешения", "codexSet.layer.collaboration": "Режим совместной работы", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 632c1b5c22d..cd786df17b8 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1337,7 +1337,8 @@ export const tr: Record = { "codexSet.prompt.title": "İstem katmanları", "codexSet.prompt.timing": "Yeni başlatılan oturumlara uygulanır. Çalışan oturumlar mevcut istem ayarlarını korur.", "codexSet.prompt.staleRevision": "Yapılandırma başka bir yerde değişti. Liste yeniden yüklendi.", - "codexSet.prompt.writeFailed": "Değişiklik kaydedilemedi.", +"codexSet.prompt.writeFailed": "Değişiklik kaydedilemedi.", + "codexSet.prompt.loadFailed": "İstem katmanları yüklenemedi.", "codexSet.prompt.unreadable": "Codex yapılandırma dosyası var ancak okunamadı, bu yüzden değişiklikler reddedildi.", "codexSet.layer.permissions": "İzinler", "codexSet.layer.collaboration": "İş birliği modu", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 5df43d5ab27..bf0d44dc918 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1112,7 +1112,8 @@ export const zhTW: Record = { "codexSet.prompt.title": "提示詞層", "codexSet.prompt.timing": "對新啟動的工作階段生效。執行中的工作階段會保留目前的提示詞設定。", "codexSet.prompt.staleRevision": "設定已在別處變更,清單已重新載入。", - "codexSet.prompt.writeFailed": "無法儲存變更。", +"codexSet.prompt.writeFailed": "無法儲存變更。", + "codexSet.prompt.loadFailed": "無法載入提示詞層。", "codexSet.prompt.unreadable": "Codex 設定檔存在但無法讀取,因此拒絕了變更。", "codexSet.layer.permissions": "權限", "codexSet.layer.collaboration": "協作模式", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index bd3a38d128a..dd897552421 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -871,7 +871,8 @@ export const zh: Record = { "codexSet.prompt.title": "提示词层", "codexSet.prompt.timing": "对新启动的会话生效。正在运行的会话保持当前的提示词设置。", "codexSet.prompt.staleRevision": "配置已在别处更改,列表已重新加载。", - "codexSet.prompt.writeFailed": "无法保存更改。", +"codexSet.prompt.writeFailed": "无法保存更改。", + "codexSet.prompt.loadFailed": "无法加载提示词层。", "codexSet.prompt.unreadable": "Codex 配置文件存在但无法读取,因此拒绝了更改。", "codexSet.layer.permissions": "权限", "codexSet.layer.collaboration": "协作模式", diff --git a/gui/src/pages/CodexSet.tsx b/gui/src/pages/CodexSet.tsx index 244d1f3e2e4..2ce5f0a438b 100644 --- a/gui/src/pages/CodexSet.tsx +++ b/gui/src/pages/CodexSet.tsx @@ -17,6 +17,11 @@ export default function CodexSet({ apiBase }: { apiBase: string }) { const t = useT(); const [tab, setTab] = useState(readCodexSetTabFromHash); const [promptMounted, setPromptMounted] = useState(() => readCodexSetTabFromHash() === "prompt"); + // Multi-auth lazy-mounts too. It used to mount unconditionally, which meant a + // direct visit to #codex-set/prompt still started its /api/config fetch and 30s + // account poll behind a hidden panel - exactly the cost this shell was shaped to + // avoid. Both panels now mount on first selection and stay mounted after. + const [multiauthMounted, setMultiauthMounted] = useState(() => readCodexSetTabFromHash() === "multiauth"); useEffect(() => { const onHash = () => setTab(readCodexSetTabFromHash()); @@ -26,6 +31,7 @@ export default function CodexSet({ apiBase }: { apiBase: string }) { useEffect(() => { if (tab === "prompt") setPromptMounted(true); + if (tab === "multiauth") setMultiauthMounted(true); }, [tab]); return ( @@ -70,15 +76,16 @@ export default function CodexSet({ apiBase }: { apiBase: string }) { )} - + {multiauthMounted && ( + + )} ); } - diff --git a/gui/src/pages/codex-set-prompt.tsx b/gui/src/pages/codex-set-prompt.tsx index cbac62ed670..9ee5a88b5a4 100644 --- a/gui/src/pages/codex-set-prompt.tsx +++ b/gui/src/pages/codex-set-prompt.tsx @@ -148,6 +148,14 @@ export default function CodexSetPrompt({ apiBase }: { apiBase: string }) { {snapshot && !snapshot.readable && (
{t("codexSet.prompt.unreadable")}
)} + {/* + A failed read must be visible. Without this the cold failure rendered as a + title and an empty list, and a failed refresh over existing rows read as + settled - the two states the loading contract exists to keep apart. + */} + {state.showError && ( +
{t("codexSet.prompt.loadFailed")}
+ )} {error &&
{error}
}