From 4702a6821b66a2da794db886bd5edffc20eaa442 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:04:09 -0300 Subject: [PATCH] feat(web): Redesign the setup page with the usage dashboard style Replace the raw JSON proposal dump with a readable preview (status banner, one row per change, validation pills, collapsed raw response), adopt the shared brand and top bar, default every role to Keep current, and pass the real page table into the setup nav. --- .specs/features/web-setup-redesign/spec.md | 101 +++++ .specs/features/web-setup-redesign/tasks.md | 74 ++++ .../features/web-setup-redesign/validation.md | 131 ++++++ src/cli/commands/ui.ts | 15 +- src/web/setup-page.ts | 413 ++++++++++++++---- src/web/setup-routes.ts | 6 +- tests/setup-page.test.ts | 303 ++++++++++++- tests/setup-web.test.ts | 5 + tests/web-cli.test.ts | 7 + 9 files changed, 962 insertions(+), 93 deletions(-) create mode 100644 .specs/features/web-setup-redesign/spec.md create mode 100644 .specs/features/web-setup-redesign/tasks.md create mode 100644 .specs/features/web-setup-redesign/validation.md diff --git a/.specs/features/web-setup-redesign/spec.md b/.specs/features/web-setup-redesign/spec.md new file mode 100644 index 0000000..5564900 --- /dev/null +++ b/.specs/features/web-setup-redesign/spec.md @@ -0,0 +1,101 @@ +# Web setup redesign + +## Goal + +Redesign `/setup` as a clear setup dashboard that uses the CodeDeck brand and the visual language of `/usage`. +The page must make current settings readable, explain edits as human changes, and preserve the setup controller and +API behavior. + +## Out of scope + +- Changes to the setup API envelope or its Portuguese keys. +- Changes to CLI `codedeck setup`, `src/config/*`, or `src/cli/*` except the `/setup` route wiring in `ui.ts`. +- Changes to `/usage`, Home, Review, or the web server. +- Removal or migration of legacy `profiles` data from a user's config. +- New runtime dependencies. The page remains one server-rendered string with inline CSS, SVG, and script. + +## Requirements + +### R1. Brand and chrome + +- R1.1 The setup page SHALL include `BRAND_CSS` from `src/web/brand.ts`. +- R1.2 The setup page SHALL use `LOGO_FAVICON_HREF` as its favicon. +- R1.3 The setup page SHALL render its top bar with `renderTopBar` from `src/web/brand.ts`. +- R1.4 The top bar SHALL mark Setup as the active page. +- R1.5 The top bar SHALL link to every page registered with the UI route table, including Home, Review, Setup, and Usage. +- R1.6 WHEN `/setup` is rendered without options THEN the exported `SETUP_PAGE` constant SHALL remain usable by existing callers. +- R1.7 The `/setup` route SHALL pass the registered pages to `renderSetupPage`. +- R1.8 Cards, buttons, pills, inputs, and selects SHALL use the `/usage` palette and control styling. +- R1.9 Model identifiers SHALL use a monospace font. +- R1.10 No native control SHALL render with a light background. +- R1.11 WHEN the setup page is rendered without a supplied page list THEN its navigation SHALL contain only Setup. +- R1.12 WHEN the setup page is rendered without a supplied page list THEN its brand link SHALL target `/setup`. + +### R2. Current state + +- R2.1 Each role card SHALL show a current binding as a harness pill, model identifier, and effort value. +- R2.2 WHEN a role has no current binding THEN its card SHALL show `not set` as the current binding. +- R2.3 The current orchestrator SHALL show a matching `ORCHESTRATOR_PRESETS` preset name when it matches a preset. +- R2.4 WHEN the current orchestrator matches no preset THEN it SHALL show `custom` and readable field values. +- R2.5 Current orchestrator values SHALL NOT be rendered as JSON. +- R2.6 Current sandbox state SHALL use words instead of JSON. +- R2.7 Current autocompact state SHALL use words instead of JSON. +- R2.8 Current sandbox state SHALL show the literal configuration value in monospace. + +### R3. Role editing + +- R3.1 Each role card SHALL offer explicit `Keep current` and `Change` choices. +- R3.2 The role choice SHALL retain the `skip-` checkbox ID and checked semantics. +- R3.3 WHEN the selected role binding uses the `opencode` harness THEN its effort select SHALL be hidden. +- R3.4 Each role SHALL start with its `skip-` checkbox checked, including unbound roles. +- R3.5 WHEN a role is set to Keep current THEN its binding and effort controls SHALL be hidden. +- R3.6 WHEN a role is set to Change THEN its binding and effort controls SHALL be shown and prefilled from current state. +- R3.7 WHEN a current role's effort is the only edited field THEN its selection SHALL preserve the current harness and model. +- R3.8 WHEN a role has no current effort THEN its effort select SHALL default to Keep current. +- R3.9 Each role card SHALL show its title in a normal header inside the card. + +### R4. Preview and result + +- R4.1 WHEN a dry run or apply response contains `mudancas` THEN the page SHALL render one row for each change. +- R4.2 Each change row SHALL format paths such as `/agents/reviewer/effort` as `reviewer · effort`. +- R4.3 Each change row SHALL show the before value, an arrow, and the after value when both values are present. +- R4.4 WHEN `beforePresent` is false THEN the change row SHALL label the change as added. +- R4.5 WHEN `afterPresent` is false THEN the change row SHALL label the change as removed. +- R4.6 WHEN `mudancas` is empty THEN the preview SHALL say `No changes`. +- R4.7 A dry-run result SHALL show the status `Preview only, nothing written`. +- R4.8 An applied result SHALL show the status `Saved`. +- R4.9 An unchanged result SHALL show the unchanged status and any supplied message. +- R4.10 An error result SHALL show the error status and its supplied message. +- R4.11 The config, catalog, and binding validations SHALL render as compact status pills or rows. +- R4.12 A validation message SHALL render only when the response supplies one. +- R4.13 The default preview SHALL NOT print the complete proposed config. +- R4.14 The default preview SHALL NOT print legacy `profiles` or `activeProfile` values. +- R4.15 The full raw response MAY be available inside a collapsed `details` element named `Raw response`. +- R4.16 Values inserted into the preview with `innerHTML` SHALL be HTML-escaped. +- R4.17 Each change row SHALL group the before value, arrow, and after value together after its friendly path. + +### R5. Actions + +- R5.1 Preview and Apply SHALL appear in one action bar. +- R5.2 Apply SHALL be the primary blue action. +- R5.3 Preview and Apply SHALL be disabled while a request is in flight. +- R5.4 Preview and Apply SHALL be disabled until setup state is loaded. +- R5.5 The action bar SHALL stick to the viewport bottom and show selection, loading, in-flight, or error status on its left. +- R5.6 The sticky action bar SHALL NOT cover the last page content at 390px. + +### R6. Layout + +- R6.1 At 1440px, role cards SHALL use a four-column grid or a two-by-two grid when space is constrained. +- R6.2 At 390px, role cards SHALL stack in one column. +- R6.3 At 390px, `document.documentElement.scrollWidth` SHALL be no greater than 390. + +### R7. Preserved behavior + +- R7.1 `buildSetupSelection` SHALL retain its current selection semantics. +- R7.2 The setup controller SHALL retain its public `start`, `refreshCatalog`, `buildSelection`, `dryRun`, and `apply` methods. +- R7.3 Every existing element ID used by the controller SHALL remain in the rendered page. +- R7.4 Every existing element ID used by setup tests SHALL remain in the rendered page unless the test only asserts obsolete copy. +- R7.5 The off-catalog apply confirmation SHALL remain per role. +- R7.6 A protected action that returns 403 SHALL show the existing expired-session message. +- R7.7 Setup API request paths, methods, and body shapes SHALL remain unchanged. +- R7.8 Existing tests SHALL continue to pass, except markup-only assertions may change to match the new copy. diff --git a/.specs/features/web-setup-redesign/tasks.md b/.specs/features/web-setup-redesign/tasks.md new file mode 100644 index 0000000..91d00bc --- /dev/null +++ b/.specs/features/web-setup-redesign/tasks.md @@ -0,0 +1,74 @@ +# Web setup redesign: tasks + +Source of truth: `spec.md` in this folder. + +## Design notes + +- Keep `renderSetupPage(options)` as a self-contained HTML string. Export `SETUP_PAGE = renderSetupPage()` for + callers that need the default markup. +- Reuse `BRAND_CSS`, `LOGO_FAVICON_HREF`, and `renderTopBar`; copy only the setup-specific CSS into this page. +- Keep `buildSetupSelection` and the controller's public methods and request shapes unchanged. +- Browser-time helpers used by the controller must live inside `createSetupPageController`, because that function + is serialized into the page's inline script. +- Escape every dynamic value inserted into `innerHTML`, including preview fields and raw response content. +- Render a concise default preview from `resultado`, `mudancas`, and `validacoes`. Keep the full response behind + a collapsed disclosure if it is retained. +- Preserve the existing element IDs, including `skip-`, even when their labels and styles change. + +## T1. Specify the redesign + +- Requirement: R1-R7. +- Files: `.specs/features/web-setup-redesign/spec.md`, `.specs/features/web-setup-redesign/tasks.md`. +- Produce one acceptance criterion per observable behavior and a coverage matrix at the end of this file. + +## T2. Render the branded setup page + +- Requirement: R1-R3, R6. +- Files: `src/web/setup-page.ts`, `src/web/setup-routes.ts`. +- Render the shared brand chrome and dark usage-style controls. Add `renderSetupPage(options)` and preserve the + `SETUP_PAGE` export. Pass registered pages from the UI route handler, and use a setup-only navigation default for + the standalone setup server. +- Show role bindings, effort, orchestrator preset or readable custom fields, sandbox, and autocompact as human text. +- Replace the role skip copy with explicit Keep current / Change affordance while retaining its ID and behavior. + Default each role to Keep current, collapse its fields, and prefill current binding and effort for Change. +- Keep opencode effort hidden and make the layout responsive. + +## T3. Render readable preview and status + +- Requirement: R4-R5, R7. +- Files: `src/web/setup-page.ts`, `tests/setup-page.test.ts`. +- Render a status banner, change rows, and compact validation state from the existing API envelope. +- Keep the raw response collapsed if shown. Escape all dynamic preview content. +- Put Preview and Apply in one sticky bottom action bar with a selection and request status on the left. Disable both + while loading or while an action is in flight. +- Add focused tests for current-state text, change rendering, validation states, escaping, and action availability. +- Update existing tests only when they pin old markup text, and record each changed assertion with its reason. + +## T4. Wire registered page links + +- Requirement: R1.3-R1.7. +- Files: `src/cli/commands/ui.ts`, `src/web/setup-routes.ts`, `tests/setup-web.test.ts`, `tests/web-cli.test.ts`. +- Pass the UI route table's page list into the setup renderer. Give standalone setup only its Setup link and point its + brand link back to `/setup`. +- Verify the UI route links to Home, Review, Setup, and Usage and standalone setup links only to Setup. + +## T5. Verify behavior and browser layout + +- Requirement: R1-R7. +- Commands: `npx vitest run tests/setup-page.test.ts tests/setup-web.test.ts tests/web-cli.test.ts`; `npx tsc --noEmit -p .`; + `npm run build`; scoped mutation probe; Chromium browser check at 1440x900 and 390x844. +- Record exact commands, pass/fail counts, mutation kills/survivors, console errors, scroll width, and screenshots in + `validation.md`. +- Dispatch a read-only CodeDeck reviewer on the final diff, read its artifact, and address its findings. + +## Coverage matrix + +| Layer | Test type | File | Command | +| --- | --- | --- | --- | +| Role selection and current-state formatting | unit | `tests/setup-page.test.ts` | `npx vitest run tests/setup-page.test.ts` | +| Preview, validations, escaping, and actions | unit | `tests/setup-page.test.ts` | `npx vitest run tests/setup-page.test.ts` | +| Setup route and registered-page navigation | route unit | `tests/setup-web.test.ts` | `npx vitest run tests/setup-web.test.ts` | +| UI route navigation | route unit | `tests/web-cli.test.ts` | `npx vitest run tests/web-cli.test.ts` | +| Setup page and API regression | focused tests | `tests/setup-page.test.ts`, `tests/setup-web.test.ts`, `tests/web-cli.test.ts` | `npx vitest run tests/setup-page.test.ts tests/setup-web.test.ts tests/web-cli.test.ts` | +| Type checking | compiler | TypeScript project | `npx tsc --noEmit -p .` | +| Browser output and responsive width | manual e2e | no committed test | `npm run build`, launch UI, Chromium at 1440x900 and 390x844 | diff --git a/.specs/features/web-setup-redesign/validation.md b/.specs/features/web-setup-redesign/validation.md new file mode 100644 index 0000000..4e7877a --- /dev/null +++ b/.specs/features/web-setup-redesign/validation.md @@ -0,0 +1,131 @@ +# Web setup redesign validation + +## Verification + +- `npm install` completed earlier in this task. It changed the stale package version in `package-lock.json`; that + incidental change was restored. No dependency or lockfile change is part of this work. +- `npx vitest run tests/setup-page.test.ts tests/setup-web.test.ts tests/web-cli.test.ts` exited 1: 1 file passed, + 2 files failed, 20 tests passed, and 22 failed. All 17 route tests in `tests/setup-web.test.ts` failed before + reaching their assertions because the environment returned `listen EPERM: operation not permitted 127.0.0.1`. + Five `tests/web-cli.test.ts` cases also failed after their server could not start. The output identified the same + `listen EPERM` restriction. +- `npx vitest run tests/setup-page.test.ts` passed: 1 file, 16 tests. +- A direct compiled route-handler check using `node --input-type=module` passed. It asserted UI setup links in order + Home, Review, Setup, Usage, and standalone setup links Setup only with its brand link pointing to `/setup`. +- `npx tsc --noEmit -p .` passed with exit code 0 and no diagnostics. +- `npm run build` passed. The script ran `tsc` and `npm run build:plugin`. +- `git diff --check` passed with no whitespace errors. + +## Mutation probe + +Temporarily changed the role prefill from `skip.checked = true` to `skip.checked = false`, then ran: + +```text +npx vitest run tests/setup-page.test.ts -t 'keeps every role unchanged by default and prefills the current binding and effort' +``` + +The test failed at `expect(skip-reviewer.checked).toBe(true)`, showing the default-Keep-current behavior is covered. +The source was restored and the focused setup-page test passed afterward. + +- Mutations killed: 1 of 1. +- Mutations survived: 0. + +## Browser check + +`/usr/bin/chromium --version` reported `Chromium 152.0.7977.82 Arch Linux`. A headless launch smoke test could not +start Chromium: + +```text +/usr/bin/chromium --headless --no-sandbox --disable-gpu --disable-dev-shm-usage --user-data-dir=/tmp/setup-redesign/chromium-smoke-profile --dump-dom 'data:text/html,smokeok' +exit 133 +[ERROR:third_party/crashpad/crashpad/util/linux/socket.cc:45] setsockopt: Operation not permitted (1) +``` + +After `npm run build`, the UI launch command also could not bind its local server: + +```text +env RUN_AGENT_DIR=/tmp/setup-redesign/run-agent RUN_AGENT_CONFIG_DIR=/tmp/setup-redesign/config node dist/cli/index.js ui --no-open --port 3147 +Failed to listen on 127.0.0.1:3147: listen EPERM: operation not permitted 127.0.0.1:3147 +``` + +The real `/setup` browser interaction therefore did not run. Console errors and 390px `scrollWidth` could not be +measured. The requested screenshots were not refreshed: + +- `/tmp/setup-redesign/desktop-before-preview.png` +- `/tmp/setup-redesign/desktop-after-preview.png` +- `/tmp/setup-redesign/mobile.png` + +Those paths contain screenshots from the earlier implementation and are stale for this corrective round. + +## Existing assertions + +- `tests/setup-web.test.ts` now asserts Setup-only navigation for its standalone route. It replaces expectations for + Home, Review, and Usage links that are not served by standalone `codedeck setup`; the brand link now returns to + `/setup` too. +- `tests/setup-page.test.ts` changes the expected current sandbox text from `workspace write` to the literal + `workspace-write`, as requested. No existing test asserted that bound roles began with Change. +- `tests/web-cli.test.ts` adds assertions that the UI-served setup navigation includes Home, Review, Setup, and Usage. + +## Reviewer findings + +Both findings from reviewer session `205e` are addressed in the current diff: + +- `src/cli/commands/ui.ts` rebuilds setup routes with the UI page list. The route-handler check confirmed Home, + Review, Setup, and Usage navigation. +- `src/web/setup-routes.ts` supplies Setup only when no page list is provided. The route-handler check confirmed + Setup-only navigation and a brand link to `/setup` for standalone setup. + +A final reviewer dispatch was attempted with `codedeck run --role reviewer --no-worktree --bg --json`, but CodeDeck +could not start its daemon (`Failed to start daemon`). It was retried after the final alignment change with the same +result, so the required read-only final review artifact is unavailable. + +## Final role-grid adjustment verification + +After adding `align-items: start` to the role grid: + +- `npx vitest run tests/setup-page.test.ts` passed: 1 file, 16 tests. +- `npx tsc --noEmit -p .` passed with exit code 0 and no diagnostics. +- `npm run build` passed, including `tsc` and `build:plugin`. + +## Orchestrator-verified results + +The orchestrator verified the prior implementation outside this sandbox in the session `2cfc` worktree on +2026-09-24: + +- `npx vitest run tests/setup-page.test.ts tests/setup-web.test.ts tests/web-cli.test.ts`: 42 of 42 tests passed. +- TypeScript check: clean. +- Headless Chromium against `dist`: navigation showed Home, Review, Setup, and Usage; all roles defaulted to Keep + current; changing only reviewer effort produced exactly `reviewer · effort`, `low → high`; mobile + `document.documentElement.scrollWidth` was 390; console errors: zero. + +This browser run predates the final `align-items: start` role-grid adjustment. After that adjustment, the local +focused setup-page test, TypeScript check, and build results are recorded above and below; the sandbox still prevents +local headless Chromium from starting. + +## Review remediation + +- `createSetupPageController` now records the last rendered envelope by reference and its preview error. Form updates + keep the existing `setup-result` DOM, while a new response renders a new preview. +- `createUiRoutes` creates the setup routes once with a shared page list. The setup and usage top bars include Home, + Review, Setup, and Usage. The home page receives the same list without its Home entry. +- Added assertions that the home page has no self-link and setup navigation still links Home. Added an innerHTML write + counter test for preview preservation and response replacement. +- Extended the manually resolved in-flight action test to assert `Preparing preview...` and `Saving setup...`. + +Verification after the remediation: + +- `npx vitest run tests/setup-page.test.ts`: passed, 1 file and 17 tests. +- `npx tsc --noEmit -p .`: passed with exit code 0 and no diagnostics. +- `npx vitest run tests/web-cli.test.ts tests/setup-web.test.ts`: passed, 2 files and 26 tests. Local HTTP listeners + worked in this run. +- `git diff --check`: passed with no whitespace errors. +- Read-only review: `codedeck run --role reviewer --no-worktree --bg --json` completed as session `3933`; the + reviewer reported no findings. The review also ran `npx vitest run tests/setup-page.test.ts tests/web-cli.test.ts`, + which passed with 2 files and 26 tests. + +Mutation probe: + +- Temporarily restored unconditional `setup-result.innerHTML` assignment and ran + `npx vitest run tests/setup-page.test.ts -t 'preserves the rendered raw preview on form updates and refreshes it for a new response'`. + The regression test failed as expected: it observed 7 writes instead of 6 after a binding input event. The guard was + restored, then the full setup-page test passed with 17 of 17 tests. diff --git a/src/cli/commands/ui.ts b/src/cli/commands/ui.ts index c59ca95..9b983bf 100644 --- a/src/cli/commands/ui.ts +++ b/src/cli/commands/ui.ts @@ -5,6 +5,7 @@ import { renderHomePage } from "../../web/home-page.js"; import { createSetupRoutes, type SetupRoutesDependencies } from "../../web/setup-routes.js"; import { DEFAULT_WEB_PORT, parseWebPort, startWebServer, type WebRoute } from "../../web/server.js"; import { createUsageRoutes, type UsageRoutesOptions } from "../../web/usage-routes.js"; +import type { WebPageLink } from "../../web/brand.js"; export interface UiCommandOptions { port?: string; @@ -19,14 +20,18 @@ export interface UiCommandDependencies { export function createUiRoutes(dependencies: Pick = {}): WebRoute[] { const reviewRoutes = createReviewRoutes().filter((route) => route.path !== "/"); - const setupRoutes = createSetupRoutes(dependencies.setup); + const pages: WebPageLink[] = []; + const setupRoutes = createSetupRoutes({ ...dependencies.setup, pages }); + const routes = [...reviewRoutes, ...setupRoutes]; const usageOptions = dependencies.usage ?? { fetchUsageQuery }; const labeledUsageRoutes = createUsageRoutes(usageOptions).map((route) => route.path === "/usage" ? { ...route, label: "Usage" } : route, ); - const routes = [...reviewRoutes, ...setupRoutes]; - const pages = [...routes, ...labeledUsageRoutes].flatMap((route) => - route.kind === "page" && route.label ? [{ label: route.label, path: route.path }] : [], + pages.push( + { label: "Home", path: "/" }, + ...[...routes, ...labeledUsageRoutes].flatMap((route) => + route.kind === "page" && route.label ? [{ label: route.label, path: route.path }] : [], + ), ); const usageRoutes = createUsageRoutes({ ...usageOptions, pages }).map((route) => route.path === "/usage" ? { ...route, label: "Usage" } : route, @@ -36,7 +41,7 @@ export function createUiRoutes(dependencies: Pick { response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); - response.end(renderHomePage(pages)); + response.end(renderHomePage(pages.filter((page) => page.path !== "/"))); }, }; return [home, ...routes, ...usageRoutes]; diff --git a/src/web/setup-page.ts b/src/web/setup-page.ts index defe374..ff1d5de 100644 --- a/src/web/setup-page.ts +++ b/src/web/setup-page.ts @@ -4,6 +4,7 @@ import type { BatchModelsResult } from "../core/models.js"; import type { RoleBinding, RunAgentConfig } from "../config/config.js"; import { ORCHESTRATOR_PRESETS, type OrchestratorMode } from "../config/orchestrator-mode.js"; import type { SetupSelection } from "../config/setup.js"; +import { BRAND_CSS, LOGO_FAVICON_HREF, renderTopBar, type WebPageLink } from "./brand.js"; export const SETUP_SESSION_EXPIRED_MESSAGE = "This CodeDeck session has expired. Reload the page. If it still fails, restart the command and open its new URL."; @@ -151,11 +152,16 @@ export interface SetupPageControllerOptions { ) => ReturnType; roles: readonly Role[]; expiredMessage: string; + presets?: Record; document?: SetupPageDocument; confirm?: (message: string) => boolean; onChange?: (state: SetupPageClientState) => void; } +export interface SetupPageOptions { + pages?: WebPageLink[]; +} + export interface SetupPageTargetState { target: { kind: "global" }; bindings: Partial>; @@ -178,8 +184,160 @@ export interface SetupPageClientState { export function createSetupPageController(options: SetupPageControllerOptions) { const state: SetupPageClientState = { refreshing: false, loading: true }; let refreshInFlight: Promise | undefined; + let actionInFlight = false; + let actionInFlightLabel = ""; + let lastRenderedEnvelope: unknown; + let lastRenderedError: string | undefined; + let hasRenderedPreview = false; const element = (id: string) => options.document?.getElementById(id) ?? null; + function escapeHtml(value: unknown): string { + return String(value).replace(/[&<>"']/g, (character) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[character] ?? character); + } + + function formatCurrentOrchestrator(mode: OrchestratorMode | undefined): string { + if (!mode) return "not set"; + const preset = Object.entries(options.presets ?? {}).find(([, value]) => + value.investigate === mode.investigate && + value.selfWork === mode.selfWork && + value.tools === mode.tools && + value.parallelism === mode.parallelism, + ); + if (preset) return preset[0]; + + const investigate: Record = { + none: "no investigation", + read: "read only", + free: "full access", + }; + const selfWork: Record = { + none: "off", + trivial: "trivial tasks", + small: "small tasks", + }; + return [ + `custom · investigate: ${investigate[mode.investigate]}`, + `self work: ${selfWork[mode.selfWork]}`, + `tools: ${mode.tools}`, + ...(mode.parallelism === undefined ? [] : [`parallelism: ${mode.parallelism}`]), + ].join(" · "); + } + + function formatPreviewPath(path: string): string { + const parts = path.split("/").slice(1).map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")); + const labels: Record = { + agents: "agents", + harness: "harness", + model: "model", + effort: "effort", + orchestrator: "orchestrator", + investigate: "investigation", + selfWork: "self work", + tools: "tools", + parallelism: "parallelism", + defaultSandbox: "sandbox", + autocompact: "autocompact", + enabled: "enabled", + cap: "cap", + }; + if (parts[0] === "agents" && parts[1]) { + return [parts[1], ...parts.slice(2).map((part) => labels[part] ?? part)].join(" · "); + } + return parts.map((part) => labels[part] ?? part).join(" · ") || "configuration"; + } + + function formatPreviewValue(value: unknown): string { + if (value === null || value === undefined) return "none"; + if (typeof value === "boolean") return value ? "on" : "off"; + if (Array.isArray(value)) return value.map((item) => formatPreviewValue(item)).join(", ") || "none"; + if (typeof value === "object") { + return Object.entries(value as Record) + .map(([key, item]) => `${key}: ${formatPreviewValue(item)}`) + .join(", ") || "none"; + } + return String(value); + } + + function renderPreview(envelope: unknown, fallbackError?: string): string { + const body = typeof envelope === "object" && envelope !== null + ? envelope as Record + : {}; + const result = typeof body.resultado === "object" && body.resultado !== null + ? body.resultado as Record + : {}; + const resultStatus = typeof result.status === "string" ? result.status : "error"; + const statusLabels: Record = { + "dry-run": "Preview only, nothing written", + applied: "Saved", + unchanged: "Unchanged", + aborted: "Cancelled", + error: "Error", + }; + const statusClass = ["dry-run", "applied", "unchanged", "aborted", "error"].includes(resultStatus) + ? resultStatus + : "error"; + const message = typeof result.message === "string" + ? result.message + : typeof body.error === "string" + ? body.error + : fallbackError ?? ""; + const changes = Array.isArray(body.mudancas) ? body.mudancas.filter((item) => { + if (typeof item !== "object" || item === null) return false; + const path = typeof (item as Record).path === "string" + ? (item as Record).path as string + : ""; + const parts = path.split("/").slice(1).map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")); + return !parts.includes("profiles") && !parts.includes("activeProfile"); + }) as Array> : []; + const changeRows = changes.map((change) => { + const path = typeof change.path === "string" ? change.path : ""; + const beforePresent = change.beforePresent !== false; + const afterPresent = change.afterPresent !== false; + const before = beforePresent ? escapeHtml(formatPreviewValue(change.before)) : 'Added'; + const after = afterPresent ? escapeHtml(formatPreviewValue(change.after)) : 'Removed'; + return `
${escapeHtml(formatPreviewPath(path))}${before}→${after}
`; + }).join(""); + + function validationRow(label: string, value: unknown): string { + if (typeof value !== "object" || value === null) return ""; + const validation = value as Record; + const status = typeof validation.status === "string" ? validation.status : "not checked"; + const messageText = typeof validation.message === "string" ? validation.message.trim() : ""; + const ok = ["ok", "accepted", "fresh", "not-needed"].includes(status); + return `
${escapeHtml(label)} · ${escapeHtml(status.replaceAll("-", " "))}${messageText ? `${escapeHtml(messageText)}` : ""}
`; + } + + const validations = typeof body.validacoes === "object" && body.validacoes !== null + ? body.validacoes as Record + : {}; + const bindingRows = Array.isArray(validations.bindings) ? validations.bindings.map((item) => { + if (typeof item !== "object" || item === null) return ""; + const binding = item as Record; + const role = typeof binding.role === "string" ? binding.role : "binding"; + const status = typeof binding.status === "string" ? binding.status : "not checked"; + const identity = [binding.harness, binding.model].filter((part) => typeof part === "string").join(":"); + const label = `${role}${identity ? ` · ${identity}` : ""}`; + const messageText = typeof binding.message === "string" ? binding.message.trim() : ""; + const ok = status === "accepted"; + return `
${escapeHtml(label)} · ${escapeHtml(status.replaceAll("-", " "))}${messageText ? `${escapeHtml(messageText)}` : ""}
`; + }).join("") : ""; + const validationRows = [ + validationRow("Config", validations.config), + validationRow("Catalog", validations.catalogo), + bindingRows, + ].filter(Boolean).join(""); + const rawResponse = escapeHtml(JSON.stringify(envelope, null, 2) ?? String(envelope)); + const displayMessage = resultStatus === "dry-run" ? "" : message; + + return `
${escapeHtml(statusLabels[resultStatus] ?? "Request failed")}${displayMessage ? `${escapeHtml(displayMessage)}` : ""}

