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); });