Changes

${changeRows || '

No changes

'}

Checks

${validationRows || '

No validation details

'}
Raw response
${rawResponse}
`; + } + function update(): void { const status = element("setup-status"); if (status) { @@ -189,6 +347,32 @@ export function createSetupPageController(options: SetupPageControllerOptions) { ? "Loading setup..." : "Setup loaded."); } + const actionStatus = element("setup-action-status"); + if (actionStatus) { + if (state.error) { + actionStatus.textContent = state.error; + } else if (state.refreshing) { + actionStatus.textContent = "Discovering models..."; + } else if (state.loading) { + actionStatus.textContent = "Loading setup..."; + } else if (actionInFlight) { + actionStatus.textContent = actionInFlightLabel; + } else { + const rolesChanging = options.roles.filter((role) => !element(`skip-${role}`)?.checked).length; + const settingsChanging = ["orchestrator-mode", "setup-sandbox", "setup-autocompact"] + .filter((id) => { + const control = element(id); + return Boolean(control?.value) && control?.value !== "skip"; + }).length; + const parts = [ + rolesChanging === 0 ? "" : `${rolesChanging} ${rolesChanging === 1 ? "role" : "roles"}`, + settingsChanging === 0 ? "" : `${settingsChanging} ${settingsChanging === 1 ? "setting" : "settings"}`, + ].filter(Boolean); + actionStatus.textContent = parts.length === 0 + ? "No changes selected" + : `${parts.join(" and ")} set to change`; + } + } const targetLabel = element("setup-target"); if (targetLabel && state.target) { targetLabel.textContent = "Global configuration"; @@ -203,27 +387,47 @@ export function createSetupPageController(options: SetupPageControllerOptions) { const refreshButton = element("setup-refresh"); if (refreshButton) refreshButton.disabled = state.refreshing; const result = element("setup-result"); - if (result) result.textContent = state.envelope === undefined ? "" : JSON.stringify(state.envelope, null, 2); + if (result && (!hasRenderedPreview || state.envelope !== lastRenderedEnvelope || state.error !== lastRenderedError)) { + result.innerHTML = state.envelope === undefined ? "" : renderPreview(state.envelope, state.error); + lastRenderedEnvelope = state.envelope; + lastRenderedError = state.error; + hasRenderedPreview = true; + } const currentOrchestrator = element("current-orchestrator"); if (currentOrchestrator && state.target) { - currentOrchestrator.textContent = state.target.orchestrator === undefined - ? "not set" - : JSON.stringify(state.target.orchestrator); + currentOrchestrator.textContent = formatCurrentOrchestrator(state.target.orchestrator); } const currentSandbox = element("current-sandbox"); - if (currentSandbox && state.target) currentSandbox.textContent = state.target.sandbox ?? "not set"; + if (currentSandbox && state.target) { + currentSandbox.textContent = state.target.sandbox ?? "not set"; + } const currentAutocompact = element("current-autocompact"); if (currentAutocompact && state.target) { currentAutocompact.textContent = state.target.autocompact === undefined ? "not set" - : JSON.stringify(state.target.autocompact); + : state.target.autocompact.enabled ? "on" : "off"; + } + if (state.target) { + for (const role of options.roles) { + const current = element(`current-binding-${role}`); + if (!current) continue; + const binding = state.target.bindings[role]; + if (!binding) { + current.innerHTML = 'not set'; + continue; + } + const effort = state.target.efforts?.[role] ?? binding.effort ?? "default"; + current.innerHTML = `${escapeHtml(binding.harness)}${escapeHtml(binding.model)}${escapeHtml(effort)} effort`; + } } for (const role of options.roles) { const skip = element(`skip-${role}`); const binding = element(`binding-${role}`); const effort = element(`effort-${role}`); + const fields = element(`role-fields-${role}`); const skipped = Boolean(skip?.checked); + if (fields) fields.hidden = skipped; if (binding) binding.disabled = skipped; const harness = (binding?.value ?? "").split(":", 1)[0].trim(); const noEffort = harness === "opencode"; @@ -235,6 +439,10 @@ export function createSetupPageController(options: SetupPageControllerOptions) { const custom = element("orchestrator-custom"); if (custom) custom.hidden = element("orchestrator-mode")?.value !== "custom"; + for (const id of ["setup-dry-run", "setup-apply"]) { + const action = element(id); + if (action) action.disabled = state.loading || state.refreshing || actionInFlight || !state.target; + } options.onChange?.(state); } @@ -263,9 +471,9 @@ export function createSetupPageController(options: SetupPageControllerOptions) { const skip = element(`skip-${role}`); const bindingInput = element(`binding-${role}`); const effort = element(`effort-${role}`); - if (skip) skip.checked = binding === undefined; + if (skip) skip.checked = true; if (bindingInput) bindingInput.value = binding === undefined ? "" : `${binding.harness}:${binding.model}`; - if (effort) effort.value = "keep"; + if (effort) effort.value = target.efforts?.[role] ?? binding?.effort ?? "keep"; } update(); } @@ -295,8 +503,12 @@ export function createSetupPageController(options: SetupPageControllerOptions) { for (const role of options.roles) { element(`skip-${role}`)?.addEventListener?.("change", update); element(`binding-${role}`)?.addEventListener?.("input", update); + element(`effort-${role}`)?.addEventListener?.("change", update); } - element("orchestrator-mode")?.addEventListener?.("change", update); + for (const id of ["orchestrator-mode", "orchestrator-investigate", "orchestrator-self-work", "orchestrator-tools", "setup-sandbox", "setup-autocompact"]) { + element(id)?.addEventListener?.("change", update); + } + element("orchestrator-parallelism")?.addEventListener?.("input", update); element("setup-refresh")?.addEventListener?.("click", () => { void refreshCatalog(); }); element("setup-dry-run")?.addEventListener?.("click", () => { void dryRun(); }); element("setup-apply")?.addEventListener?.("click", () => { void apply(); }); @@ -318,6 +530,7 @@ export function createSetupPageController(options: SetupPageControllerOptions) { async function start(): Promise { bindEvents(); + update(); try { const { response, payload } = await loadJson("/api/setup/state"); if (!response.ok) { @@ -391,6 +604,9 @@ export function createSetupPageController(options: SetupPageControllerOptions) { selection: ReturnType, ): Promise<{ ok: boolean; status: number; payload: unknown }> { state.error = undefined; + actionInFlight = true; + actionInFlightLabel = path.endsWith("/dry-run") ? "Preparing preview..." : "Saving setup..."; + update(); try { const response = await options.fetcher(path, { method: "POST", @@ -418,6 +634,9 @@ export function createSetupPageController(options: SetupPageControllerOptions) { state.error = error instanceof Error ? error.message : String(error); update(); return { ok: false, status: 500, payload: { error: state.error } }; + } finally { + actionInFlight = false; + update(); } } @@ -469,97 +688,97 @@ export function createSetupPageController(options: SetupPageControllerOptions) { } const roleControls = ROLES.map((role) => ` -
- ${role} - - - - - - -
`).join(""); - -export const SETUP_PAGE = ` +
+

${role}

+

Currentnot set

+ + +
`).join(""); + +export function renderSetupPage(options: SetupPageOptions = {}): string { + const pages = options.pages ?? [{ label: "Setup", path: "/setup" }]; + const renderedTopbar = renderTopBar({ pages, activePath: "/setup", title: "Setup" }); + const topbar = pages.length === 1 && pages[0]?.path === "/setup" + ? renderedTopbar.replace('class="brand" href="/"', 'class="brand" href="/setup"') + : renderedTopbar; + return ` CodeDeck setup - + + -
-

CodeDeck setup

-

Review the current configuration, prepare a proposal, then apply it.

-
- Global configuration -

Loading setup...

-

Model catalog: not loaded

- + ${topbar} +
+
+

Setup

Choose model bindings and runtime defaults for this workspace.

+ Global configuration +
+ +
+
Setup stateLoading setup...
+
Model catalognot loaded
+
-

Role bindings

-
${roleControls} +
+

Role bindings

Keep the current model or choose a new binding for each role.

+
${roleControls}
-

Orchestrator

-
-

Current: unchanged

- - - +
+

Orchestrator

Choose a preset or describe a custom policy.

+
+

Currentnot set

+ + + +
-

Other settings

-
-

Current sandbox: not set

-

Current autocompact: not set

+
+

Other settings

+
+

Current sandboxnot set

+

Current autocompactnot set

+
-
- - +
+

Preview

Check each change before saving.

+
+
+
+ Loading setup... + +
-

Proposal

-

   
`; +} + +export const SETUP_CSS = ` +.topbar{height:52px;display:flex;align-items:center;gap:24px;padding:0 28px;border-bottom:1px solid var(--border);background:var(--bg)} +.brand{display:flex;align-items:center;gap:8px;color:var(--text);text-decoration:none;font-weight:600;font-size:15px;letter-spacing:-.3px}.brand svg{display:block} +.topbar-title{font-size:13px;color:var(--text-muted);border-left:1px solid var(--border-strong);padding-left:18px}.topbar nav{display:flex;gap:20px;margin-left:auto}.topbar nav a{color:var(--text-faint);font-size:12px;text-decoration:none}.topbar nav a:hover,.topbar nav a.active{color:var(--text)} +.setup-main{width:min(100%,1320px);margin:0 auto;padding:30px 28px 20px}h1,h2,h3,p{margin-top:0}h1{margin-bottom:7px;font-size:30px;line-height:1.15;letter-spacing:-.7px}h2{margin-bottom:0;font-size:19px;line-height:1.25;letter-spacing:-.3px}h3{margin-bottom:12px;font-size:14px} +.page-heading{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:24px}.intro{margin-bottom:0;color:var(--text-muted);font-size:13px} +.target-pill,.pill{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border-strong);border-radius:999px;background:var(--surface-raised);padding:5px 9px;color:var(--text-muted);font-size:11px;white-space:nowrap} +.card,.role-card,.preview-panel{min-width:0;border:1px solid var(--border);border-radius:9px;background:var(--surface);padding:16px}.load-card{display:flex;align-items:center;gap:24px;margin-bottom:29px}.load-copy,.catalog-copy{display:grid;gap:3px;min-width:0}.load-copy{flex:1}.load-copy strong{font-size:12px}.load-copy span,.catalog-copy span:last-child{color:var(--text-muted);font-size:12px;overflow-wrap:anywhere}.catalog-copy{min-width:160px}.muted{color:var(--text-faint);font-size:11px} +.section-block{margin-top:28px}.section-heading{display:flex;align-items:end;justify-content:space-between;gap:16px;margin-bottom:12px}.section-heading>p{margin:0;color:var(--text-faint);font-size:11px}.roles{display:grid;align-items:start;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px} +.role-card{margin:0;padding:14px}.role-card-header{margin-bottom:10px}.role-title{margin:0;color:var(--text);font-size:13px;font-weight:650;text-transform:capitalize}.role-fields[hidden]{display:none}.current-line{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:0 0 13px}.current-binding{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:6px;min-width:0;text-align:right}.harness-pill{padding:3px 7px;color:var(--text)}.model-id{min-width:0;color:var(--text);font:11px var(--font-mono);overflow-wrap:anywhere}.effort-value{color:var(--text-faint);font-size:10px;white-space:nowrap}.not-set,.current-text{color:var(--text-muted);font-size:11px}.mono-value{font-family:var(--font-mono)} +.role-mode{position:relative;display:flex;align-items:center;gap:3px;margin:0 0 14px;padding:3px;border:1px solid var(--border);border-radius:6px;background:#000;color:var(--text-faint);cursor:pointer}.role-mode input{position:absolute;width:1px;height:1px;opacity:0}.role-mode:focus-within{outline:2px solid var(--blue);outline-offset:2px}.mode-option{flex:1;padding:5px 6px;border-radius:4px;text-align:center;font-size:10px}.role-mode input:checked~.mode-keep,.role-mode input:not(:checked)~.mode-change{background:var(--surface-raised);color:var(--text)} +.field-label{display:block;margin:11px 0 5px;color:var(--text-muted);font-size:11px}input[type="text"],input[type="number"],select{display:block;width:100%;min-width:0;min-height:36px;border:1px solid var(--border-strong);border-radius:6px;background:#050505;color:var(--text);padding:8px 9px;font:12px var(--font-sans);color-scheme:dark}input[type="number"]{font-family:var(--font-mono)}input::placeholder{color:var(--text-faint)}input:focus,select:focus{outline:2px solid var(--blue);outline-offset:1px}input:disabled,select:disabled{opacity:.48}select option{background:#0a0a0a;color:var(--text)} +.settings-card{max-width:none}.settings-card>.field-label,.settings-card>select{max-width:390px}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.settings-card .settings-grid{margin-top:15px}.settings-card .settings-grid[hidden]{display:none}.setting-card{min-width:0} +.button{display:inline-flex;align-items:center;justify-content:center;min-height:36px;border:1px solid var(--border-strong);border-radius:6px;padding:8px 13px;background:var(--surface);color:var(--text);font:600 12px var(--font-sans);cursor:pointer}.button:hover:not(:disabled){background:var(--surface-hover)}.button.primary{border-color:var(--blue);background:var(--blue);color:#fff}.button.primary:hover:not(:disabled){background:#0063d9}.button:disabled{cursor:not-allowed;opacity:.45} +.actions{position:sticky;bottom:0;z-index:20;display:flex;align-items:center;gap:8px;margin:24px 0 0;padding:11px 14px;border:0;border-top:1px solid var(--border);border-radius:0;background:rgba(10,10,10,.96);backdrop-filter:blur(12px)}.action-status{min-width:0;margin-right:auto;color:var(--text-muted);font-size:11px;overflow-wrap:anywhere} +.preview-panel{padding:16px}.preview-panel .section-heading{margin-bottom:16px}.result-content:empty{display:none}.result-status{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:15px;padding:11px 13px;border:1px solid var(--border-strong);border-radius:7px;background:#080808;font-size:12px}.result-status strong{color:var(--text)}.result-status span{color:var(--text-muted);overflow-wrap:anywhere}.result-status.status-applied{border-color:rgba(16,185,129,.45)}.result-status.status-error{border-color:rgba(229,72,77,.55)}.result-status.status-dry-run{border-color:rgba(0,112,243,.5)}.preview-section{margin-top:15px}.preview-section h3{margin-bottom:8px;color:var(--text-muted);font-size:11px;font-weight:600} +.change-list{display:grid;gap:5px}.change-row{display:flex;align-items:center;justify-content:flex-start;flex-wrap:wrap;gap:6px 14px;min-width:0;padding:9px 10px;border:1px solid var(--border);border-radius:6px;background:#050505}.change-path{color:var(--text);font-size:11px;overflow-wrap:anywhere}.change-values{display:flex;align-items:center;gap:8px;min-width:0}.change-value{min-width:0;color:var(--text-muted);font:11px var(--font-mono);overflow-wrap:anywhere}.change-arrow{flex:none;color:var(--text-faint)}.change-marker{color:var(--blue-chart);font:600 10px var(--font-sans);text-transform:uppercase} +.validation-list{display:flex;align-items:center;flex-wrap:wrap;gap:7px 12px}.validation-item{display:flex;align-items:center;flex-wrap:wrap;gap:7px;min-width:0}.validation-message{color:var(--text-faint);font-size:11px;overflow-wrap:anywhere}.pill-ok{border-color:rgba(16,185,129,.4);color:#8fe0bc}.empty{margin:8px 0;color:var(--text-faint);font-size:12px}.raw-response{margin-top:14px;border-top:1px solid var(--border);padding-top:10px;color:var(--text-muted);font-size:11px}.raw-response summary{cursor:pointer}.raw-response pre{max-height:380px;overflow:auto;margin:10px 0 0;padding:12px;border:1px solid var(--border);border-radius:6px;background:#050505;color:var(--text-muted);font:10px/1.5 var(--font-mono);white-space:pre-wrap;overflow-wrap:anywhere}[hidden]{display:none!important} +@media(max-width:900px){.setup-main{padding:24px 20px 20px}.roles{grid-template-columns:repeat(2,minmax(0,1fr))}} +@media(max-width:600px){.topbar{height:auto;min-height:52px;gap:8px;padding:9px 12px;flex-wrap:wrap}.topbar-title{padding-left:9px;font-size:11px}.topbar nav{gap:9px}.topbar nav a{font-size:10px}.setup-main{padding:20px 12px 12px}.page-heading{align-items:flex-start;flex-direction:column;gap:10px}.load-card{align-items:stretch;flex-wrap:wrap;gap:12px}.load-copy{flex-basis:100%}.catalog-copy{flex:1;min-width:120px}.roles,.settings-grid{grid-template-columns:minmax(0,1fr)}.section-heading{align-items:flex-start;flex-direction:column;gap:5px}.actions{padding:10px 8px}.action-status{font-size:10px}.actions .button{flex:1;padding:8px 7px}.current-line{align-items:flex-start}} +`; + +export const SETUP_PAGE = renderSetupPage(); diff --git a/src/web/setup-routes.ts b/src/web/setup-routes.ts index e531fd4..4262054 100644 --- a/src/web/setup-routes.ts +++ b/src/web/setup-routes.ts @@ -25,13 +25,15 @@ import { } from "../config/setup.js"; import { getPaths } from "../config/paths.js"; import { getRegistry } from "../drivers/registry.js"; -import { SETUP_PAGE } from "./setup-page.js"; +import { renderSetupPage } from "./setup-page.js"; +import type { WebPageLink } from "./brand.js"; import type { WebRoute } from "./server.js"; const MAX_SETUP_BODY_BYTES = 64 * 1024; const MODEL_PATTERN = /^[^\p{White_Space}\p{Cc}\p{Cf}=]+$/u; export interface SetupRoutesDependencies { + pages?: WebPageLink[]; readConfig?: () => SetupConfigRead; saveConfig?: (config: RunAgentConfig) => void | boolean; registry?: DriverRegistry; @@ -396,7 +398,7 @@ export function createSetupRoutes(dependencies: SetupRoutesDependencies = {}): W function page(_request: IncomingMessage, response: ServerResponse): void { response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); - response.end(SETUP_PAGE); + response.end(renderSetupPage({ pages: dependencies.pages ?? [{ label: "Setup", path: "/setup" }] })); } function stateRoute(_request: IncomingMessage, response: ServerResponse): void { diff --git a/tests/setup-page.test.ts b/tests/setup-page.test.ts index f3638a7..9110d68 100644 --- a/tests/setup-page.test.ts +++ b/tests/setup-page.test.ts @@ -13,6 +13,7 @@ import { type SetupPageElement, type SetupPageFormValues, type SetupPageResponse, + type SetupPageControllerOptions, } from "../src/web/setup-page.js"; function form(overrides: Partial = {}): SetupPageFormValues { @@ -41,15 +42,48 @@ function response(payload: unknown, status = 200): SetupPageResponse { return { status, ok: status >= 200 && status < 300, json: async () => payload }; } -function fakeDocument(): { document: SetupPageDocument; elements: Map } { +function fakeDocument(): { + document: SetupPageDocument; + elements: Map; + innerHtmlWrites: Map; + dispatch(id: string, event: string): void; +} { const elements = new Map(); + const listeners = new Map void>>(); + const innerHtmlWrites = new Map(); return { elements, + innerHtmlWrites, + dispatch(id, event) { + listeners.get(id)?.get(event)?.(); + }, document: { getElementById(id) { let element = elements.get(id); if (!element) { - element = { value: "", checked: false, disabled: false, hidden: false, textContent: "" }; + let innerHTML = ""; + element = { + value: "", + checked: false, + disabled: false, + hidden: false, + textContent: "", + get innerHTML() { + return innerHTML; + }, + set innerHTML(value) { + innerHTML = value; + innerHtmlWrites.set(id, (innerHtmlWrites.get(id) ?? 0) + 1); + }, + addEventListener(name, callback) { + let elementListeners = listeners.get(id); + if (!elementListeners) { + elementListeners = new Map(); + listeners.set(id, elementListeners); + } + elementListeners.set(name, callback); + }, + }; elements.set(id, element); } return element; @@ -58,6 +92,26 @@ function fakeDocument(): { document: SetupPageDocument; elements: Map = {}, +) { + return createSetupPageController({ + fetcher, + buildSelection: (values, bindings) => buildSetupSelection(values, bindings, { + roles: ROLES, + efforts: REASONING_EFFORTS, + presets: ORCHESTRATOR_PRESETS, + }), + roles: ROLES, + presets: ORCHESTRATOR_PRESETS, + expiredMessage: SETUP_SESSION_EXPIRED_MESSAGE, + document, + ...overrides, + }); +} + describe("setup page selection", () => { it("offers every role, free-text binding fields, effort choices, and all setup fields", () => { for (const role of ROLES) { @@ -78,6 +132,251 @@ describe("setup page selection", () => { expect(SETUP_PAGE).toContain('value="off"'); expect(SETUP_PAGE).toContain('value="custom"'); expect(SETUP_PAGE).toContain('id="orchestrator-parallelism" type="number"'); + expect(SETUP_PAGE).toContain('href="data:image/svg+xml,'); + expect(SETUP_PAGE).toContain('aria-current="page" class="active">Setup'); + expect(SETUP_PAGE).toContain('href="/setup" aria-label="CodeDeck home"'); + expect(SETUP_PAGE).not.toContain('href="/review"'); + expect(SETUP_PAGE).not.toContain('href="/usage"'); + expect(SETUP_PAGE).toContain('Keep current'); + expect(SETUP_PAGE).toContain('Change'); + expect(SETUP_PAGE).toContain('class="roles"'); + expect(SETUP_PAGE).toContain('

reviewer

'); + expect(SETUP_PAGE).toContain('id="role-fields-reviewer" class="role-fields" hidden>'); + expect(SETUP_PAGE).not.toContain("Apply setup'); + expect(SETUP_PAGE).not.toContain("Skip this role"); + expect(SETUP_PAGE).not.toContain('
');
+  });
+
+  it("renders the current bindings and runtime settings as readable values", async () => {
+    const { document, elements } = fakeDocument();
+    const controller = makeController(document, async (path) => path === "/api/setup/state"
+      ? response({
+        target: { kind: "global" },
+        bindings: { reviewer: { harness: "codex", model: "model", effort: "high" } },
+        efforts: { reviewer: "high" },
+        orchestrator: { ...ORCHESTRATOR_PRESETS.balanced },
+        sandbox: "workspace-write",
+        autocompact: { enabled: false },
+      })
+      : response({ models: [], status: "fresh", source: "cache", ageMs: 0, cacheWriteFailed: false }));
+    await controller.start();
+
+    expect(elements.get("current-binding-reviewer")?.innerHTML).toContain('codex');
+    expect(elements.get("current-binding-reviewer")?.innerHTML).toContain("model<one>");
+    expect(elements.get("current-binding-reviewer")?.innerHTML).toContain("high effort");
+    expect(elements.get("current-binding-general")?.innerHTML).toContain("not set");
+    expect(elements.get("current-orchestrator")?.textContent).toBe("balanced");
+    expect(elements.get("current-sandbox")?.textContent).toBe("workspace-write");
+    expect(SETUP_PAGE).toContain('id="current-sandbox" class="current-text mono-value"');
+    expect(elements.get("current-autocompact")?.textContent).toBe("off");
+    expect(elements.get("current-orchestrator")?.textContent).not.toContain("{");
+    expect(elements.get("current-autocompact")?.textContent).not.toContain("{");
+  });
+
+  it("keeps every role unchanged by default and prefills the current binding and effort", async () => {
+    const { document, elements, dispatch } = fakeDocument();
+    const controller = makeController(document, async (path) => path === "/api/setup/state"
+      ? response({
+        target: { kind: "global" },
+        bindings: {
+          general: { harness: "claude", model: "gpt-no-effort" },
+          reviewer: { harness: "codex", model: "gpt-current", effort: "low" },
+        },
+        efforts: { reviewer: "low" },
+      })
+      : response({ models: [], status: "fresh", source: "cache", ageMs: 0, cacheWriteFailed: false }));
+    for (const id of ["orchestrator-mode", "setup-sandbox", "setup-autocompact"]) {
+      elements.set(id, { value: "skip" });
+    }
+
+    await controller.start();
+
+    expect(elements.get("skip-reviewer")?.checked).toBe(true);
+    expect(elements.get("role-fields-reviewer")?.hidden).toBe(true);
+    expect(elements.get("binding-reviewer")?.value).toBe("codex:gpt-current");
+    expect(elements.get("binding-reviewer")?.disabled).toBe(true);
+    expect(elements.get("effort-reviewer")?.value).toBe("low");
+    expect(elements.get("skip-general")?.checked).toBe(true);
+    expect(elements.get("binding-general")?.value).toBe("claude:gpt-no-effort");
+    expect(elements.get("effort-general")?.value).toBe("keep");
+    expect(elements.get("skip-auditor")?.checked).toBe(true);
+    expect(elements.get("role-fields-auditor")?.hidden).toBe(true);
+    expect(elements.get("binding-auditor")?.value).toBe("");
+    expect(elements.get("effort-auditor")?.value).toBe("keep");
+    expect(elements.get("setup-action-status")?.textContent).toBe("No changes selected");
+
+    elements.get("skip-reviewer")!.checked = false;
+    dispatch("skip-reviewer", "change");
+    expect(elements.get("role-fields-reviewer")?.hidden).toBe(false);
+    expect(elements.get("binding-reviewer")?.disabled).toBe(false);
+    expect(elements.get("setup-action-status")?.textContent).toBe("1 role set to change");
+
+    elements.get("effort-reviewer")!.value = "high";
+    const selected = controller.buildSelection();
+    expect(selected).toEqual({ agents: { reviewer: { harness: "codex", model: "gpt-current", effort: "high" } } });
+  });
+
+  it("describes an unmatched orchestrator as custom with readable fields", async () => {
+    const { document, elements } = fakeDocument();
+    const controller = makeController(document, async (path) => path === "/api/setup/state"
+      ? response({
+        target: { kind: "global" },
+        bindings: {},
+        orchestrator: { investigate: "read", selfWork: "trivial", tools: "edit", parallelism: 3 },
+      })
+      : response({ models: [], status: "fresh", source: "cache", ageMs: 0, cacheWriteFailed: false }));
+
+    await controller.start();
+
+    expect(elements.get("current-orchestrator")?.textContent).toBe(
+      "custom · investigate: read only · self work: trivial tasks · tools: edit · parallelism: 3",
+    );
+  });
+
+  it("renders change rows, status, validations, escaped messages, and collapsed raw data", async () => {
+    const { document, elements } = fakeDocument();
+    const payload = {
+      proposta: { activeProfile: "legacy", profiles: { legacy: { secret: "legacy-config" } } },
+      validacoes: {
+        config: { status: "ok", message: null },
+        catalogo: { status: "fresh", message: "" },
+        bindings: [{ role: "reviewer", harness: "codex", model: "known", status: "accepted", message: "Model recognized" }],
+      },
+      mudancas: [
+        { path: "/agents/reviewer/effort", beforePresent: true, before: "low", afterPresent: true, after: "high" },
+        { path: "/agents/reviewer/model", beforePresent: false, before: null, afterPresent: true, after: "" },
+        { path: "/agents/auditor/model", beforePresent: true, before: "old-model", afterPresent: false, after: null },
+      ],
+      resultado: { status: "dry-run", code: 0, saved: false, message: "Dry run only, no changes were written." },
+    };
+    const controller = makeController(document, async (path) => path === "/api/setup/state"
+      ? response({ target: { kind: "global" }, bindings: {}, efforts: {} })
+      : path === "/api/setup/catalog"
+        ? response({ models: [], status: "fresh", source: "cache", ageMs: 0, cacheWriteFailed: false })
+        : response(payload));
+
+    await controller.start();
+    await controller.dryRun(form());
+
+    const markup = elements.get("setup-result")?.innerHTML ?? "";
+    const visiblePreview = markup.split('
')[0]; + expect(visiblePreview).toContain("Preview only, nothing written"); + expect(visiblePreview).toContain("reviewer · effort"); + expect(visiblePreview).toContain("low"); + expect(visiblePreview).toContain("→"); + expect(visiblePreview).toContain("high"); + expect(visiblePreview).toContain("Added"); + expect(visiblePreview).toContain("Removed"); + expect(visiblePreview).toContain("Config · ok"); + expect(visiblePreview).toContain("Catalog · fresh"); + expect(visiblePreview).toContain("reviewer · codex:known · accepted"); + expect(visiblePreview).toContain("<img src=x onerror=alert(1)>"); + expect(visiblePreview).not.toContain(""); + expect(visiblePreview).not.toContain("proposta"); + expect(visiblePreview).not.toContain("profiles"); + expect(visiblePreview).not.toContain("activeProfile"); + expect(markup).toContain("legacy-config"); + expect(markup).toContain("
"); + expect(markup).not.toContain("
"); + }); + + it("preserves the rendered raw preview on form updates and refreshes it for a new response", async () => { + const { document, elements, innerHtmlWrites, dispatch } = fakeDocument(); + const envelopes = [ + { mudancas: [], resultado: { status: "dry-run", code: 0, saved: false, message: "First preview" } }, + { mudancas: [], resultado: { status: "dry-run", code: 0, saved: false, message: "Second preview" } }, + ]; + let actionIndex = 0; + const controller = makeController(document, async (path) => path === "/api/setup/state" + ? response({ target: { kind: "global" }, bindings: {}, efforts: {} }) + : path === "/api/setup/catalog" + ? response({ models: [], status: "fresh", source: "cache", ageMs: 0, cacheWriteFailed: false }) + : response(envelopes[actionIndex++]!)); + + await controller.start(); + await controller.dryRun(form()); + const writesAfterPreview = innerHtmlWrites.get("setup-result"); + expect(elements.get("setup-result")?.innerHTML).toContain("First preview"); + + elements.get("binding-reviewer")!.value = "codex:model"; + dispatch("binding-reviewer", "input"); + expect(innerHtmlWrites.get("setup-result")).toBe(writesAfterPreview); + + await controller.dryRun(form()); + expect(elements.get("setup-result")?.innerHTML).toContain("Second preview"); + expect(innerHtmlWrites.get("setup-result")).toBe((writesAfterPreview ?? 0) + 1); + }); + + it("renders saved, unchanged, error, and no-change result states", async () => { + const { document, elements } = fakeDocument(); + let payload: Record = { + mudancas: [], + resultado: { status: "applied", code: 0, saved: true, message: "Configuration saved." }, + }; + const controller = makeController(document, async (path) => path === "/api/setup/state" + ? response({ target: { kind: "global" }, bindings: {}, efforts: {} }) + : path === "/api/setup/catalog" + ? response({ models: [], status: "fresh", source: "cache", ageMs: 0, cacheWriteFailed: false }) + : response(payload)); + + await controller.start(); + await controller.apply(form()); + expect(elements.get("setup-result")?.innerHTML).toContain("Saved"); + expect(elements.get("setup-result")?.innerHTML).toContain("No changes"); + + payload = { mudancas: [], resultado: { status: "unchanged", code: 0, saved: false, message: "Configuration unchanged." } }; + await controller.dryRun(form()); + expect(elements.get("setup-result")?.innerHTML).toContain("Unchanged"); + expect(elements.get("setup-result")?.innerHTML).toContain("Configuration unchanged."); + + payload = { mudancas: [], resultado: { status: "error", code: 15, saved: false, message: "Config write failed." } }; + await controller.apply(form()); + expect(elements.get("setup-result")?.innerHTML).toContain("Error"); + expect(elements.get("setup-result")?.innerHTML).toContain("Config write failed."); + }); + + it("keeps Preview and Apply disabled until loaded and during a request", async () => { + const { document, elements } = fakeDocument(); + let finishState: ((value: SetupPageResponse) => void) | undefined; + let finishDryRun: ((value: SetupPageResponse) => void) | undefined; + let finishApply: ((value: SetupPageResponse) => void) | undefined; + const controller = makeController(document, async (path) => { + if (path === "/api/setup/state") return await new Promise((resolve) => { finishState = resolve; }); + if (path === "/api/setup/catalog") return response({ models: [], status: "fresh", source: "cache", ageMs: 0, cacheWriteFailed: false }); + return await new Promise((resolve) => { + if (path.endsWith("/dry-run")) finishDryRun = resolve; + else finishApply = resolve; + }); + }); + + const starting = controller.start(); + expect(elements.get("setup-dry-run")?.disabled).toBe(true); + expect(elements.get("setup-apply")?.disabled).toBe(true); + + finishState?.(response({ target: { kind: "global" }, bindings: {}, efforts: {} })); + await starting; + expect(elements.get("setup-dry-run")?.disabled).toBe(false); + expect(elements.get("setup-apply")?.disabled).toBe(false); + + const preview = controller.dryRun(form()); + expect(elements.get("setup-dry-run")?.disabled).toBe(true); + expect(elements.get("setup-apply")?.disabled).toBe(true); + expect(elements.get("setup-action-status")?.textContent).toBe("Preparing preview..."); + finishDryRun?.(response({ mudancas: [], resultado: { status: "dry-run", code: 0, saved: false, message: "" } })); + await preview; + expect(elements.get("setup-dry-run")?.disabled).toBe(false); + expect(elements.get("setup-apply")?.disabled).toBe(false); + + const apply = controller.apply(form()); + expect(elements.get("setup-dry-run")?.disabled).toBe(true); + expect(elements.get("setup-apply")?.disabled).toBe(true); + expect(elements.get("setup-action-status")?.textContent).toBe("Saving setup..."); + finishApply?.(response({ mudancas: [], resultado: { status: "applied", code: 0, saved: true, message: "" } })); + await apply; + expect(elements.get("setup-dry-run")?.disabled).toBe(false); + expect(elements.get("setup-apply")?.disabled).toBe(false); }); it("preserves skipped bindings and unchanged effort, and leaves an omitted orchestrator untouched", () => { diff --git a/tests/setup-web.test.ts b/tests/setup-web.test.ts index d930766..dcbc477 100644 --- a/tests/setup-web.test.ts +++ b/tests/setup-web.test.ts @@ -142,6 +142,11 @@ describe("setup page and state route", () => { expect(page.status).toBe(200); expect(page.headers["content-type"]).toBe("text/html; charset=utf-8"); expect(page.headers["content-security-policy"]).toBe("frame-ancestors 'none'"); + expect(page.body).toContain('href="/setup" aria-label="CodeDeck home"'); + expect(page.body).toContain('href="/setup" aria-current="page" class="active">Setup'); + expect(page.body).not.toContain('href="/review"'); + expect(page.body).not.toContain('href="/usage"'); + expect(page.body).not.toContain('href="/"'); expect(payload.target).toEqual({ kind: "global" }); expect(payload.bindings).toEqual({ general: { harness: "claude", model: "top-level" } }); expect(payload.efforts).toEqual({}); diff --git a/tests/web-cli.test.ts b/tests/web-cli.test.ts index 8ceb572..3b57090 100644 --- a/tests/web-cli.test.ts +++ b/tests/web-cli.test.ts @@ -108,6 +108,7 @@ describe("ui CLI command", () => { const rootResponse = await fetch(`${started?.baseUrl}/`); const rootHtml = await rootResponse.text(); expect(rootResponse.status).toBe(200); + expect(rootHtml).not.toContain('href="/"'); expect(rootHtml).toContain('href="/review"'); expect(rootHtml).toContain('href="/setup"'); expect(rootHtml).toContain('href="/usage"'); @@ -118,8 +119,14 @@ describe("ui CLI command", () => { expect(await reviewResponse.text()).toContain("Review local"); const setupResponse = await fetch(`${started?.baseUrl}/setup`); + const setupHtml = await setupResponse.text(); const usageResponse = await fetch(`${started?.baseUrl}/usage`); + const setupNav = setupHtml.split('")[0] ?? ""; expect(setupResponse.status).toBe(200); + expect(setupNav).toContain('href="/">Home'); + expect(setupNav).toContain('href="/review">Review'); + expect(setupNav).toContain('href="/setup" aria-current="page" class="active">Setup'); + expect(setupNav).toContain('href="/usage">Usage'); expect(usageResponse.status).toBe(200); });