From d35dc7f9c13df3027f2bc1caf4bc8afffd42ec58 Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:48:58 -0300 Subject: [PATCH 1/3] feat(web): Redesign setup as an editable roster with a policy compare The setup page asked for a Keep/Change choice per role and free-text harness:model fields, and showed the orchestrator policy as selects. Replace it with a roster table where each role row opens a searchable model picker with harness icons, plus an inline effort meter. Show the orchestrator policy as a comparison of the presets next to an editable Custom column, with icons per choice. Policy, effort, sandbox and autocompact changes animate in place, and reduced motion turns the animation off. Saving now goes through a review drawer that runs the dry run, shows a diff of the config keys Setup manages, and asks for confirmation of off-catalog models inside the page instead of a native confirm dialog. Co-Authored-By: Claude --- .specs/features/web-setup-redesign/spec.md | 5 + .specs/features/web-setup-roster/spec.md | 93 ++ src/web/setup-icons.ts | 43 + src/web/setup-page.ts | 1719 ++++++++++++++++---- tests/setup-page.test.ts | 897 +++++----- 5 files changed, 2000 insertions(+), 757 deletions(-) create mode 100644 .specs/features/web-setup-roster/spec.md create mode 100644 src/web/setup-icons.ts diff --git a/.specs/features/web-setup-redesign/spec.md b/.specs/features/web-setup-redesign/spec.md index 5564900..c9c1059 100644 --- a/.specs/features/web-setup-redesign/spec.md +++ b/.specs/features/web-setup-redesign/spec.md @@ -1,5 +1,10 @@ # Web setup redesign +> Superseded in part by `.specs/features/web-setup-roster/spec.md`. R2, R3, R5 and R6 (current-state text, Keep/Change +> role cards, the Preview button, and the card grid) are replaced by the roster rows and the review drawer. R4 still +> holds for escaping, hidden legacy keys, and the collapsed raw response, but its labels changed: change rows read +> `Reviewer effort` with `to` between values, and a dry run shows `Not saved yet`. R1 and R7 still hold. + ## Goal Redesign `/setup` as a clear setup dashboard that uses the CodeDeck brand and the visual language of `/usage`. diff --git a/.specs/features/web-setup-roster/spec.md b/.specs/features/web-setup-roster/spec.md new file mode 100644 index 0000000..af9c64b --- /dev/null +++ b/.specs/features/web-setup-roster/spec.md @@ -0,0 +1,93 @@ +# Web setup roster + +## Goal + +Rebuild `/setup` around direct editing: one row per role that you change in place, a comparison table for the +orchestrator policy, and a review drawer that shows the server's dry run before anything is written. Replaces the +Keep current / Change toggle, the native datalist, the preview button, and the native `confirm()` dialog from +`web-setup-redesign`. Mocks: roster layout (A) with the Compare policy (C). + +## Out of scope + +- Changes to the setup API routes, request bodies, or the envelope and its Portuguese keys. +- Changes to `buildSetupSelection` semantics, `src/config/*`, or the CLI `codedeck setup` flow. +- Changes to `/usage`, Home, Review, or the web server. +- New runtime dependencies. The page stays one server-rendered string with inline CSS, SVG, and script. + +## Requirements + +### R1. Chrome and assets + +- R1.1 The page SHALL keep `BRAND_CSS`, `LOGO_FAVICON_HREF`, and `renderTopBar` with Setup active, and the + setup-only navigation default when no page list is supplied. +- R1.2 The page SHALL embed one inline SVG sprite with a logo for each harness in `AGENT_IDS` and the line icons + the page uses. Every use of an icon SHALL reference the sprite with ``. +- R1.3 The header SHALL show the config path from `/api/setup/state` and the catalog status with a refresh button. + +### R2. Role rows + +- R2.1 The page SHALL render one row per role with its name, a one-line description, a model button, an effort + meter, and a state cell. +- R2.2 The model button SHALL show the harness logo, the harness id, and the model id in monospace. An unbound role + SHALL show `Choose a model`. +- R2.3 WHEN the model button is activated THEN a picker SHALL open with a search field and the catalog models + grouped by harness. The saved model SHALL be tagged `saved` and the draft model SHALL be checked. +- R2.4 WHEN a harness is unavailable THEN its picker group SHALL say so and offer no models. +- R2.5 WHEN the search text is `harness:model` for a known harness and the model is not in its catalog THEN the + picker SHALL offer to use it, tagged as not in the catalog. +- R2.6 The picker SHALL support ArrowUp, ArrowDown, Enter, and Escape. Escape SHALL return focus to the button. +- R2.7 The effort meter SHALL offer the five `REASONING_EFFORTS` levels. WHEN the catalog lists + `reasoningEfforts` for the draft model THEN levels outside that list SHALL be disabled. +- R2.8 WHEN the draft harness is `opencode` THEN the meter SHALL be replaced by text saying opencode sets its own + effort. +- R2.9 WHEN a role's draft differs from its saved binding or effort THEN its row SHALL be marked changed and its + state cell SHALL show the saved value and an Undo action that restores it. + +### R3. Orchestrator policy + +- R3.1 The policy SHALL render as a table whose columns are the presets and Custom, and whose rows are + investigate, self work, and tools. Each cell SHALL show an icon and a label. +- R3.2 Each column header SHALL carry a glyph drawn from that column's three values. +- R3.3 Activating a preset header SHALL set the draft policy to that preset. The Custom column cells SHALL offer + the three values of their row and SHALL switch the policy to custom. +- R3.4 The active column SHALL be highlighted by one element that moves between columns with a transition. +- R3.5 The Custom picks SHALL move their selection with a transition, and changed labels SHALL animate in. +- R3.6 A parallel workers field SHALL say the value is guidance in the orchestrator prompt and that blank means no + limit. +- R3.7 WHEN no policy is saved and none is chosen THEN no column SHALL be active. + +### R4. Runtime settings + +- R4.1 Sandbox SHALL be a two-option control with icons whose selection moves with a transition. Full access SHALL + show a warning sentence. +- R4.2 Autocompact SHALL be a switch. +- R4.3 WHEN a setting has no saved value and is untouched THEN it SHALL be sent as unchanged. + +### R5. Save flow + +- R5.1 A sticky bar SHALL show the number of unsaved changes and their names, a Discard action, and a Review and + save action. Both actions SHALL be disabled when nothing changed or while loading or saving. +- R5.2 Review and save SHALL open a drawer and request `/api/setup/dry-run` with the current selection. +- R5.3 The drawer SHALL render one row per change from `mudancas`, a line diff of the keys Setup manages built + from `proposta`, and the config, catalog, and binding validations. +- R5.4 WHEN a changed binding is not in an available harness catalog THEN the drawer SHALL show a per-role + confirmation checkbox, and saving SHALL stay disabled until each one is checked. +- R5.5 Saving SHALL request `/api/setup/apply` with `offCatalogConfirmed` for the confirmed roles. +- R5.6 WHEN apply returns `applied` THEN the drawer SHALL close, a toast SHALL confirm the save, and the page SHALL + reload the saved state so no row stays marked changed. +- R5.7 WHEN a request fails THEN the drawer SHALL show the error. A 403 SHALL show the expired-session message. +- R5.8 Values inserted with `innerHTML` SHALL be escaped. Legacy `profiles` and `activeProfile` SHALL NOT appear + outside the collapsed raw response. + +### R6. Motion and layout + +- R6.1 The drawer and scrim SHALL slide and fade. The picker SHALL fade in. +- R6.2 Under `prefers-reduced-motion: reduce`, transitions and animations SHALL be disabled. +- R6.3 At 390px the rows SHALL stack, the policy table SHALL scroll inside its own container, and + `document.documentElement.scrollWidth` SHALL be no greater than 390. + +### R7. Preserved behavior + +- R7.1 `buildSetupSelection` SHALL keep its semantics and signature. +- R7.2 The controller SHALL keep `state`, `start`, `refreshCatalog`, `buildSelection`, `dryRun`, and `apply`. +- R7.3 Setup API paths, methods, and body shapes SHALL stay unchanged. diff --git a/src/web/setup-icons.ts b/src/web/setup-icons.ts new file mode 100644 index 0000000..cc31746 --- /dev/null +++ b/src/web/setup-icons.ts @@ -0,0 +1,43 @@ +// Harness logos: Claude, Codex, OpenCode, and Antigravity from @lobehub/icons-static-svg 1.95.1 (MIT); +// omp from can1357/oh-my-pi assets/icon.svg (MIT). Line icons from lucide-static 1.48.0 (ISC). +// The marks belong to their owners and only label which harness a role runs on. + +const HARNESS_SYMBOLS: Record = { + claude: ``, + codex: ``, + opencode: ``, + omp: ``, + antigravity: ``, +}; + +const LINE_ICONS: Record = { + "eye-off": ``, + "eye": ``, + "telescope": ``, + "hand": ``, + "wrench": ``, + "hammer": ``, + "split": ``, + "book-open": ``, + "pencil": ``, + "shield-check": ``, + "shield-alert": ``, + "rotate-cw": ``, + "chevron-down": ``, + "check": ``, + "undo-2": ``, + "x": ``, + "triangle-alert": ``, + "terminal": ``, +}; + +export type SetupIconName = keyof typeof LINE_ICONS; + +export function renderSetupSprite(): string { + const lines = Object.entries(LINE_ICONS).map(([name, body]) => + `${body}`, + ); + return ``; +} + +export const SETUP_HARNESS_ICONS: readonly string[] = Object.keys(HARNESS_SYMBOLS); diff --git a/src/web/setup-page.ts b/src/web/setup-page.ts index ff1d5de..d397171 100644 --- a/src/web/setup-page.ts +++ b/src/web/setup-page.ts @@ -1,10 +1,12 @@ import { REASONING_EFFORTS } from "../core/driver.js"; import { ROLES, type Role } from "../core/roles.js"; +import { AGENT_IDS } from "../core/session.js"; 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"; +import { renderSetupSprite, type SetupIconName } from "./setup-icons.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."; @@ -118,6 +120,68 @@ export function buildSetupSelection( }; } +export interface SetupPolicyOption { + value: string; + label: string; + icon: SetupIconName; + summary: string; +} + +export interface SetupPolicyAxis { + key: "investigate" | "selfWork" | "tools"; + label: string; + options: SetupPolicyOption[]; +} + +export const SETUP_POLICY_AXES: readonly SetupPolicyAxis[] = [ + { + key: "investigate", + label: "Reads the code", + options: [ + { value: "none", label: "Never", icon: "eye-off", summary: "never reads the code" }, + { value: "read", label: "Read only", icon: "eye", summary: "reads code to plan" }, + { value: "free", label: "Freely", icon: "telescope", summary: "explores the code freely" }, + ], + }, + { + key: "selfWork", + label: "Does work itself", + options: [ + { value: "none", label: "Never", icon: "hand", summary: "hands off every task" }, + { value: "trivial", label: "Trivial fixes", icon: "wrench", summary: "fixes trivial things itself" }, + { value: "small", label: "Small tasks", icon: "hammer", summary: "takes small tasks itself" }, + ], + }, + { + key: "tools", + label: "Tools", + options: [ + { value: "dispatch", label: "Dispatch only", icon: "split", summary: "only has dispatch tools" }, + { value: "read", label: "Read", icon: "book-open", summary: "has read-only tools" }, + { value: "edit", label: "Edit", icon: "pencil", summary: "can edit files" }, + ], + }, +]; + +// The orchestrator leads the roster because it is the role the others work for. +export const SETUP_ROLE_ORDER: readonly Role[] = [ + "orchestrator", + ...ROLES.filter((role) => role !== "orchestrator"), +]; + +const ROLE_COPY: Record = { + orchestrator: "Coordinates workers and tracks their state.", + general: "Does the work directly in the workspace.", + reviewer: "Inspects changes with evidence. Edits nothing.", + auditor: "Fans out over large scopes, then proves findings.", +}; + +export interface SetupPageEvent { + target?: unknown; + key?: string; + preventDefault?(): void; +} + export interface SetupPageElement { value?: string; checked?: boolean; @@ -125,11 +189,19 @@ export interface SetupPageElement { hidden?: boolean; textContent?: string | null; innerHTML?: string; - addEventListener?(name: string, callback: () => void): void; + className?: string; + scrollTop?: number; + style?: Record; + addEventListener?(name: string, callback: (event?: SetupPageEvent) => void): void; + setAttribute?(name: string, value: string): void; + focus?(): void; + getBoundingClientRect?(): { left: number; top: number; bottom: number; width: number }; + scrollIntoView?(options?: { block?: string }): void; } export interface SetupPageDocument { getElementById(id: string): SetupPageElement | null; + addEventListener?(name: string, callback: (event?: SetupPageEvent) => void, capture?: boolean): void; } export interface SetupPageResponse { @@ -153,8 +225,10 @@ export interface SetupPageControllerOptions { roles: readonly Role[]; expiredMessage: string; presets?: Record; + efforts?: readonly string[]; + harnesses?: readonly string[]; + axes?: readonly SetupPolicyAxis[]; document?: SetupPageDocument; - confirm?: (message: string) => boolean; onChange?: (state: SetupPageClientState) => void; } @@ -163,6 +237,7 @@ export interface SetupPageOptions { } export interface SetupPageTargetState { + config?: { status?: string; source?: string; path?: string }; target: { kind: "global" }; bindings: Partial>; efforts: Partial>; @@ -181,14 +256,53 @@ export interface SetupPageClientState { envelope?: unknown; } +export interface SetupDraftBinding { + harness: string; + model: string; + effort: string; +} + +export interface SetupDraftPolicy { + investigate: string; + selfWork: string; + tools: string; +} + +export interface SetupDraft { + agents: Partial>; + policy?: SetupDraftPolicy; + custom: boolean; + parallelism: string; + sandbox?: string; + autocompact?: boolean; +} + export function createSetupPageController(options: SetupPageControllerOptions) { const state: SetupPageClientState = { refreshing: false, loading: true }; + const roles = options.roles; + const efforts = options.efforts ?? []; + const harnesses = options.harnesses ?? []; + const presets = options.presets ?? {}; + const presetNames = Object.keys(presets); + const columns = [...presetNames, "custom"]; + const axes = options.axes ?? []; + const ui = { + drawer: false, + pickerRole: undefined as Role | undefined, + pickerQuery: "", + pickerIndex: 0, + confirmOff: {} as Partial>, + reviewOk: false, + bound: false, + rendered: false, + }; + let draft: SetupDraft = { agents: {}, custom: false, parallelism: "" }; let refreshInFlight: Promise | undefined; let actionInFlight = false; let actionInFlightLabel = ""; - let lastRenderedEnvelope: unknown; - let lastRenderedError: string | undefined; - let hasRenderedPreview = false; + let toastTimer: unknown; + const htmlCache = new Map(); + const swapFlip = new Map(); const element = (id: string) => options.document?.getElementById(id) ?? null; function escapeHtml(value: unknown): string { @@ -201,32 +315,249 @@ export function createSetupPageController(options: SetupPageControllerOptions) { })[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, + function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); + } + + function icon(name: string, className = ""): string { + return ``; + } + + function chip(harness: string): string { + return `${escapeHtml(harness)}`; + } + + function setHtml(id: string, html: string): boolean { + const target = element(id); + if (!target || htmlCache.get(id) === html) return false; + htmlCache.set(id, html); + target.innerHTML = html; + return true; + } + + function setClass(id: string, className: string): void { + const target = element(id); + if (target && target.className !== className) target.className = className; + } + + function setAttr(id: string, name: string, value: string): void { + element(id)?.setAttribute?.(name, value); + } + + function setDisabled(id: string, disabled: boolean): void { + const target = element(id); + if (target && target.disabled !== disabled) target.disabled = disabled; + } + + // Swapping between two identical keyframe names restarts the entrance + // animation every time the text changes, without touching layout. + function swapText(id: string, base: string, text: string): void { + const target = element(id); + if (!target) return; + if (target.textContent === text) { + if ((target.className ?? "").replace(/ swap-[ab]$/, "") !== base) target.className = base; + return; + } + target.textContent = text; + if (!ui.rendered) { + target.className = base; + return; + } + const flip = !swapFlip.get(id); + swapFlip.set(id, flip); + target.className = `${base} ${flip ? "swap-a" : "swap-b"}`; + } + + function cloneDraft(value: SetupDraft): SetupDraft { + return JSON.parse(JSON.stringify(value)) as SetupDraft; + } + + function presetFor(policy: SetupDraftPolicy | undefined): string | undefined { + if (!policy) return undefined; + return presetNames.find((name) => + presets[name]?.investigate === policy.investigate && + presets[name]?.selfWork === policy.selfWork && + presets[name]?.tools === policy.tools, ); - if (preset) return preset[0]; + } + + function samePolicy(left: SetupDraftPolicy | undefined, right: SetupDraftPolicy | undefined): boolean { + if (!left || !right) return left === right; + return left.investigate === right.investigate && left.selfWork === right.selfWork && left.tools === right.tools; + } - const investigate: Record = { - none: "no investigation", - read: "read only", - free: "full access", + function baselinePolicy(): SetupDraftPolicy { + const preset = presets.balanced ?? presets[presetNames[0] ?? ""]; + return { + investigate: preset?.investigate ?? axes[0]?.options[0]?.value ?? "none", + selfWork: preset?.selfWork ?? axes[1]?.options[0]?.value ?? "none", + tools: preset?.tools ?? axes[2]?.options[0]?.value ?? "dispatch", }; - const selfWork: Record = { - none: "off", - trivial: "trivial tasks", - small: "small tasks", + } + + function savedDraft(): SetupDraft { + const target = state.target; + const agents: SetupDraft["agents"] = {}; + for (const role of roles) { + const binding = target?.bindings?.[role]; + if (binding) { + agents[role] = { + harness: binding.harness, + model: binding.model, + effort: target?.efforts?.[role] ?? binding.effort ?? "", + }; + } + } + const mode = target?.orchestrator; + const policy = mode ? { investigate: mode.investigate, selfWork: mode.selfWork, tools: mode.tools } : undefined; + return { + agents, + ...(policy ? { policy } : {}), + custom: Boolean(policy) && !presetFor(policy), + parallelism: mode?.parallelism === undefined ? "" : String(mode.parallelism), + ...(target?.sandbox === undefined ? {} : { sandbox: target.sandbox }), + ...(target?.autocompact === undefined ? {} : { autocompact: target.autocompact.enabled !== false }), }; + } + + function policyMode(value: SetupDraft = draft): string { + if (!value.policy) return "none"; + if (value.custom) return "custom"; + return presetFor(value.policy) ?? "custom"; + } + + function effortOf(binding: SetupDraftBinding | undefined): string { + return !binding || binding.harness === "opencode" ? "" : binding.effort; + } + + function modelChanged(role: Role, saved: SetupDraft): boolean { + const current = draft.agents[role]; + const previous = saved.agents[role]; + return Boolean(current) && (!previous || previous.harness !== current?.harness || previous.model !== current?.model); + } + + function effortChanged(role: Role, saved: SetupDraft): boolean { + const current = draft.agents[role]; + const previous = saved.agents[role]; + return Boolean(current && previous) && effortOf(current) !== effortOf(previous); + } + + function roleChanged(role: Role, saved: SetupDraft): boolean { + return modelChanged(role, saved) || effortChanged(role, saved); + } + + function policyChanged(saved: SetupDraft): boolean { + return Boolean(draft.policy) && !samePolicy(draft.policy, saved.policy); + } + + function parallelismChanged(saved: SetupDraft): boolean { + return Boolean(draft.policy) && draft.parallelism.trim() !== saved.parallelism; + } + + function sandboxChanged(saved: SetupDraft): boolean { + return draft.sandbox !== undefined && draft.sandbox !== saved.sandbox; + } + + function autocompactChanged(saved: SetupDraft): boolean { + return draft.autocompact !== undefined && draft.autocompact !== saved.autocompact; + } + + function changeNames(saved: SetupDraft = savedDraft()): string[] { return [ - `custom · investigate: ${investigate[mode.investigate]}`, - `self work: ${selfWork[mode.selfWork]}`, - `tools: ${mode.tools}`, - ...(mode.parallelism === undefined ? [] : [`parallelism: ${mode.parallelism}`]), - ].join(" · "); + ...roles.filter((role) => roleChanged(role, saved)).map((role) => capitalize(role)), + ...(policyChanged(saved) ? ["Orchestrator policy"] : []), + ...(parallelismChanged(saved) ? ["Parallel workers"] : []), + ...(sandboxChanged(saved) ? ["Sandbox"] : []), + ...(autocompactChanged(saved) ? ["Autocompact"] : []), + ]; + } + + function harnessEntry(harness: string) { + return state.catalog?.models?.find((entry) => entry.agent === harness); + } + + function catalogModels(harness: string) { + const seen = new Set(); + return (harnessEntry(harness)?.providers ?? []).flatMap((provider) => provider.models).filter((model) => { + if (seen.has(model.id)) return false; + seen.add(model.id); + return true; + }); + } + + function catalogModel(harness: string, model: string) { + return catalogModels(harness).find((candidate) => candidate.id === model || candidate.aliases?.includes(model)); + } + + function supportedEfforts(harness: string, model: string): string[] | undefined { + const listed = catalogModel(harness, model)?.reasoningEfforts?.filter((effort) => efforts.includes(effort)); + return listed && listed.length > 0 ? listed : undefined; + } + + function isOffCatalog(harness: string, model: string): boolean { + const entry = harnessEntry(harness); + return Boolean(entry?.available) && !catalogModel(harness, model); + } + + function offCatalogRoles(saved: SetupDraft = savedDraft()): Role[] { + return roles.filter((role) => { + const current = draft.agents[role]; + return Boolean(current) && modelChanged(role, saved) && isOffCatalog(current!.harness, current!.model); + }); + } + + function readForm(): SetupPageFormValues { + const saved = savedDraft(); + const roleValues: SetupPageFormValues["roles"] = {}; + for (const role of roles) { + const current = draft.agents[role]; + roleValues[role] = { + skip: !roleChanged(role, saved), + binding: current ? `${current.harness}:${current.model}` : "", + effort: current?.effort || "keep", + }; + } + const policy = draft.policy; + let orchestrator = "skip"; + if (policy && (policyChanged(saved) || parallelismChanged(saved))) { + const preset = !draft.custom && draft.parallelism.trim() === "" ? presetFor(policy) : undefined; + orchestrator = preset ?? "custom"; + } + return { + roles: roleValues, + orchestrator, + investigate: policy?.investigate ?? "none", + selfWork: policy?.selfWork ?? "none", + tools: policy?.tools ?? "dispatch", + parallelism: draft.parallelism, + sandbox: sandboxChanged(saved) ? draft.sandbox ?? "skip" : "skip", + autocompact: autocompactChanged(saved) ? (draft.autocompact ? "on" : "off") : "skip", + }; + } + + function formatAge(ms: number): string { + const minutes = Math.floor(ms / 60000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes} min ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} h ago`; + return `${Math.floor(hours / 24)} d ago`; + } + + function catalogText(): string { + if (state.refreshing) return "Refreshing catalog"; + const catalog = state.catalog; + if (!catalog) { + if (state.discoveryError) return `Catalog unavailable: ${state.discoveryError}`; + return state.loading ? "Loading catalog" : "Catalog not loaded"; + } + const age = typeof catalog.ageMs === "number" ? formatAge(catalog.ageMs) : ""; + const base = catalog.status === "fresh" + ? `Catalog updated ${age}`.trim() + : catalog.status === "offline" + ? `Catalog offline, cached ${age}`.trim() + : "Catalog unavailable"; + return state.discoveryError ? `${base}. ${state.discoveryError}` : base; } function formatPreviewPath(path: string): string { @@ -240,16 +571,16 @@ export function createSetupPageController(options: SetupPageControllerOptions) { investigate: "investigation", selfWork: "self work", tools: "tools", - parallelism: "parallelism", + parallelism: "parallel workers", 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 [capitalize(parts[1]), ...parts.slice(2).map((part) => labels[part] ?? part)].join(" "); } - return parts.map((part) => labels[part] ?? part).join(" · ") || "configuration"; + return capitalize(parts.map((part) => labels[part] ?? part).join(" ") || "configuration"); } function formatPreviewValue(value: unknown): string { @@ -264,6 +595,96 @@ export function createSetupPageController(options: SetupPageControllerOptions) { return String(value); } + function sortKeys(value: unknown): unknown { + if (Array.isArray(value)) return value.map((item) => sortKeys(item)); + if (typeof value === "object" && value !== null) { + const record = value as Record; + return Object.fromEntries(Object.keys(record).sort().map((key) => [key, sortKeys(record[key])])); + } + return value; + } + + // Only the keys Setup writes, so the diff shows what saving changes and + // never echoes unrelated or legacy keys from the file. + function managedLines(source: Record): string[] { + const managed: Record = {}; + for (const key of ["agents", "orchestrator", "defaultSandbox", "autocompact"]) { + if (source[key] !== undefined) managed[key] = sortKeys(source[key]); + } + return JSON.stringify(managed, null, 2).split("\n"); + } + + function diffLines(before: string[], after: string[]) { + const rows = before.length; + const cols = after.length; + const lengths = Array.from({ length: rows + 1 }, () => new Array(cols + 1).fill(0)); + for (let i = rows - 1; i >= 0; i--) { + for (let j = cols - 1; j >= 0; j--) { + lengths[i]![j] = before[i] === after[j] + ? lengths[i + 1]![j + 1]! + 1 + : Math.max(lengths[i + 1]![j]!, lengths[i]![j + 1]!); + } + } + const output: Array<{ kind: " " | "+" | "-"; text: string; old?: number; next?: number }> = []; + let i = 0; + let j = 0; + while (i < rows && j < cols) { + if (before[i] === after[j]) { + output.push({ kind: " ", text: before[i]!, old: i + 1, next: j + 1 }); + i++; + j++; + } else if (lengths[i + 1]![j]! >= lengths[i]![j + 1]!) { + output.push({ kind: "-", text: before[i]!, old: i + 1 }); + i++; + } else { + output.push({ kind: "+", text: after[j]!, next: j + 1 }); + j++; + } + } + while (i < rows) output.push({ kind: "-", text: before[i]!, old: ++i }); + while (j < cols) output.push({ kind: "+", text: after[j]!, next: ++j }); + return output; + } + + function renderDiff(proposal: Record): string { + const target = state.target; + const before = managedLines({ + agents: target?.bindings, + orchestrator: target?.orchestrator, + defaultSandbox: target?.sandbox, + autocompact: target?.autocompact, + }); + const lines = diffLines(before, managedLines(proposal)); + const added = lines.filter((line) => line.kind === "+").length; + const removed = lines.filter((line) => line.kind === "-").length; + if (added + removed === 0) return ""; + const near = lines.map((_, index) => + lines.slice(Math.max(0, index - 2), index + 3).some((line) => line.kind !== " "), + ); + const rows: string[] = []; + let hidden = 0; + const flush = () => { + if (hidden > 0) rows.push(`
${hidden} unchanged ${hidden === 1 ? "line" : "lines"}
`); + hidden = 0; + }; + lines.forEach((line, index) => { + if (line.kind === " " && !near[index]) { + hidden++; + return; + } + flush(); + const text = escapeHtml(line.text) + .replace(/^(\s*)(".*?")(:)/, '$1$2$3') + .replace(/(:\s)(".*?")/, '$1$2'); + const kind = line.kind === "+" ? " plus" : line.kind === "-" ? " minus" : ""; + rows.push(`
${line.old ?? ""}${line.next ?? ""}${line.kind === " " ? "" : line.kind}${text}
`); + }); + flush(); + const path = target?.config?.path ?? "config.json"; + const file = path.split("/").pop() || path; + return `

${escapeHtml(file)}+${added}-${removed}

${rows.join("")}

Only the keys Setup manages are shown. The rest of the file stays as it is.

`; + } + function renderPreview(envelope: unknown, fallbackError?: string): string { const body = typeof envelope === "object" && envelope !== null ? envelope as Record @@ -273,20 +694,24 @@ export function createSetupPageController(options: SetupPageControllerOptions) { : {}; const resultStatus = typeof result.status === "string" ? result.status : "error"; const statusLabels: Record = { - "dry-run": "Preview only, nothing written", + "dry-run": "Not saved yet", applied: "Saved", - unchanged: "Unchanged", + unchanged: "Nothing to save", 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 message = fallbackError + ?? (typeof result.message === "string" + ? result.message + : typeof body.error === "string" + ? body.error + : ""); + const displayMessage = resultStatus === "dry-run" && !fallbackError + ? "Check the changes below, then save." + : message; 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" @@ -299,18 +724,21 @@ export function createSetupPageController(options: SetupPageControllerOptions) { 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}
`; + const before = beforePresent ? `${escapeHtml(formatPreviewValue(change.before))}` : 'Added'; + const after = afterPresent ? `${escapeHtml(formatPreviewValue(change.after))}` : 'Removed'; + return `
${escapeHtml(formatPreviewPath(path))}${before}to${after}
`; }).join(""); + function check(label: string, status: string, messageText: string, ok: boolean): string { + return `
${icon(ok ? "check" : "triangle-alert")}${escapeHtml(label)} ${escapeHtml(status.replaceAll("-", " "))}${messageText ? `${escapeHtml(messageText)}` : ""}
`; + } + 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)}` : ""}
`; + return check(label, status, messageText, ["ok", "accepted", "fresh", "not-needed"].includes(status)); } const validations = typeof body.validacoes === "object" && body.validacoes !== null @@ -322,196 +750,545 @@ export function createSetupPageController(options: SetupPageControllerOptions) { 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)}` : ""}
`; + return check(`${capitalize(role)}${identity ? ` ${identity}` : ""}`, status, messageText, status === "accepted"); }).join("") : ""; const validationRows = [ validationRow("Config", validations.config), validationRow("Catalog", validations.catalogo), bindingRows, ].filter(Boolean).join(""); + const proposal = typeof body.proposta === "object" && body.proposta !== null + ? renderDiff(body.proposta as Record) + : ""; const rawResponse = escapeHtml(JSON.stringify(envelope, null, 2) ?? String(envelope)); - const displayMessage = resultStatus === "dry-run" ? "" : message; + const statusIcon = statusClass === "applied" || statusClass === "unchanged" + ? "check" + : statusClass === "dry-run" ? "eye" : "triangle-alert"; - return `
${escapeHtml(statusLabels[resultStatus] ?? "Request failed")}${displayMessage ? `${escapeHtml(displayMessage)}` : ""}

Changes

${changeRows || '

No changes

'}

Checks

${validationRows || '

No validation details

'}
Raw response
${rawResponse}
`; + return `
${icon(statusIcon)}${escapeHtml(statusLabels[resultStatus] ?? "Request failed")}${displayMessage ? `${escapeHtml(displayMessage)}` : ""}
` + + `

${changes.length} ${changes.length === 1 ? "change" : "changes"}

${changeRows || '

No changes

'}
` + + proposal + + `

Checks

${validationRows || '

No validation details

'}
` + + `
Raw response
${rawResponse}
`; } - function update(): void { - const status = element("setup-status"); - if (status) { - status.textContent = state.error ?? (state.refreshing - ? "Discovering models..." - : state.loading - ? "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`; + function renderReview(): string { + if (actionInFlight && actionInFlightLabel === "Checking changes") { + return '
Checking your changes
'; + } + if (state.envelope !== undefined) { + if (ui.reviewOk && !state.error) { + const body = state.envelope as Record; + return renderPreview({ ...body, resultado: { status: "dry-run" } }); } + return renderPreview(state.envelope, state.error); } - const targetLabel = element("setup-target"); - if (targetLabel && state.target) { - targetLabel.textContent = "Global configuration"; - } - const catalogLabel = element("catalog-status"); - if (catalogLabel) { - const catalogStatus = state.catalog?.status ?? "not loaded"; - catalogLabel.textContent = state.discoveryError - ? `${catalogStatus}: ${state.discoveryError}` - : catalogStatus; - } - const refreshButton = element("setup-refresh"); - if (refreshButton) refreshButton.disabled = state.refreshing; - const result = element("setup-result"); - 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 = formatCurrentOrchestrator(state.target.orchestrator); - } - const currentSandbox = element("current-sandbox"); - 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" - : 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`; + if (state.error) { + return `
${icon("triangle-alert")}Error${escapeHtml(state.error)}
`; + } + return ""; + } + + function bindingHtml(role: Role): string { + if (state.loading || !state.target) return `Loading${icon("chevron-down", "chev")}`; + const current = draft.agents[role]; + if (!current) return `Choose a model${icon("chevron-down", "chev")}`; + const off = modelChanged(role, savedDraft()) && isOffCatalog(current.harness, current.model); + return `${chip(current.harness)}${escapeHtml(current.model)}${off ? 'not in catalog' : ""}${icon("chevron-down", "chev")}`; + } + + function roleStateHtml(role: Role, saved: SetupDraft): string { + if (state.loading || !state.target) return ""; + const current = draft.agents[role]; + const previous = saved.agents[role]; + if (!current) return 'Not set'; + if (!roleChanged(role, saved)) return `${icon("check")}Saved`; + const was = !previous + ? "not set" + : modelChanged(role, saved) + ? previous.model + : `${previous.effort || "default"} effort`; + return `was ${escapeHtml(was)}`; + } + + function pickerEntries() { + const role = ui.pickerRole; + const query = ui.pickerQuery.trim().toLowerCase(); + const current = role ? draft.agents[role] : undefined; + const saved = role ? savedDraft().agents[role] : undefined; + const groups: Array<{ harness: string; note: string; off: boolean; items: Array<{ harness: string; model: string; name: string; saved: boolean; selected: boolean; custom: boolean }> }> = []; + for (const harness of harnesses) { + const entry = harnessEntry(harness); + if (!entry || !entry.available) { + if (query && !harness.includes(query)) continue; + const note = !state.catalog + ? "Catalog not loaded" + : !entry + ? "Not in the catalog" + : entry.error ? `Unavailable: ${entry.error}` : "Unavailable. Run codedeck doctor."; + groups.push({ harness, note, off: true, items: [] }); + continue; + } + const models = catalogModels(harness); + const matching = models.filter((model) => !query || [`${harness}:${model.id}`, model.name ?? "", ...(model.aliases ?? [])] + .some((value) => value.toLowerCase().includes(query))); + if (query && matching.length === 0) continue; + groups.push({ + harness, + note: models.length === 0 + ? "No models listed" + : harness === "opencode" + ? `${models.length} models, sets its own effort` + : `${models.length} ${models.length === 1 ? "model" : "models"}`, + off: false, + items: matching.map((model) => ({ + harness, + model: model.id, + name: model.name && model.name !== model.id ? model.name : "", + saved: saved?.harness === harness && saved.model === model.id, + selected: current?.harness === harness && current.model === model.id, + custom: false, + })), + }); + } + const typed = ui.pickerQuery.trim().match(/^([a-z]+):(.+)$/); + if (typed && harnesses.includes(typed[1]!) && !catalogModel(typed[1]!, typed[2]!.trim())) { + groups.push({ + harness: typed[1]!, + note: "", + off: false, + items: [{ harness: typed[1]!, model: typed[2]!.trim(), name: "", saved: false, selected: false, custom: true }], + }); + } + return { groups, items: groups.flatMap((group) => group.items) }; + } + + function pickerHtml(): string { + const { groups, items } = pickerEntries(); + if (items.length === 0 && groups.every((group) => group.off)) { + const offGroups = groups.map((group) => `
${chip(group.harness)}${escapeHtml(group.note)}
`).join(""); + return `${offGroups}

No model matches. Type harness:model to use one outside the catalog.

`; + } + let index = 0; + return groups.map((group) => { + if (group.items[0]?.custom) { + const item = group.items[0]; + const id = index++; + return `
`; + } + const head = `
${chip(group.harness)}${escapeHtml(group.note)}
`; + const options = group.items.map((item) => { + const id = index++; + return ``; + }).join(""); + return `
${head}${options}
`; + }).join(""); + } + + function policyCaption(mode: string): string { + if (mode === "none") { + return `Not set. The orchestrator runs as ${capitalize(presetNames[0] ?? "dispatcher")} until you choose.`; + } + const policy = draft.policy ?? baselinePolicy(); + const parts = axes.map((axis) => axis.options.find((option) => option.value === policy[axis.key])?.summary ?? ""); + const list = parts.length > 1 ? `${parts.slice(0, -1).join(", ")}, and ${parts[parts.length - 1]}` : parts.join(""); + return `${capitalize(mode)}: ${list}.`; + } + + function fingerprintPoints(policy: SetupDraftPolicy): Array<[number, number]> { + return axes.map((axis, row) => { + const level = Math.max(0, axis.options.findIndex((option) => option.value === policy[axis.key])); + return [3 + level * 6, 3 + row * 6]; + }); + } + + function update(): void { + const saved = savedDraft(); + const ready = !state.loading && Boolean(state.target); + const busy = actionInFlight || state.refreshing; + const settled = (element("setup-root")?.className ?? "").includes("settled"); + setClass("setup-root", `setup-root${ready ? " ready" : ""}${state.loading ? " loading" : ""}${settled ? " settled" : ""}`); + + const configPath = element("setup-config-path"); + if (configPath && state.target?.config?.path) configPath.textContent = state.target.config.path; + swapText("catalog-status", `catalog-text${state.discoveryError ? " warn" : ""}`, catalogText()); + setClass("setup-refresh", `icon-btn${state.refreshing ? " spinning" : ""}`); + setDisabled("setup-refresh", state.refreshing); + const loadError = !state.target && !state.loading ? state.error ?? "Could not load setup state." : ""; + const errorBanner = element("setup-error"); + if (errorBanner) { + errorBanner.hidden = !loadError; + errorBanner.textContent = loadError; + } + + for (const role of roles) { + const current = draft.agents[role]; + const changed = ready && roleChanged(role, saved); + setClass(`role-row-${role}`, `r-row${changed ? " changed" : ""}${ready && !current ? " unbound" : ""}`); + setHtml(`binding-${role}`, bindingHtml(role)); + setDisabled(`binding-${role}`, !ready); + setAttr(`binding-${role}`, "aria-expanded", String(ui.pickerRole === role)); + + const noEffort = current?.harness === "opencode"; + const supported = current ? supportedEfforts(current.harness, current.model) : undefined; + const level = current && !noEffort ? efforts.indexOf(current.effort) + 1 : 0; + const effortMark = ready && (effortChanged(role, saved) || (modelChanged(role, saved) && effortOf(current) !== effortOf(saved.agents[role]))); + setClass(`effort-${role}`, `meter lvl-${level}${effortMark ? " changed" : ""}${noEffort ? " none" : ""}${ready && !current ? " unbound" : ""}`); + for (const effort of efforts) { + setDisabled(`effort-${role}-${effort}`, !ready || !current || noEffort || Boolean(supported && !supported.includes(effort))); + setAttr(`effort-${role}-${effort}`, "aria-checked", String(current?.effort === effort)); } + swapText(`effort-label-${role}`, "meter-label", !ready || !current || noEffort ? "" : current.effort || "default"); + swapText(`effort-note-${role}`, "meter-note", !ready ? "" : !current ? "No model yet" : noEffort ? "opencode sets its own effort" : ""); + setHtml(`role-state-${role}`, roleStateHtml(role, saved)); } - 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"; - if (effort) { - effort.disabled = skipped || noEffort; - effort.hidden = noEffort; + const mode = policyMode(); + const column = mode === "none" ? "none" : String(Math.max(0, columns.indexOf(mode))); + setClass("policy-section", `sec${ready && (policyChanged(saved) || parallelismChanged(saved)) ? " changed" : ""}`); + setClass("policy-table", `cmp col-${column}`); + for (const name of columns) { + setAttr(`policy-col-${name}`, "aria-pressed", String(mode === name)); + setDisabled(`policy-col-${name}`, !ready); + } + const customPolicy = draft.policy ?? baselinePolicy(); + for (const axis of axes) { + const index = Math.max(0, axis.options.findIndex((option) => option.value === customPolicy[axis.key])); + setClass(`policy-pick-${axis.key}`, `cmp-picks at-${index}`); + for (const option of axis.options) { + setAttr(`policy-pick-${axis.key}-${option.value}`, "aria-checked", String(option.value === customPolicy[axis.key])); + setDisabled(`policy-pick-${axis.key}-${option.value}`, !ready); } + swapText(`policy-custom-label-${axis.key}`, "cmp-custom-label", axis.options[index]?.label ?? ""); } + const points = fingerprintPoints(customPolicy); + setAttr("policy-fp-line", "d", points.map(([x, y], index) => `${index === 0 ? "M" : "L"}${x} ${y}`).join(" ")); + points.forEach(([x], index) => setAttr(`policy-fp-dot-${index}`, "cx", String(x))); + swapText("policy-caption", "policy-caption", ready ? policyCaption(mode) : "Loading policy"); + setDisabled("orchestrator-parallelism", !ready || !draft.policy); + setAttr("orchestrator-parallelism", "placeholder", draft.policy ? "No limit" : "Not set"); - 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; + const sandboxAt = draft.sandbox === "workspace-write" ? "0" : draft.sandbox === "danger-full-access" ? "1" : "none"; + setClass("sandbox-row", `rt-row${ready && sandboxChanged(saved) ? " changed" : ""}`); + setClass("sandbox-control", `seg-ctl at-${sandboxAt}`); + for (const value of ["workspace-write", "danger-full-access"]) { + setAttr(`sandbox-${value}`, "aria-checked", String(draft.sandbox === value)); + setDisabled(`sandbox-${value}`, !ready); } + const sandboxCopy = !ready + ? "" + : draft.sandbox === "danger-full-access" + ? "No sandbox for Codex workers. They can reach the network and anything your user can." + : draft.sandbox === "workspace-write" + ? "Codex workers write only inside their workspace, with no network." + : "Not set. Codex runs with workspace write."; + swapText("sandbox-copy", `rt-copy${draft.sandbox === "danger-full-access" ? " warn" : ""}`, sandboxCopy); + + setClass("autocompact-row", `rt-row${ready && autocompactChanged(saved) ? " changed" : ""}`); + setClass("setup-autocompact", `switch${draft.autocompact === undefined ? " unset" : ""}`); + setAttr("setup-autocompact", "aria-checked", String(draft.autocompact === true)); + setDisabled("setup-autocompact", !ready); + const autocompactCopy = !ready + ? "" + : draft.autocompact === true + ? "Claude and OpenCode sessions compact their context before it fills up." + : draft.autocompact === false + ? "Off. CodeDeck leaves context compaction to each harness." + : "Not set, so it stays off. CodeDeck leaves compaction to each harness."; + swapText("autocompact-copy", "rt-copy", autocompactCopy); + + const names = ready ? changeNames(saved) : []; + const barError = state.error && !ui.drawer && state.target ? state.error : ""; + setClass("setup-bar", `savebar${names.length > 0 ? " dirty" : ""}${barError ? " has-error" : ""}`); + const status = barError + || (state.loading + ? "Loading setup" + : !state.target + ? "Setup not loaded" + : actionInFlight + ? actionInFlightLabel + : names.length === 0 + ? "All changes saved" + : `${names.length} unsaved ${names.length === 1 ? "change" : "changes"}`); + swapText("setup-action-status", "sb-status", status); + swapText("setup-change-list", "sb-list", barError ? "" : names.join(", ")); + setDisabled("setup-discard", names.length === 0 || busy || !ready); + setDisabled("setup-review", names.length === 0 || busy || !ready); + + setClass("setup-scrim", `scrim${ui.drawer ? " open" : ""}`); + setClass("setup-drawer", `drawer${ui.drawer ? " open" : ""}`); + setAttr("setup-drawer", "aria-hidden", String(!ui.drawer)); + const drawerPath = element("setup-drawer-path"); + if (drawerPath && state.target?.config?.path) drawerPath.textContent = state.target.config.path; + setHtml("setup-result", renderReview()); + const offRoles = offCatalogRoles(saved); + const confirmHtml = offRoles.map((role) => { + const current = draft.agents[role]!; + return ``; + }).join(""); + if (setHtml("setup-confirmations", confirmHtml)) ui.confirmOff = {}; + const pending = offRoles.filter((role) => !ui.confirmOff[role]); + setDisabled("setup-apply", actionInFlight || !ui.reviewOk || pending.length > 0); + + setClass("setup-picker", `picker${ui.pickerRole ? " open" : ""}`); + setAttr("setup-picker", "aria-hidden", String(!ui.pickerRole)); + if (ui.pickerRole) { + setHtml("setup-picker-list", pickerHtml()); + setAttr("setup-picker-search", "aria-activedescendant", `picker-opt-${ui.pickerIndex}`); + } + + if (ready) ui.rendered = true; options.onChange?.(state); } - function fillCatalogChoices(): void { - const catalog = state.catalog; - for (const role of options.roles) { - const list = element(`catalog-models-${role}`); - if (!list) continue; - const choices = (catalog?.models ?? []).flatMap((harness) => - harness.providers.flatMap((provider) => provider.models.flatMap((model) => { - const values = [model.id, ...(model.aliases ?? [])].filter((value) => value.trim() !== ""); - return values.map((value) => `${harness.agent}:${value}`); - })), - ); - list.innerHTML = [...new Set(choices)] - .map((value) => ``) - .join(""); + function placePicker(): void { + const role = ui.pickerRole; + const picker = element("setup-picker"); + const rect = role ? element(`binding-${role}`)?.getBoundingClientRect?.() : undefined; + if (!picker?.style || !rect) return; + const view = globalThis as { innerWidth?: number; innerHeight?: number }; + const width = Math.min(Math.max(rect.width, 380), (view.innerWidth ?? 1024) - 32); + const left = Math.min(Math.max(16, rect.left), (view.innerWidth ?? 1024) - width - 16); + const below = (view.innerHeight ?? 768) - rect.bottom - 22; + const above = rect.top - 22; + const up = below < 260 && above > below; + const height = Math.min(420, up ? above : below); + picker.style.left = `${left}px`; + picker.style.width = `${width}px`; + picker.style.maxHeight = `${Math.max(160, height)}px`; + picker.style.top = up ? `${rect.top - 6 - Math.max(160, height)}px` : `${rect.bottom + 6}px`; + picker.style.transformOrigin = up ? "bottom left" : "top left"; + } + + function openPicker(role: Role): void { + if (!state.target || state.loading) return; + ui.pickerRole = role; + ui.pickerQuery = ""; + const search = element("setup-picker-search"); + if (search) search.value = ""; + const selected = pickerEntries().items.findIndex((item) => item.selected); + ui.pickerIndex = Math.max(0, selected); + placePicker(); + update(); + search?.focus?.(); + element(`picker-opt-${ui.pickerIndex}`)?.scrollIntoView?.({ block: "nearest" }); + } + + function closePicker(restoreFocus = true): void { + const role = ui.pickerRole; + if (!role) return; + ui.pickerRole = undefined; + update(); + if (restoreFocus) element(`binding-${role}`)?.focus?.(); + } + + function setPickerQuery(value: string): void { + ui.pickerQuery = value; + ui.pickerIndex = 0; + update(); + const list = element("setup-picker-list"); + if (list) list.scrollTop = 0; + } + + function movePicker(step: number): void { + const count = pickerEntries().items.length; + if (count === 0) return; + ui.pickerIndex = (ui.pickerIndex + step + count) % count; + update(); + element(`picker-opt-${ui.pickerIndex}`)?.scrollIntoView?.({ block: "nearest" }); + } + + function selectBinding(role: Role, harness: string, model: string): void { + const previous = draft.agents[role]; + const saved = savedDraft().agents[role]; + let effort = previous?.effort ?? ""; + if (saved && saved.harness === harness && saved.model === model) { + effort = saved.effort; + } else { + const supported = supportedEfforts(harness, model); + if (effort && supported && !supported.includes(effort)) { + const wanted = efforts.indexOf(effort); + effort = [...supported].reverse().find((level) => efforts.indexOf(level) <= wanted) ?? supported[0] ?? ""; + } } + draft.agents[role] = { harness, model, effort }; + delete ui.confirmOff[role]; + if (ui.pickerRole === role) closePicker(true); + else update(); } - function prefillTarget(): void { - const target = state.target; - if (!target) return; - for (const role of options.roles) { - const binding = target.bindings[role]; - const skip = element(`skip-${role}`); - const bindingInput = element(`binding-${role}`); - const effort = element(`effort-${role}`); - if (skip) skip.checked = true; - if (bindingInput) bindingInput.value = binding === undefined ? "" : `${binding.harness}:${binding.model}`; - if (effort) effort.value = target.efforts?.[role] ?? binding?.effort ?? "keep"; + function handleKey(event?: SetupPageEvent): void { + const key = event?.key; + if (ui.pickerRole) { + if (key === "ArrowDown" || key === "ArrowUp") { + event?.preventDefault?.(); + movePicker(key === "ArrowDown" ? 1 : -1); + } else if (key === "Enter") { + event?.preventDefault?.(); + const item = pickerEntries().items[ui.pickerIndex]; + if (item) selectBinding(ui.pickerRole, item.harness, item.model); + } else if (key === "Escape") { + event?.preventDefault?.(); + closePicker(true); + } + return; } + if (key === "Escape" && ui.drawer) closeReview(); + } + + function setEffort(role: Role, effort: string): void { + const current = draft.agents[role]; + if (!current || !efforts.includes(effort)) return; + current.effort = effort; update(); } - function readForm(): SetupPageFormValues { - const roles: SetupPageFormValues["roles"] = {}; - for (const role of options.roles) { - roles[role] = { - skip: Boolean(element(`skip-${role}`)?.checked), - binding: element(`binding-${role}`)?.value ?? "", - effort: element(`effort-${role}`)?.value ?? "keep", - }; + function revertRole(role: Role): void { + const saved = savedDraft().agents[role]; + if (saved) draft.agents[role] = saved; + else delete draft.agents[role]; + delete ui.confirmOff[role]; + update(); + } + + function choosePreset(name: string): void { + const preset = presets[name]; + if (!preset) return; + draft.policy = { investigate: preset.investigate, selfWork: preset.selfWork, tools: preset.tools }; + draft.custom = false; + update(); + } + + function chooseCustom(): void { + draft.policy = draft.policy ?? baselinePolicy(); + draft.custom = true; + update(); + } + + function setPolicyValue(key: string, value: string): void { + const axis = axes.find((candidate) => candidate.key === key); + if (!axis?.options.some((option) => option.value === value)) return; + draft.policy = { ...(draft.policy ?? baselinePolicy()), [key]: value } as SetupDraftPolicy; + draft.custom = true; + update(); + } + + function syncParallelism(): void { + const input = element("orchestrator-parallelism"); + if (input) input.value = draft.parallelism; + } + + function setParallelism(value: string): void { + draft.parallelism = value; + if (state.error) state.error = undefined; + update(); + } + + function setSandbox(value: string): void { + if (value !== "workspace-write" && value !== "danger-full-access") return; + draft.sandbox = value; + update(); + } + + function setAutocompact(value?: boolean): void { + draft.autocompact = value ?? !draft.autocompact; + update(); + } + + function resetDraft(): void { + draft = cloneDraft(savedDraft()); + ui.confirmOff = {}; + syncParallelism(); + } + + function discard(): void { + resetDraft(); + state.error = undefined; + update(); + } + + function confirmOffCatalog(role: Role, checked: boolean): void { + ui.confirmOff[role] = checked; + update(); + } + + async function openReview(): Promise<{ ok: boolean; status: number; payload: unknown } | undefined> { + if (changeNames().length === 0 || actionInFlight) return; + try { + buildSelection(); + } catch (error) { + state.error = error instanceof Error ? error.message : String(error); + update(); + return; } - return { - roles, - orchestrator: element("orchestrator-mode")?.value ?? "skip", - investigate: element("orchestrator-investigate")?.value ?? "none", - selfWork: element("orchestrator-self-work")?.value ?? "none", - tools: element("orchestrator-tools")?.value ?? "dispatch", - parallelism: element("orchestrator-parallelism")?.value ?? "", - sandbox: element("setup-sandbox")?.value ?? "skip", - autocompact: element("setup-autocompact")?.value ?? "skip", - }; + closePicker(false); + ui.drawer = true; + ui.reviewOk = false; + state.error = undefined; + state.envelope = undefined; + update(); + element("setup-drawer-close")?.focus?.(); + return await dryRun(); + } + + function closeReview(): void { + if (!ui.drawer) return; + ui.drawer = false; + if (state.error && state.envelope !== undefined) state.error = undefined; + update(); + element("setup-review")?.focus?.(); + } + + function showToast(text: string): void { + const toast = element("setup-toast"); + if (!toast) return; + toast.innerHTML = `${icon("check")}${escapeHtml(text)}`; + toast.className = "toast show"; + clearTimeout(toastTimer as ReturnType); + toastTimer = setTimeout(() => { toast.className = "toast"; }, 2800); + } + + function onClick(event?: SetupPageEvent): void { + type Actor = { getAttribute(name: string): string | null; checked?: boolean }; + const target = event?.target as { closest?(selector: string): Actor | null } | undefined; + const actor = target?.closest?.("[data-act]") ?? null; + const act = actor?.getAttribute("data-act"); + if (ui.pickerRole && !target?.closest?.("#setup-picker") && act !== "open-picker") closePicker(false); + if (!actor || !act) return; + const role = actor.getAttribute("data-role") as Role | null; + const value = actor.getAttribute("data-value") ?? ""; + if (act === "open-picker" && role) { + if (ui.pickerRole === role) closePicker(true); + else openPicker(role); + } else if (act === "pick" && ui.pickerRole) { + selectBinding(ui.pickerRole, actor.getAttribute("data-harness") ?? "", actor.getAttribute("data-model") ?? ""); + } else if (act === "effort" && role) setEffort(role, value); + else if (act === "revert" && role) revertRole(role); + else if (act === "preset") choosePreset(value); + else if (act === "custom") chooseCustom(); + else if (act === "policy-value") setPolicyValue(actor.getAttribute("data-key") ?? "", value); + else if (act === "sandbox") setSandbox(value); + else if (act === "autocompact") setAutocompact(); + else if (act === "discard") discard(); + else if (act === "review") void openReview(); + else if (act === "close-review") closeReview(); + else if (act === "apply") void apply(); + else if (act === "refresh") void refreshCatalog(); + else if (act === "confirm-off" && role) confirmOffCatalog(role, Boolean(actor.checked)); } function bindEvents(): void { - for (const role of options.roles) { - element(`skip-${role}`)?.addEventListener?.("change", update); - element(`binding-${role}`)?.addEventListener?.("input", update); - element(`effort-${role}`)?.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(); }); + if (ui.bound) return; + ui.bound = true; + const doc = options.document; + doc?.addEventListener?.("click", onClick); + doc?.addEventListener?.("keydown", handleKey); + doc?.addEventListener?.("scroll", () => { if (ui.pickerRole) placePicker(); }, true); + (globalThis as { addEventListener?(name: string, callback: () => void): void }) + .addEventListener?.("resize", () => { if (ui.pickerRole) placePicker(); }); + element("setup-picker-search")?.addEventListener?.("input", () => setPickerQuery(element("setup-picker-search")?.value ?? "")); + element("orchestrator-parallelism")?.addEventListener?.("input", () => setParallelism(element("orchestrator-parallelism")?.value ?? "")); } async function loadJson( @@ -528,32 +1305,37 @@ export function createSetupPageController(options: SetupPageControllerOptions) { }; } + async function loadState(): Promise { + const { response, payload } = await loadJson("/api/setup/state"); + if (!response.ok) { + state.error = typeof payload.error === "string" ? payload.error : "Could not load setup state."; + return false; + } + state.target = payload as unknown as SetupPageTargetState; + resetDraft(); + return true; + } + async function start(): Promise { bindEvents(); update(); try { - const { response, payload } = await loadJson("/api/setup/state"); - if (!response.ok) { - state.error = typeof payload.error === "string" ? payload.error : "Could not load setup state."; - state.loading = false; - update(); - return state; - } - state.target = payload as unknown as SetupPageTargetState; - prefillTarget(); - const catalog = await loadJson("/api/setup/catalog"); - if (catalog.response.ok) { - state.catalog = catalog.payload as unknown as BatchModelsResult; - state.discoveryError = typeof catalog.payload.discoveryError === "string" ? catalog.payload.discoveryError : undefined; - fillCatalogChoices(); - } else { - state.discoveryError = typeof catalog.payload.error === "string" ? catalog.payload.error : "Could not load model catalog."; + if (await loadState()) { + const catalog = await loadJson("/api/setup/catalog"); + if (catalog.response.ok) { + state.catalog = catalog.payload as unknown as BatchModelsResult; + state.discoveryError = typeof catalog.payload.discoveryError === "string" ? catalog.payload.discoveryError : undefined; + } else { + state.discoveryError = typeof catalog.payload.error === "string" ? catalog.payload.error : "Could not load model catalog."; + } } } catch (error) { state.error = error instanceof Error ? error.message : String(error); } state.loading = false; update(); + // Class changes made by the first render should land without sliding. + setTimeout(() => setClass("setup-root", `${element("setup-root")?.className ?? "setup-root"} settled`), 60); return state; } @@ -580,7 +1362,6 @@ export function createSetupPageController(options: SetupPageControllerOptions) { } state.catalog = payload as unknown as BatchModelsResult; state.discoveryError = typeof payload.discoveryError === "string" ? payload.discoveryError : undefined; - fillCatalogChoices(); return { ok: true, status: response.status, payload }; } catch (error) { state.discoveryError = error instanceof Error ? error.message : String(error); @@ -599,13 +1380,34 @@ export function createSetupPageController(options: SetupPageControllerOptions) { return options.buildSelection(values, state.target.bindings); } + // The dry-run route never accepts an off-catalog model, since only apply + // takes offCatalogConfirmed. A 422 whose only failures are models the user + // can confirm in the drawer is still a reviewable plan. + function onlyOffCatalogFailures(body: Record): boolean { + const validations = typeof body.validacoes === "object" && body.validacoes !== null + ? body.validacoes as Record + : {}; + const entries = Array.isArray(validations.bindings) ? validations.bindings as Array> : []; + const failed = entries.filter((entry) => entry.status !== "accepted"); + const confirmable = offCatalogRoles(); + return failed.length > 0 && failed.every((entry) => + (entry.status === "unknown-model" || entry.status === "unverified") && + confirmable.includes(entry.role as Role), + ); + } + async function postAction( path: string, selection: ReturnType, ): Promise<{ ok: boolean; status: number; payload: unknown }> { + const isDryRun = path.endsWith("/dry-run"); state.error = undefined; actionInFlight = true; - actionInFlightLabel = path.endsWith("/dry-run") ? "Preparing preview..." : "Saving setup..."; + actionInFlightLabel = isDryRun ? "Checking changes" : "Saving"; + if (isDryRun) { + state.envelope = undefined; + ui.reviewOk = false; + } update(); try { const response = await options.fetcher(path, { @@ -614,25 +1416,28 @@ export function createSetupPageController(options: SetupPageControllerOptions) { body: JSON.stringify(selection), }); const payload = await response.json(); + const body = typeof payload === "object" && payload !== null ? payload as Record : {}; + const result = typeof body.resultado === "object" && body.resultado !== null + ? body.resultado as Record + : {}; if (response.status === 403) { state.error = options.expiredMessage; } else if (!response.ok) { - const body = typeof payload === "object" && payload !== null ? payload as Record : {}; - const result = typeof body.resultado === "object" && body.resultado !== null - ? body.resultado as Record - : {}; state.error = typeof result.message === "string" ? result.message : typeof body.error === "string" ? body.error : "Setup request failed."; } + if (isDryRun) { + const confirmable = response.status === 422 && onlyOffCatalogFailures(body); + if (confirmable) state.error = undefined; + ui.reviewOk = (response.ok && result.status === "dry-run") || confirmable; + } state.envelope = payload; - update(); return { ok: response.ok, status: response.status, payload }; } catch (error) { state.error = error instanceof Error ? error.message : String(error); - update(); return { ok: false, status: 500, payload: { error: state.error } }; } finally { actionInFlight = false; @@ -660,52 +1465,124 @@ export function createSetupPageController(options: SetupPageControllerOptions) { } const confirmed: Partial> = {}; - for (const role of options.roles) { + for (const role of roles) { const binding = selection.agents[role]; const previous = state.target?.bindings[role]; if (!binding || (previous?.harness === binding.harness && previous.model === binding.model)) continue; - const harness = state.catalog?.models.find((candidate) => candidate.agent === binding.harness); - if (!harness?.available) continue; - const listed = harness.providers.some((provider) => provider.models.some((model) => - model.id === binding.model || model.aliases?.includes(binding.model), - )); - if (listed) continue; - const accept = options.confirm?.( - `Model "${binding.model}" is not in the ${binding.harness} catalog for ${role}. Apply it anyway?`, - ) ?? false; - if (!accept) { - state.error = `Apply cancelled for the off-catalog model selected for ${role}.`; + if (!isOffCatalog(binding.harness, binding.model)) continue; + if (!ui.confirmOff[role]) { + state.error = `Confirm the off-catalog model for ${role} before saving.`; update(); return; } confirmed[role] = true; } if (Object.keys(confirmed).length > 0) selection.offCatalogConfirmed = confirmed; - return await postAction("/api/setup/apply", selection); + const result = await postAction("/api/setup/apply", selection); + const body = typeof result.payload === "object" && result.payload !== null ? result.payload as Record : {}; + const status = typeof body.resultado === "object" && body.resultado !== null + ? (body.resultado as Record).status + : undefined; + if (result.ok && status === "applied") { + ui.drawer = false; + ui.reviewOk = false; + showToast(`Saved to ${state.target?.config?.path ?? "config.json"}`); + try { + await loadState(); + } catch (error) { + state.error = error instanceof Error ? error.message : String(error); + } + update(); + element("setup-review")?.focus?.(); + } + return result; } - return { state, start, refreshCatalog, buildSelection, dryRun, apply }; + return { + state, + get draft() { + return draft; + }, + start, + refreshCatalog, + buildSelection, + readForm, + dryRun, + apply, + changeNames: () => changeNames(), + openPicker, + closePicker, + setPickerQuery, + handleKey, + selectBinding, + setEffort, + revertRole, + choosePreset, + chooseCustom, + setPolicyValue, + setParallelism, + setSandbox, + setAutocompact, + discard, + openReview, + closeReview, + confirmOffCatalog, + }; } -const roleControls = ROLES.map((role) => ` -
-

${role}

-

Currentnot set

- - -
`).join(""); +function icon(name: SetupIconName, className = ""): string { + return ``; +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function renderFingerprint(mode: OrchestratorMode, live = false): string { + const points = SETUP_POLICY_AXES.map((axis, row) => { + const level = Math.max(0, axis.options.findIndex((option) => option.value === mode[axis.key])); + return [3 + level * 6, 3 + row * 6] as const; + }); + const grid = [0, 1, 2].flatMap((row) => [0, 1, 2].map((col) => + ``)).join(""); + const line = points.map(([x, y], index) => `${index === 0 ? "M" : "L"}${x} ${y}`).join(" "); + const dots = points.map(([x, y], index) => + `
`).join(""); + return `
${dots}`; +} + +function renderRoleRow(role: Role): string { + const segments = REASONING_EFFORTS.map((effort) => + ``).join(""); + return `
+
${capitalize(role)}
${ROLE_COPY[role]}
+
+
${segments}
+
+
`; +} + +function renderPolicyTable(): string { + const presetNames = Object.keys(ORCHESTRATOR_PRESETS) as Array; + const columns = [...presetNames, "custom"] as const; + const heads = columns.map((name, index) => { + const mode = name === "custom" ? ORCHESTRATOR_PRESETS.balanced : ORCHESTRATOR_PRESETS[name]; + return ``; + }).join(""); + const rows = SETUP_POLICY_AXES.map((axis, row) => { + const last = row === SETUP_POLICY_AXES.length - 1 ? " last" : ""; + const presetCells = presetNames.map((name, index) => { + const option = axis.options.find((candidate) => candidate.value === ORCHESTRATOR_PRESETS[name][axis.key])!; + return `
${icon(option.icon)}${option.label}
`; + }).join(""); + const start = axis.options.findIndex((option) => option.value === ORCHESTRATOR_PRESETS.balanced[axis.key]); + const picks = axis.options.map((option) => + ``).join(""); + const custom = `
${picks}
${axis.options[start]!.label}
`; + return `
${axis.label}
${presetCells}${custom}`; + }).join(""); + return `
${heads}${rows}
`; +} export function renderSetupPage(options: SetupPageOptions = {}): string { const pages = options.pages ?? [{ label: "Setup", path: "/setup" }]; @@ -723,63 +1600,67 @@ export function renderSetupPage(options: SetupPageOptions = {}): string { + ${renderSetupSprite()} ${topbar} +
-
-

Setup

Choose model bindings and runtime defaults for this workspace.

- Global configuration +
+

Setup

Pick the harness and model each role runs on. Saving writes config.json.

+
Loading catalog
+ -
-
Setup stateLoading setup...
-
Model catalognot loaded
- +
+

Roles

Click a model to change it. Nothing is written until you save.

+
+
RoleHarness and modelReasoning effortState
+ ${SETUP_ROLE_ORDER.map((role) => renderRoleRow(role)).join("")} +
-
-

Role bindings

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

-
${roleControls}
-
+
+
+

Orchestrator policy

How much the orchestrator does itself before it hands work off.

+ ${renderPolicyTable()} +

Loading policy

+
+
-
-

Orchestrator

Choose a preset or describe a custom policy.

-
-

Currentnot set

- - -
- -
-

Other settings

-
-

Current sandboxnot set

-

Current autocompactnot set

-
-
+
-
-

Preview

Check each change before saving.

-
-
-
- Loading setup... - - +
+
Loading setup
+ +
+ +
+ + + +
+
@@ -802,26 +1685,228 @@ export function renderSetupPage(options: SetupPageOptions = {}): string { `; } +const LEVEL_RULES = [1, 2, 3, 4, 5].map((level) => + `.meter.lvl-${level} .seg:nth-child(-n+${level})::before{background:var(--text)}.meter.changed.lvl-${level} .seg:nth-child(-n+${level})::before{background:var(--blue)}`).join(""); +const SEGMENT_DELAYS = [2, 3, 4, 5].map((n) => `.seg:nth-child(${n})::before{transition-delay:${(n - 1) * 28}ms}`).join(""); +const COLUMN_RULES = [0, 1, 2, 3].map((column) => + `.cmp.col-${column} .cmp-hl{transform:translateX(${column * 100}%)}.cmp.col-${column} .c${column}{color:var(--text)}.cmp.col-${column} .cmp-cell.c${column}:not(.custom) .ico,.cmp.col-${column} .cmp-head.c${column} .fp{color:var(--blue-text)}`).join(""); +const PICK_RULES = [0, 1, 2].map((index) => + `.cmp-picks.at-${index} .pick-thumb{transform:translateX(${index * 28}px)}.cmp-picks.at-${index} .cmp-pick:nth-of-type(${index + 1}){color:var(--text)}.cmp.col-3 .cmp-picks.at-${index} .cmp-pick:nth-of-type(${index + 1}){color:var(--blue-text)}`).join(""); + export const SETUP_CSS = ` +:root{--blue-text:#60a5fa;--blue-soft:rgba(0,112,243,.1);--blue-line:rgba(0,112,243,.55);--warn:#f5a524;--warn-text:#f7c56b;--ok-text:#8fe0bc;--err-text:#ff9592;--ease:cubic-bezier(.2,.8,.2,1)} +body{font-size:13px;line-height:1.45;-webkit-font-smoothing:antialiased} +button{cursor:pointer;color:inherit}button:disabled{cursor:not-allowed} +:focus-visible{outline:2px solid var(--blue);outline-offset:2px} +h1,h2,h3,p{margin:0}h1,h2{text-wrap:balance} +[hidden]{display:none!important} +.mono{font-family:var(--font-mono)} +.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} +.sprite{position:absolute;width:0;height:0;overflow:hidden} +.ico{display:block;flex:none;width:16px;height:16px} .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}} +.setup-main{width:min(100%,1240px);margin:0 auto;padding:28px 28px 0} +.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;flex-wrap:wrap;margin-bottom:8px} +.page-head h1{margin-bottom:6px;font-size:24px;font-weight:600;letter-spacing:-.6px} +.lede{max-width:72ch;color:var(--text-muted)}.lede code{color:var(--text);font-size:12px;overflow-wrap:anywhere} +.catalog{display:flex;align-items:center;gap:8px;color:var(--text-faint);font-size:12px}.catalog-text{display:inline-block}.catalog-text.warn{color:var(--warn-text)} +.icon-btn{display:inline-grid;place-items:center;flex:none;width:30px;height:30px;padding:0;border:1px solid var(--border-strong);border-radius:6px;background:var(--surface);color:var(--text-muted);transition:color .15s,background-color .15s} +.icon-btn .ico{width:14px;height:14px}.icon-btn:hover:not(:disabled){color:var(--text);background:var(--surface-raised)} +.icon-btn.spinning .ico{animation:cd-spin .8s linear infinite} +.load-error{margin-top:16px;padding:10px 12px;border:1px solid rgba(229,72,77,.5);border-radius:7px;color:var(--err-text);font-size:12px} +.sec{margin-top:32px} +.sec-head{display:flex;align-items:baseline;justify-content:space-between;gap:6px 12px;flex-wrap:wrap;margin-bottom:12px} +.sec-head h2{display:flex;align-items:center;font-size:14px;font-weight:600;letter-spacing:-.1px}.sec-head p{color:var(--text-faint);font-size:12px} +.sec-dot{display:inline-block;flex:none;width:0;height:7px;border-radius:50%;background:var(--blue);transform:scale(0);transition:transform .25s var(--ease),width .25s var(--ease),margin-right .25s var(--ease)} +.sec.changed .sec-head .sec-dot,.rt-row.changed .sec-dot{width:7px;margin-right:8px;transform:scale(1)} +.tag{display:inline-flex;align-items:center;border:1px solid var(--border-strong);border-radius:999px;padding:1px 7px;color:var(--text-muted);font-size:10.5px;font-weight:400;white-space:nowrap} +.tag.warn{border-color:rgba(245,165,36,.45);color:var(--warn-text)} +.hchip{display:inline-flex;align-items:center;gap:6px;color:var(--text-muted);font-size:12px;white-space:nowrap} +.hmark{display:inline-grid;place-items:center;flex:none;width:22px;height:22px;border-radius:6px;background:var(--surface-raised)} +.hmark svg{display:block;width:14px;height:14px}.hchip[data-h="omp"] .hmark svg{width:17px;height:13px}.hchip[data-h="opencode"] .hmark svg{color:var(--text)} +.roster{border-top:1px solid var(--border)} +.r-row{display:grid;grid-template-columns:minmax(190px,1fr) minmax(0,1.35fr) 236px 170px;align-items:center;gap:20px;padding:13px 12px;border-bottom:1px solid var(--border);transition:background-color .25s ease} +.r-head{padding-block:8px;color:var(--text-faint);font-size:11.5px} +.r-row.changed{background:var(--blue-soft)} +.r-role{display:flex;align-items:flex-start;gap:10px;min-width:0} +.r-dot{flex:none;width:7px;height:7px;margin-top:6px;border-radius:50%;background:var(--blue);transform:scale(0);transition:transform .25s var(--ease)} +.r-row.changed .r-dot{transform:scale(1)} +.r-name{font-size:13px;font-weight:600}.r-desc{color:var(--text-faint);font-size:12px} +.model-trigger{display:flex;align-items:center;gap:10px;width:100%;min-height:38px;padding:6px 10px;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);text-align:left;transition:border-color .15s,background-color .15s} +.model-trigger:hover:not(:disabled){border-color:#3a3a3a;background:#101010} +.model-trigger[aria-expanded="true"]{border-color:var(--blue)} +.model-trigger:disabled{opacity:.6} +.model-body{display:flex;align-items:center;gap:10px;flex:1;min-width:0} +.model-name{flex:1;min-width:0;font-size:12px;overflow-wrap:anywhere} +.model-empty{flex:1;color:var(--text-faint);font-size:12px} +.chev{width:14px;height:14px;color:var(--text-faint);transition:transform .2s var(--ease)}.model-trigger[aria-expanded="true"] .chev{transform:rotate(180deg)} +.ready .model-body,.ready .r-state>*{animation:cd-in-a .26s var(--ease)} +.meter{display:flex;align-items:center;gap:3px;min-height:24px} +.meter-segs{display:flex;gap:3px} +.seg{display:grid;place-items:center;width:22px;height:20px;padding:0;border:0;background:none} +.seg::before{content:"";display:block;width:100%;height:8px;border-radius:2px;background:var(--border-strong);transition:background-color .18s ease} +${SEGMENT_DELAYS} +${LEVEL_RULES} +.seg:not(:disabled):hover::before{filter:brightness(1.7)} +.seg:disabled::before{opacity:.35} +.meter-label{display:inline-block;min-width:48px;margin-left:8px;color:var(--text-muted);font-size:12px} +.meter-note{display:none;color:var(--text-faint);font-size:12px} +.meter.none .meter-segs,.meter.none .meter-label,.meter.unbound .meter-label{display:none} +.meter.none .meter-note,.meter.unbound .meter-note{display:inline-block} +.meter.unbound .meter-note{margin-left:8px} +.r-state{display:flex;flex-direction:column;align-items:flex-start;gap:2px;min-width:0;font-size:12px} +.state-note{display:inline-flex;align-items:center;gap:5px;color:var(--text-faint)}.state-note .ico{width:13px;height:13px} +.was{color:var(--text-faint);overflow-wrap:anywhere}.was .mono{color:var(--text-muted);text-decoration:line-through;text-decoration-color:var(--text-faint)} +.link-btn{display:inline-flex;align-items:center;gap:4px;padding:2px 4px;margin-left:-4px;border:0;border-radius:4px;background:none;color:var(--text-muted);font-size:12px} +.link-btn .ico{width:13px;height:13px}.link-btn:hover{color:var(--text)} +.bottom{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(0,1fr);gap:48px} +.cmp-wrap{overflow-x:auto;margin-inline:-4px;padding:0 4px 2px} +.cmp{position:relative;display:grid;grid-template-columns:minmax(118px,.9fr) repeat(4,minmax(112px,1fr));grid-template-rows:repeat(4,auto);min-width:590px} +.cmp>*{position:relative;z-index:1} +.cmp .cmp-hl{position:absolute;inset:0;z-index:0;grid-column:2/3;grid-row:1/-1;border:1px dashed transparent;border-radius:9px;background:var(--blue-soft);box-shadow:inset 0 2px 0 var(--blue);transition:transform .38s var(--ease),background-color .25s,box-shadow .25s,border-color .25s} +.cmp.col-none .cmp-hl{background:transparent;box-shadow:none;border-color:var(--border-strong)} +.cmp-corner,.cmp-head,.cmp-label,.cmp-cell{border-bottom:1px solid var(--border)} +.cmp-head{display:flex;align-items:center;gap:8px;padding:11px 12px;border-top:0;border-inline:0;background:none;color:var(--text-muted);font-size:12.5px;font-weight:600;text-align:left;transition:color .2s} +.cmp-head:hover:not(:disabled){color:var(--text)} +.fp{flex:none;width:18px;height:18px;color:var(--text-faint);transition:color .25s} +.fp-dot{fill:var(--border-strong)}.fp-pt{fill:currentColor;transition:cx .32s var(--ease)} +.fp-line{fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;transition:d .32s var(--ease)} +.cmp-label{display:flex;align-items:center;padding:10px 12px 10px 0;color:var(--text-muted);font-size:12px} +.cmp-cell{display:flex;align-items:center;gap:8px;padding:10px 12px;color:var(--text-faint);font-size:12px;transition:color .25s} +.cmp-cell .ico{width:14px;height:14px;transition:color .25s} +.cmp-cell.last,.cmp-label.last{border-bottom-color:transparent} +.cmp-cell.custom{flex-direction:column;align-items:flex-start;gap:5px;padding-block:8px} +${COLUMN_RULES} +.cmp-picks{position:relative;display:inline-flex;gap:2px;padding:2px;border:1px solid var(--border);border-radius:6px;background:var(--bg)} +.pick-thumb{position:absolute;top:2px;left:2px;width:26px;height:22px;border-radius:4px;background:var(--surface-raised);transition:transform .3s var(--ease)} +.cmp-pick{position:relative;display:grid;place-items:center;width:26px;height:22px;padding:0;border:0;border-radius:4px;background:none;color:var(--text-faint);transition:color .2s} +.cmp-pick:hover:not(:disabled){color:var(--text)} +${PICK_RULES} +.cmp-custom-label{display:inline-block;font-size:11.5px} +.policy-caption{display:block;margin-top:12px;color:var(--text-muted);font-size:12px} +.pf{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-top:16px;padding-top:14px;border-top:1px solid var(--border)} +.pf label{display:grid;gap:2px}.pf-label{font-size:12.5px;font-weight:600}.pf-copy{max-width:52ch;color:var(--text-muted);font-size:12px} +.num{flex:none;width:112px;min-height:34px;padding:6px 9px;border:1px solid var(--border-strong);border-radius:6px;background:var(--surface);color:var(--text);font:12px var(--font-mono)} +.num::placeholder{color:var(--text-faint)}.num:focus{outline:2px solid var(--blue);outline-offset:1px}.num:disabled{opacity:.5} +.rt{display:grid} +.rt-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-block:12px;border-top:1px solid var(--border)} +.rt-row:first-child{padding-top:0;border-top:0} +.rt-label{display:flex;align-items:center;margin-bottom:2px;font-size:12.5px;font-weight:600}.rt-label .tag{margin-left:8px} +.rt-copy{display:block;max-width:46ch;min-height:1.45em;color:var(--text-muted);font-size:12px;transition:color .2s}.rt-copy.warn{color:var(--warn-text)} +.seg-ctl{position:relative;display:grid;flex:none;grid-template-columns:1fr 1fr;gap:3px;padding:3px;border:1px solid var(--border);border-radius:7px;background:var(--bg)} +.seg-thumb{position:absolute;top:3px;bottom:3px;left:3px;width:calc((100% - 9px) / 2);border-radius:4px;background:var(--surface-raised);transition:transform .32s var(--ease),opacity .2s} +.seg-ctl.at-1 .seg-thumb{transform:translateX(calc(100% + 3px))}.seg-ctl.at-none .seg-thumb{opacity:0} +.seg-ctl button{position:relative;display:flex;align-items:center;justify-content:center;gap:6px;padding:5px 10px;border:0;border-radius:4px;background:none;color:var(--text-faint);font-size:11.5px;white-space:nowrap;transition:color .2s} +.seg-ctl button .ico{width:14px;height:14px;transition:color .2s} +.seg-ctl button:hover:not(:disabled){color:var(--text)} +.seg-ctl.at-0 #sandbox-workspace-write,.seg-ctl.at-1 #sandbox-danger-full-access{color:var(--text)} +.seg-ctl.at-0 #sandbox-workspace-write .ico{color:var(--success)}.seg-ctl.at-1 #sandbox-danger-full-access .ico{color:var(--warn)} +.switch{position:relative;flex:none;width:36px;height:20px;padding:0;border:1px solid var(--border-strong);border-radius:999px;background:var(--surface-raised);transition:background-color .2s,border-color .2s} +.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--text-muted);transition:transform .25s var(--ease),background-color .2s} +.switch.unset{border-style:dashed} +.switch[aria-checked="true"]{border-color:var(--blue);background:var(--blue)}.switch[aria-checked="true"]::after{background:#fff;transform:translateX(16px)} +.switch:disabled{opacity:.5} +.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:34px;padding:7px 13px;border:1px solid var(--border-strong);border-radius:6px;background:var(--surface);font-size:12px;font-weight:600;white-space:nowrap;transition:background-color .15s,opacity .2s} +.btn:hover:not(:disabled){background:var(--surface-raised)} +.btn.primary{border-color:var(--blue);background:var(--blue);color:#fff}.btn.primary:hover:not(:disabled){background:#0063d9} +.btn.ghost{border-color:transparent;background:none;color:var(--text-muted)}.btn.ghost:hover:not(:disabled){color:var(--text);background:var(--surface-raised)} +.btn:disabled{opacity:.4} +.savebar{position:sticky;bottom:0;z-index:5;display:flex;align-items:center;gap:8px;margin:36px -28px 0;padding:12px 28px;border-top:1px solid var(--border);background:rgba(0,0,0,.88);backdrop-filter:blur(10px)} +.sb-left{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 10px;flex:1;min-width:0} +.sb-dot{flex:none;width:7px;height:7px;border-radius:50%;background:var(--blue);align-self:center;transform:scale(0);margin-right:-10px;transition:transform .25s var(--ease),margin .25s var(--ease)} +.savebar.dirty .sb-dot{transform:scale(1);margin-right:0} +.sb-status{display:inline-block;font-size:12.5px}.savebar.has-error .sb-status{color:var(--err-text);font-weight:500} +.sb-list{display:inline-block;color:var(--text-muted);font-size:12px;overflow-wrap:anywhere} +.scrim{position:fixed;inset:0;z-index:20;background:rgba(0,0,0,.6);opacity:0;visibility:hidden;transition:opacity .3s ease,visibility 0s .3s} +.scrim.open{opacity:1;visibility:visible;transition:opacity .3s ease} +.drawer{position:fixed;top:0;right:0;bottom:0;z-index:21;display:flex;flex-direction:column;width:min(460px,100%);border-left:1px solid var(--border-strong);background:var(--surface);box-shadow:-24px 0 60px rgba(0,0,0,.5);transform:translateX(100%);visibility:hidden;transition:transform .36s var(--ease),visibility 0s .36s} +.drawer.open{transform:none;visibility:visible;transition:transform .36s var(--ease)} +.dr-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:18px 20px;border-bottom:1px solid var(--border)} +.dr-head h2{font-size:15px;font-weight:600}.dr-path{margin-top:2px;color:var(--text-faint);font-size:11.5px;overflow-wrap:anywhere} +.dr-body{flex:1;display:grid;align-content:start;gap:18px;padding:18px 20px;overflow:auto} +.result-content{display:grid;gap:22px}.result-content:empty{display:none} +.dr-section{animation:cd-in-a .32s var(--ease) both} +.dr-section:nth-child(2){animation-delay:.05s}.dr-section:nth-child(3){animation-delay:.1s}.dr-section:nth-child(4){animation-delay:.15s} +.dr-foot{display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--border)} +.dr-label{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;color:var(--text-faint);font-size:11.5px;font-weight:400} +.result-status{display:flex;align-items:flex-start;gap:10px;padding:11px 12px;border:1px solid var(--border-strong);border-radius:7px;background:var(--bg);font-size:12px} +.result-status .ico{width:15px;height:15px;margin-top:1px}.result-status>span{display:grid;gap:2px} +.result-msg{color:var(--text-muted);overflow-wrap:anywhere} +.status-dry-run{border-color:var(--blue-line)}.status-dry-run .ico{color:var(--blue-text)} +.status-applied,.status-unchanged{border-color:rgba(16,185,129,.45)}.status-applied .ico,.status-unchanged .ico{color:var(--success)} +.status-error,.status-aborted{border-color:rgba(229,72,77,.55)}.status-error .ico,.status-aborted .ico{color:var(--error)} +.ch-list{display:grid;gap:6px} +.ch{display:grid;gap:4px;padding:10px 12px;border:1px solid var(--border);border-radius:7px;background:var(--bg)} +.ch-name{font-size:12px;font-weight:600} +.ch-vals{display:flex;align-items:center;flex-wrap:wrap;gap:4px 8px;color:var(--text-muted);font:11.5px var(--font-mono)} +.ch-vals .before{color:var(--text-faint);text-decoration:line-through;text-decoration-color:var(--text-faint);overflow-wrap:anywhere} +.ch-vals .after{color:var(--text);overflow-wrap:anywhere}.ch-to{color:var(--text-faint);font-family:var(--font-sans)} +.marker{color:var(--blue-text);font:500 10.5px var(--font-sans)} +.counts{display:flex;gap:8px;font:11.5px var(--font-mono)}.add{color:var(--ok-text)}.del{color:var(--err-text)} +.diff{max-height:320px;overflow:auto;border:1px solid var(--border);border-radius:7px;background:var(--bg);font:11px/1.7 var(--font-mono)} +.dl{display:grid;grid-template-columns:30px 30px 14px minmax(max-content,1fr);white-space:pre} +.dl>span{padding-right:6px}.dl .no{color:#3d3d3d;text-align:right;user-select:none}.dl .mk{color:var(--text-faint);user-select:none} +.dl.plus{background:rgba(16,185,129,.09)}.dl.plus .mk{color:var(--ok-text)} +.dl.minus{background:rgba(229,72,77,.09)}.dl.minus .mk{color:var(--err-text)} +.dl .k{color:#8c8c8c}.dl .s{color:#cfcfcf}.dl.plus .s{color:#bff0da}.dl.minus .s{color:#ffc6c4} +.fold{padding:2px 0 2px 76px;border-block:1px solid var(--border);background:#0d0d0d;color:var(--text-faint);font:10.5px var(--font-sans)} +.dr-note{margin-top:6px;color:var(--text-faint);font-size:11px} +.checks{display:grid;gap:7px} +.check{display:flex;align-items:flex-start;gap:8px;color:var(--text-muted);font-size:12px} +.check .ico{width:14px;height:14px;margin-top:1px}.check.ok .ico{color:var(--success)}.check.warn .ico{color:var(--warn)} +.check strong{color:var(--text);font-weight:500}.check-msg{display:block;color:var(--text-faint);overflow-wrap:anywhere} +.empty{margin:0;color:var(--text-faint);font-size:12px} +.confirmations{display:grid;gap:8px}.confirmations:empty{display:none} +.confirm{display:flex;align-items:flex-start;gap:8px;padding:10px 12px;border:1px solid rgba(245,165,36,.4);border-radius:7px;color:var(--warn-text);font-size:12px;animation:cd-in-a .3s var(--ease) both} +.confirm input{margin-top:2px;accent-color:var(--warn)}.confirm .mono{overflow-wrap:anywhere} +.dr-loading{display:grid;gap:10px;color:var(--text-faint);font-size:12px} +.sk{display:block;height:34px;border-radius:7px;background:linear-gradient(90deg,#111 0%,#1c1c1c 50%,#111 100%);background-size:200% 100%;animation:cd-shimmer 1.2s linear infinite}.sk.short{width:62%} +.raw-response{color:var(--text-faint);font-size:11.5px}.raw-response summary{cursor:pointer} +.raw-response pre{max-height:320px;overflow:auto;margin:8px 0 0;padding:10px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--text-muted);font:10.5px/1.5 var(--font-mono);white-space:pre-wrap;overflow-wrap:anywhere} +.picker{position:fixed;top:0;left:0;z-index:30;display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--border-strong);border-radius:9px;background:var(--surface);box-shadow:0 18px 40px rgba(0,0,0,.6);opacity:0;visibility:hidden;transform:translateY(-4px) scale(.98);transform-origin:top left;transition:opacity .16s ease,transform .18s var(--ease),visibility 0s .18s} +.picker.open{opacity:1;visibility:visible;transform:none;transition:opacity .16s ease,transform .18s var(--ease)} +.search{display:block;width:100%;padding:11px 12px;border:0;border-bottom:1px solid var(--border);background:none;color:var(--text);font-size:12.5px;outline:none} +.search::placeholder{color:var(--text-faint)} +.pk-list{flex:1;min-height:0;padding:6px;overflow:auto} +.pk-group+.pk-group{margin-top:6px;padding-top:6px;border-top:1px solid var(--border)} +.pk-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:5px 6px} +.pk-head.off .hchip{opacity:.5} +.pk-note{min-width:0;color:var(--text-faint);font-size:11px;text-align:right;overflow-wrap:anywhere} +.pk-item{display:flex;align-items:center;gap:8px;width:100%;padding:7px 8px 7px 34px;border:0;border-radius:5px;background:none;color:var(--text-muted);font-size:12px;text-align:left} +.pk-item:hover,.pk-item.kbd{background:var(--surface-raised);color:var(--text)} +.pk-model{min-width:0;overflow-wrap:anywhere}.pk-name{flex:1;min-width:64px;overflow:hidden;color:var(--text-faint);font-size:11px;text-align:right;text-overflow:ellipsis;white-space:nowrap} +.pk-item .tag{margin-left:auto}.pk-name+.tag{margin-left:0} +.pk-item.sel{color:var(--text)}.pk-check{width:14px;height:14px;color:var(--blue-text)} +.pk-custom{justify-content:space-between;padding-left:8px} +.pk-empty{margin:0;padding:12px;color:var(--text-faint);font-size:12px} +.toast{position:fixed;left:50%;bottom:24px;z-index:40;display:flex;align-items:center;gap:8px;max-width:calc(100% - 32px);padding:10px 14px;border:1px solid rgba(16,185,129,.45);border-radius:8px;background:var(--surface);box-shadow:0 10px 30px rgba(0,0,0,.5);font-size:12px;opacity:0;visibility:hidden;transform:translate(-50%,10px);transition:opacity .2s ease,transform .3s var(--ease),visibility 0s .3s} +.toast.show{opacity:1;visibility:visible;transform:translate(-50%,0);transition:opacity .2s ease,transform .3s var(--ease)} +.toast .ico{width:14px;height:14px;color:var(--success)}.toast span{overflow-wrap:anywhere} +.swap-a{animation:cd-in-a .26s var(--ease)}.swap-b{animation:cd-in-b .26s var(--ease)} +@keyframes cd-in-a{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}} +@keyframes cd-in-b{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}} +@keyframes cd-spin{to{transform:rotate(360deg)}} +@keyframes cd-shimmer{from{background-position:200% 0}to{background-position:-200% 0}} +.setup-root:not(.settled) *,.setup-root:not(.settled) *::before,.setup-root:not(.settled) *::after{transition:none!important} +@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition-duration:0s!important;transition-delay:0s!important}} +@media (max-width:1080px){ + .r-row{grid-template-columns:minmax(0,1fr) minmax(0,1.2fr);gap:10px 20px} + .r-head{display:none} + .r-state{flex-direction:row;align-items:center;flex-wrap:wrap;gap:10px} + .bottom{grid-template-columns:minmax(0,1fr);gap:0} +} +@media (max-width:640px){ + .topbar{height:auto;min-height:52px;flex-wrap:wrap;gap:8px 12px;padding:9px 16px} + .topbar-title{padding-left:10px;font-size:12px}.topbar nav{gap:12px} + .setup-main{padding:20px 16px 0} + .r-row{grid-template-columns:minmax(0,1fr);padding-inline:4px} + .pf,#sandbox-row{flex-direction:column;align-items:flex-start} + .seg-ctl{width:100%} + .savebar{margin-inline:-16px;padding:10px 16px} + .sb-list{display:none} +} `; export const SETUP_PAGE = renderSetupPage(); diff --git a/tests/setup-page.test.ts b/tests/setup-page.test.ts index 9110d68..c8b288d 100644 --- a/tests/setup-page.test.ts +++ b/tests/setup-page.test.ts @@ -1,19 +1,25 @@ import { runInNewContext } from "node:vm"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { REASONING_EFFORTS } from "../src/core/driver.js"; import { ROLES, type Role } from "../src/core/roles.js"; +import { AGENT_IDS } from "../src/core/session.js"; +import type { BatchModelsResult } from "../src/core/models.js"; import { ORCHESTRATOR_PRESETS } from "../src/config/orchestrator-mode.js"; import type { RoleBinding } from "../src/config/config.js"; import { buildSetupSelection, createSetupPageController, SETUP_PAGE, + SETUP_POLICY_AXES, + SETUP_ROLE_ORDER, SETUP_SESSION_EXPIRED_MESSAGE, type SetupPageDocument, type SetupPageElement, + type SetupPageFetchInit, type SetupPageFormValues, type SetupPageResponse, type SetupPageControllerOptions, + type SetupPageTargetState, } from "../src/web/setup-page.js"; function form(overrides: Partial = {}): SetupPageFormValues { @@ -42,436 +48,482 @@ function response(payload: unknown, status = 200): SetupPageResponse { return { status, ok: status >= 200 && status < 300, json: async () => payload }; } +type FakeElement = SetupPageElement & { attributes: Map }; + function fakeDocument(): { document: SetupPageDocument; - elements: Map; - innerHtmlWrites: Map; - dispatch(id: string, event: string): void; + elements: Map; + el(id: string): FakeElement; } { - 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) { - 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; - }, + const elements = new Map(); + const document: SetupPageDocument = { + getElementById(id) { + let element = elements.get(id); + if (!element) { + const attributes = new Map(); + element = { + attributes, + value: "", + checked: false, + disabled: false, + hidden: false, + textContent: "", + innerHTML: "", + className: "", + style: {}, + setAttribute(name, value) { + attributes.set(name, value); + }, + }; + elements.set(id, element); + } + return element; }, }; + return { document, elements, el: (id) => document.getElementById(id) as FakeElement }; } -function makeController( - document: SetupPageDocument, - fetcher: SetupPageControllerOptions["fetcher"], - overrides: Partial = {}, -) { - return createSetupPageController({ +const CATALOG: BatchModelsResult = { + models: [ + { + agent: "claude", + available: true, + providers: [{ + provider: "anthropic", + models: [ + { id: "claude-sonnet-5", name: "Claude Sonnet 5", provider: "anthropic", reasoningEfforts: ["low", "medium", "high"] }, + { id: "claude-opus-5-5", name: "Claude Opus 5.5", provider: "anthropic" }, + ], + }], + }, + { + agent: "codex", + available: true, + providers: [{ provider: "openai", models: [{ id: "gpt-known", name: "GPT Known", provider: "openai" }] }], + }, + { agent: "opencode", available: true, providers: [{ provider: "x", models: [{ id: "qwen", name: "Qwen", provider: "x" }] }] }, + { agent: "omp", available: false, error: "not installed", providers: [] }, + ], + status: "fresh", + source: "cache", + ageMs: 5 * 60_000, + cacheWriteFailed: false, +}; + +const STATE: SetupPageTargetState = { + config: { status: "ok", source: "global", path: "/home/me/.config/run-agent/config.json" }, + target: { kind: "global" }, + bindings: { + orchestrator: { harness: "opencode", model: "qwen" }, + reviewer: { harness: "claude", model: "claude-sonnet-5", effort: "high" }, + }, + efforts: { reviewer: "high" }, + orchestrator: { ...ORCHESTRATOR_PRESETS.balanced }, + sandbox: "workspace-write", + autocompact: { enabled: false }, +}; + +type Handler = (path: string, init?: SetupPageFetchInit) => SetupPageResponse | Promise | undefined; + +function makeController(overrides: Partial = {}, handler: Handler = () => undefined, state = STATE) { + const fake = fakeDocument(); + const posts: Array<{ path: string; body: Record }> = []; + const fetcher: SetupPageControllerOptions["fetcher"] = async (path, init) => { + if (init?.method === "POST") posts.push({ path, body: JSON.parse(init.body ?? "{}") }); + const handled = await handler(path, init); + if (handled) return handled; + if (path === "/api/setup/state") return response(state); + if (path === "/api/setup/catalog") return response(CATALOG); + return response({ error: `unexpected ${path}` }, 500); + }; + const controller = createSetupPageController({ fetcher, - buildSelection: (values, bindings) => buildSetupSelection(values, bindings, { - roles: ROLES, - efforts: REASONING_EFFORTS, - presets: ORCHESTRATOR_PRESETS, - }), - roles: ROLES, + buildSelection: (values, bindings) => build(values, bindings), + roles: SETUP_ROLE_ORDER, presets: ORCHESTRATOR_PRESETS, + efforts: REASONING_EFFORTS, + harnesses: AGENT_IDS, + axes: SETUP_POLICY_AXES, expiredMessage: SETUP_SESSION_EXPIRED_MESSAGE, - document, + document: fake.document, ...overrides, }); + return { controller, posts, ...fake }; } -describe("setup page selection", () => { - it("offers every role, free-text binding fields, effort choices, and all setup fields", () => { +function dryRunEnvelope(overrides: Record = {}) { + return { + proposta: { agents: { ...STATE.bindings, reviewer: { harness: "claude", model: "claude-sonnet-5", effort: "low" } }, orchestrator: STATE.orchestrator, defaultSandbox: "workspace-write", autocompact: { enabled: false } }, + validacoes: { + config: { status: "ok", message: null }, + catalogo: { status: "fresh", message: null }, + bindings: [{ role: "reviewer", harness: "claude", model: "claude-sonnet-5", status: "accepted", message: "" }], + }, + mudancas: [{ path: "/agents/reviewer/effort", beforePresent: true, before: "high", afterPresent: true, after: "low" }], + resultado: { status: "dry-run", code: 0, saved: false, message: "Dry run only, no changes were written." }, + ...overrides, + }; +} + +describe("setup page markup", () => { + it("renders the roster, the policy comparison, runtime controls, and the review drawer", () => { + const order = SETUP_ROLE_ORDER.map((role) => SETUP_PAGE.indexOf(`id="role-row-${role}"`)); + expect(order.every((index) => index > 0)).toBe(true); + expect(order).toEqual([...order].sort((a, b) => a - b)); + expect(SETUP_ROLE_ORDER[0]).toBe("orchestrator"); for (const role of ROLES) { - expect(SETUP_PAGE).toContain(`id="binding-${role}" type="text"`); - expect(SETUP_PAGE).toContain(`id="effort-${role}"`); - expect(SETUP_PAGE).toContain(`id="skip-${role}" type="checkbox"`); - for (const effort of REASONING_EFFORTS) expect(SETUP_PAGE).toContain(``); + expect(SETUP_PAGE).toContain(`id="binding-${role}" class="model-trigger"`); + for (const effort of REASONING_EFFORTS) expect(SETUP_PAGE).toContain(`id="effort-${role}-${effort}"`); } - expect(SETUP_PAGE).toContain('option value="dispatcher"'); - expect(SETUP_PAGE).toContain('option value="balanced"'); - expect(SETUP_PAGE).toContain('option value="explorer"'); - expect(SETUP_PAGE).toContain('id="orchestrator-investigate"'); - expect(SETUP_PAGE).toContain('id="orchestrator-self-work"'); - expect(SETUP_PAGE).toContain('id="orchestrator-tools"'); - expect(SETUP_PAGE).toContain("danger-full-access"); - expect(SETUP_PAGE).toContain('value="workspace-write"'); - expect(SETUP_PAGE).toContain('value="on"'); - expect(SETUP_PAGE).toContain('value="off"'); - expect(SETUP_PAGE).toContain('value="custom"'); - expect(SETUP_PAGE).toContain('id="orchestrator-parallelism" type="number"'); + for (const name of ["dispatcher", "balanced", "explorer", "custom"]) { + expect(SETUP_PAGE).toContain(`id="policy-col-${name}"`); + } + for (const axis of SETUP_POLICY_AXES) { + for (const option of axis.options) { + expect(SETUP_PAGE).toContain(`id="policy-pick-${axis.key}-${option.value}"`); + expect(SETUP_PAGE).toContain(``); + } + } + for (const harness of AGENT_IDS) expect(SETUP_PAGE).toContain(`id="hi-${harness}"`); + expect(SETUP_PAGE).toContain('id="sandbox-workspace-write"'); + expect(SETUP_PAGE).toContain('id="sandbox-danger-full-access"'); + expect(SETUP_PAGE).toContain('id="setup-autocompact" class="switch unset" type="button" role="switch"'); + expect(SETUP_PAGE).toContain('id="orchestrator-parallelism" class="num" type="number"'); + expect(SETUP_PAGE).toContain('id="setup-drawer" class="drawer" role="dialog"'); + expect(SETUP_PAGE).toContain('id="setup-apply" class="btn primary" type="button" data-act="apply" disabled>Save changes'); + expect(SETUP_PAGE).toContain('id="setup-review" class="btn primary" type="button" data-act="review" disabled>Review and save'); + expect(SETUP_PAGE).toContain("prefers-reduced-motion"); + expect(SETUP_PAGE).not.toContain("Custom mix"); + expect(SETUP_PAGE).not.toContain("confirm("); + }); + + it("keeps the standalone topbar pointing at setup only", () => { 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 }));
+describe("setup page controller", () => {
+  it("renders the saved bindings, policy, and runtime settings after start", async () => {
+    const { controller, el } = makeController();
     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("{");
+    expect(el("setup-config-path").textContent).toBe(STATE.config!.path);
+    expect(el("catalog-status").textContent).toBe("Catalog updated 5 min ago");
+    expect(el("binding-reviewer").innerHTML).toContain('data-h="claude"');
+    expect(el("binding-reviewer").innerHTML).toContain("claude-sonnet-5");
+    expect(el("binding-general").innerHTML).toContain("Choose a model");
+    expect(el("role-state-reviewer").innerHTML).toContain("Saved");
+    expect(el("role-state-general").innerHTML).toContain("Not set");
+    expect(el("effort-reviewer").className).toBe("meter lvl-3");
+    expect(el("effort-label-reviewer").textContent).toBe("high");
+    expect(el("effort-orchestrator").className).toContain("none");
+    expect(el("effort-note-orchestrator").textContent).toBe("opencode sets its own effort");
+    expect(el("effort-general").className).toContain("unbound");
+    expect(el("effort-reviewer-xhigh").disabled).toBe(true);
+    expect(el("effort-reviewer-medium").disabled).toBe(false);
+
+    expect(el("policy-table").className).toBe("cmp col-1");
+    expect(el("policy-col-balanced").attributes.get("aria-pressed")).toBe("true");
+    expect(el("policy-caption").textContent).toBe("Balanced: reads code to plan, fixes trivial things itself, and can edit files.");
+    expect(el("orchestrator-parallelism").disabled).toBe(false);
+    expect(el("sandbox-control").className).toBe("seg-ctl at-0");
+    expect(el("sandbox-copy").textContent).toBe("Codex workers write only inside their workspace, with no network.");
+    expect(el("setup-autocompact").className).toBe("switch");
+    expect(el("setup-autocompact").attributes.get("aria-checked")).toBe("false");
+    expect(el("setup-action-status").textContent).toBe("All changes saved");
+    expect(el("setup-review").disabled).toBe(true);
   });
 
-  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" });
-    }
-
+  it("marks an unset policy, sandbox, and autocompact without inventing values", async () => {
+    const { controller, el } = makeController({}, () => undefined, {
+      target: { kind: "global" },
+      bindings: {},
+      efforts: {},
+    });
     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" } } });
+    expect(el("policy-table").className).toBe("cmp col-none");
+    expect(el("policy-caption").textContent).toBe("Not set. The orchestrator runs as Dispatcher until you choose.");
+    expect(el("orchestrator-parallelism").disabled).toBe(true);
+    expect(el("orchestrator-parallelism").attributes.get("placeholder")).toBe("Not set");
+    expect(el("sandbox-control").className).toBe("seg-ctl at-none");
+    expect(el("sandbox-copy").textContent).toBe("Not set. Codex runs with workspace write.");
+    expect(el("setup-autocompact").className).toBe("switch unset");
+    expect(el("autocompact-copy").textContent).toBe("Not set, so it stays off. CodeDeck leaves compaction to each harness.");
+    expect(controller.readForm()).toMatchObject({ orchestrator: "skip", sandbox: "skip", autocompact: "skip" });
+    expect(controller.buildSelection()).toEqual({ agents: {} });
   });
 
-  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 }));
-
+  it("tracks role edits in the draft and sends only changed roles", async () => {
+    const { controller, el } = makeController();
     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" }],
+    controller.setEffort("reviewer", "low");
+    expect(controller.changeNames()).toEqual(["Reviewer"]);
+    expect(el("effort-reviewer").className).toBe("meter lvl-1 changed");
+    expect(el("role-state-reviewer").innerHTML).toContain("was high effort");
+    expect(el("setup-action-status").textContent).toBe("1 unsaved change");
+    expect(el("setup-change-list").textContent).toBe("Reviewer");
+    expect(el("setup-review").disabled).toBe(false);
+
+    controller.selectBinding("general", "codex", "gpt-known");
+    const values = controller.readForm();
+    expect(values.roles.orchestrator?.skip).toBe(true);
+    expect(values.roles.reviewer).toEqual({ skip: false, binding: "claude:claude-sonnet-5", effort: "low" });
+    expect(values.roles.general).toEqual({ skip: false, binding: "codex:gpt-known", effort: "keep" });
+    expect(controller.buildSelection()).toEqual({
+      agents: {
+        reviewer: { harness: "claude", model: "claude-sonnet-5", effort: "low" },
+        general: { harness: "codex", model: "gpt-known" },
       },
-      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("
"); + controller.revertRole("general"); + expect(controller.changeNames()).toEqual(["Reviewer"]); + controller.discard(); + expect(controller.changeNames()).toEqual([]); + expect(el("setup-action-status").textContent).toBe("All changes saved"); }); - 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++]!)); - + it("clamps the carried effort to what the new model supports and restores the saved effort", async () => { + const { controller } = makeController({}, () => undefined, { + ...STATE, + bindings: { ...STATE.bindings, general: { harness: "claude", model: "claude-opus-5-5", effort: "max" } }, + efforts: { ...STATE.efforts, general: "max" }, + }); 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); + controller.selectBinding("general", "claude", "claude-sonnet-5"); + expect(controller.draft.agents.general?.effort).toBe("high"); + controller.selectBinding("general", "claude", "claude-opus-5-5"); + expect(controller.draft.agents.general?.effort).toBe("max"); + expect(controller.changeNames()).toEqual([]); }); - 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)); - + it("maps presets, custom picks, and parallelism to the orchestrator selection", async () => { + const { controller, el } = makeController(); 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."); + controller.choosePreset("explorer"); + expect(el("policy-table").className).toBe("cmp col-2"); + expect(controller.readForm().orchestrator).toBe("explorer"); + expect(controller.buildSelection().orchestrator).toEqual({ ...ORCHESTRATOR_PRESETS.explorer }); + + controller.setPolicyValue("tools", "read"); + expect(el("policy-table").className).toBe("cmp col-3"); + expect(el("policy-pick-tools").className).toBe("cmp-picks at-1"); + expect(el("policy-custom-label-tools").textContent).toBe("Read"); + expect(el("policy-fp-line").attributes.get("d")).toBe("M15 3 L15 9 L9 15"); + expect(controller.buildSelection().orchestrator).toEqual({ investigate: "free", selfWork: "small", tools: "read" }); + + controller.choosePreset("balanced"); + expect(controller.changeNames()).toEqual([]); + controller.setParallelism("3"); + expect(controller.changeNames()).toEqual(["Parallel workers"]); + expect(controller.readForm().orchestrator).toBe("custom"); + expect(controller.buildSelection().orchestrator).toEqual({ ...ORCHESTRATOR_PRESETS.balanced, parallelism: 3 }); + + controller.chooseCustom(); + expect(el("policy-table").className).toBe("cmp col-3"); + expect(el("policy-col-custom").attributes.get("aria-pressed")).toBe("true"); }); - 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; - }); - }); + it("sends sandbox and autocompact only when they change", async () => { + const { controller, el } = makeController(); + await controller.start(); - const starting = controller.start(); - expect(elements.get("setup-dry-run")?.disabled).toBe(true); - expect(elements.get("setup-apply")?.disabled).toBe(true); + controller.setSandbox("workspace-write"); + controller.setAutocompact(false); + expect(controller.readForm()).toMatchObject({ sandbox: "skip", autocompact: "skip" }); + + controller.setSandbox("danger-full-access"); + controller.setAutocompact(); + expect(el("sandbox-control").className).toBe("seg-ctl at-1"); + expect(el("sandbox-copy").className).toMatch(/^rt-copy warn/); + expect(el("setup-autocompact").attributes.get("aria-checked")).toBe("true"); + expect(controller.changeNames()).toEqual(["Sandbox", "Autocompact"]); + expect(controller.buildSelection()).toEqual({ agents: {}, sandbox: "danger-full-access", autocompact: { enabled: 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("filters the picker, offers a typed off-catalog entry, and picks with the keyboard", async () => { + const { controller, el } = makeController(); + await controller.start(); + + controller.openPicker("general"); + expect(el("setup-picker").className).toBe("picker open"); + expect(el("setup-picker-list").innerHTML).toContain("Unavailable: not installed"); + + controller.setPickerQuery("opus"); + expect(el("setup-picker-list").innerHTML).toContain('data-model="claude-opus-5-5"'); + expect(el("setup-picker-list").innerHTML).not.toContain('data-model="claude-sonnet-5"'); + + controller.setPickerQuery("codex:my-local"); + const list = el("setup-picker-list").innerHTML; + expect(list).toContain("pk-custom"); + expect(list).toContain("codex:my-local<model>"); + expect(list).not.toContain(""); + + controller.setPickerQuery("sonnet"); + controller.handleKey({ key: "Enter" }); + expect(el("setup-picker").className).toBe("picker"); + expect(controller.draft.agents.general).toEqual({ harness: "claude", model: "claude-sonnet-5", effort: "" }); + + controller.openPicker("auditor"); + controller.handleKey({ key: "Escape" }); + expect(el("setup-picker").className).toBe("picker"); + expect(controller.draft.agents.auditor).toBeUndefined(); }); - it("preserves skipped bindings and unchanged effort, and leaves an omitted orchestrator untouched", () => { - const values = form({ - roles: { - general: { skip: true, binding: "", effort: "keep" }, - reviewer: { skip: false, binding: "codex:typed-model", effort: "keep" }, - auditor: { skip: false, binding: "opencode:code-model", effort: "high" }, + it("reviews the change in the drawer with a truthful diff, checks, and escaped messages", async () => { + const envelope = dryRunEnvelope({ + proposta: { ...dryRunEnvelope().proposta, activeProfile: "legacy", profiles: { legacy: { secret: "legacy-config" } } }, + validacoes: { + config: { status: "ok", message: null }, + catalogo: { status: "fresh", message: "" }, + bindings: [{ role: "reviewer", harness: "claude", model: "claude-sonnet-5", status: "accepted", message: "" }], }, }); - const selection = build(values, { - general: { harness: "claude", model: "legacy", effort: "medium" }, - reviewer: { harness: "codex", model: "typed-model", effort: "high" }, - auditor: { harness: "opencode", model: "code-model", effort: "low" }, - }); + const { controller, el, posts } = makeController({}, (path) => path === "/api/setup/dry-run" ? response(envelope) : undefined); + await controller.start(); + controller.setEffort("reviewer", "low"); + + await controller.openReview(); + + expect(posts).toEqual([{ path: "/api/setup/dry-run", body: { agents: { reviewer: { harness: "claude", model: "claude-sonnet-5", effort: "low" } } } }]); + expect(el("setup-drawer").className).toBe("drawer open"); + const markup = el("setup-result").innerHTML; + const visible = markup.split('
')[0]!; + expect(visible).toContain("Not saved yet"); + expect(visible).toContain("Reviewer effort"); + expect(visible).toContain('
'); + expect(visible).toContain('
'); + expect(visible).toContain("config.json"); + expect(visible).toContain("<img src=x onerror=alert(1)>"); + expect(visible).not.toContain("'); + expect(el("setup-apply").disabled).toBe(false); + }); - expect(selection.agents).toEqual({ - reviewer: { harness: "codex", model: "typed-model", effort: "high" }, - auditor: { harness: "opencode", model: "code-model", effort: "low" }, + it("gates saving an off-catalog model on the drawer confirmation, then reloads the saved state", async () => { + let saved = false; + const savedState: SetupPageTargetState = { + ...STATE, + bindings: { ...STATE.bindings, general: { harness: "codex", model: "my-local-model" } }, + }; + const { controller, el, posts } = makeController({}, (path) => { + if (path === "/api/setup/state") return response(saved ? savedState : STATE); + if (path === "/api/setup/dry-run") { + return response(dryRunEnvelope({ + validacoes: { bindings: [{ role: "general", harness: "codex", model: "my-local-model", status: "unknown-model", message: "Not in the codex catalog." }] }, + resultado: { status: "error", code: 22, saved: false, message: "Unknown model." }, + }), 422); + } + if (path === "/api/setup/apply") { + saved = true; + return response({ mudancas: [], resultado: { status: "applied", code: 0, saved: true, message: "Configuration saved." } }); + } }); - expect(Object.hasOwn(selection, "orchestrator")).toBe(false); + await controller.start(); + controller.selectBinding("general", "codex", "my-local-model"); + expect(el("binding-general").innerHTML).toContain("not in catalog"); + + await controller.openReview(); + expect(controller.state.error).toBeUndefined(); + expect(el("setup-confirmations").innerHTML).toContain('data-act="confirm-off" data-role="general"'); + expect(el("setup-result").innerHTML).toContain("Not saved yet"); + expect(el("setup-apply").disabled).toBe(true); + + await controller.apply(); + expect(controller.state.error).toBe("Confirm the off-catalog model for general before saving."); + expect(posts.map((post) => post.path)).toEqual(["/api/setup/dry-run"]); + + controller.confirmOffCatalog("general", true); + expect(el("setup-apply").disabled).toBe(false); + await controller.apply(); + + expect(posts[1]).toEqual({ + path: "/api/setup/apply", + body: { agents: { general: { harness: "codex", model: "my-local-model" } }, offCatalogConfirmed: { general: true } }, + }); + expect(el("setup-drawer").className).toBe("drawer"); + expect(el("setup-toast").className).toBe("toast show"); + expect(el("setup-toast").innerHTML).toContain(`Saved to ${STATE.config!.path}`); + expect(controller.changeNames()).toEqual([]); + expect(el("role-state-general").innerHTML).toContain("Saved"); }); - it("keeps the empty agents sentinel when every first-run role is skipped", () => { - expect(build(form(), {})).toEqual({ agents: {} }); - }); + it("keeps Save disabled when the dry run fails for a reason the user cannot confirm", async () => { + const { controller, el } = makeController({}, (path) => path === "/api/setup/dry-run" + ? response(dryRunEnvelope({ + validacoes: { bindings: [{ role: "general", harness: "omp", model: "x", status: "harness-unavailable", message: "omp is not installed." }] }, + resultado: { status: "error", code: 22, saved: false, message: "Harness unavailable." }, + }), 422) + : undefined); + await controller.start(); + controller.selectBinding("general", "omp", "x"); - it("stores custom parallelism as a positive finite number and maps the other controls", () => { - const selection = build(form({ - orchestrator: "custom", - investigate: "free", - selfWork: "small", - tools: "edit", - parallelism: "3.5", - sandbox: "danger-full-access", - autocompact: "off", - }), {}); + await controller.openReview(); - expect(selection.orchestrator).toEqual({ investigate: "free", selfWork: "small", tools: "edit", parallelism: 3.5 }); - expect(selection.sandbox).toBe("danger-full-access"); - expect(selection.autocompact).toEqual({ enabled: false }); + expect(controller.state.error).toBe("Harness unavailable."); + expect(el("setup-result").innerHTML).toContain("Harness unavailable."); + expect(el("setup-apply").disabled).toBe(true); }); - it("does not expose an effort control for an opencode binding", async () => { - const { document, elements } = fakeDocument(); - const controller = createSetupPageController({ - fetcher: async (path) => path === "/api/setup/state" - ? response({ target: { kind: "global" }, bindings: { general: { harness: "opencode", model: "code-model" } } }) - : response({ models: [], status: "fresh", source: "cache", ageMs: 0, cacheWriteFailed: false }), - buildSelection: (values, bindings) => buildSetupSelection(values, bindings, { - roles: ROLES, - efforts: REASONING_EFFORTS, - presets: ORCHESTRATOR_PRESETS, - }), - roles: ROLES, - expiredMessage: SETUP_SESSION_EXPIRED_MESSAGE, - document, + it("disables review and save until state loads and while a request runs", async () => { + let finishState: ((value: SetupPageResponse) => void) | undefined; + let finishDryRun: ((value: SetupPageResponse) => void) | undefined; + const { controller, el } = makeController({}, (path) => { + if (path === "/api/setup/state") return new Promise((resolve) => { finishState = resolve; }); + if (path === "/api/setup/dry-run") return new Promise((resolve) => { finishDryRun = resolve; }); }); - await controller.start(); - - expect(elements.get("effort-general")?.hidden).toBe(true); - expect(elements.get("effort-general")?.disabled).toBe(true); + const starting = controller.start(); + expect(el("setup-action-status").textContent).toBe("Loading setup"); + expect(el("binding-reviewer").disabled).toBe(true); + finishState?.(response(STATE)); + await starting; + expect(el("binding-reviewer").disabled).toBe(false); + + controller.setEffort("reviewer", "low"); + const review = controller.openReview(); + expect(el("setup-action-status").textContent).toBe("Checking changes"); + expect(el("setup-review").disabled).toBe(true); + expect(el("setup-discard").disabled).toBe(true); + expect(el("setup-apply").disabled).toBe(true); + expect(el("setup-result").innerHTML).toContain("Checking your changes"); + finishDryRun?.(response(dryRunEnvelope())); + await review; + expect(el("setup-apply").disabled).toBe(false); + expect(el("setup-review").disabled).toBe(false); }); it("shows discovery while refreshing and retains the previous catalog when discovery is unavailable", async () => { - const { document, elements } = fakeDocument(); let finishRefresh: ((value: SetupPageResponse) => void) | undefined; const requests: Array<[string, string]> = []; - const previousCatalog = { - models: [{ agent: "codex", available: true, providers: [{ provider: "openai", models: [] }] }], - status: "fresh" as const, - source: "cache" as const, - ageMs: 50, - cacheWriteFailed: false, - }; + const { document, el } = fakeDocument(); const controller = createSetupPageController({ fetcher: async (path, init) => { requests.push([path, init?.method ?? "GET"]); return new Promise((resolve) => { finishRefresh = resolve; }); }, - buildSelection: (values, bindings) => buildSetupSelection(values, bindings, { - roles: ROLES, - efforts: REASONING_EFFORTS, - presets: ORCHESTRATOR_PRESETS, - }), - roles: ROLES, + buildSelection: (values, bindings) => build(values, bindings), + roles: SETUP_ROLE_ORDER, expiredMessage: SETUP_SESSION_EXPIRED_MESSAGE, document, }); - controller.state.catalog = previousCatalog; + controller.state.catalog = CATALOG; const pending = controller.refreshCatalog(); expect(controller.state.refreshing).toBe(true); - expect(elements.get("setup-status")?.textContent).toBe("Discovering models..."); + expect(el("catalog-status").textContent).toBe("Refreshing catalog"); + expect(el("setup-refresh").disabled).toBe(true); finishRefresh?.(response({ models: [], status: "unavailable", @@ -483,105 +535,68 @@ describe("setup page selection", () => { await pending; expect(requests).toEqual([["/api/setup/catalog/refresh", "POST"]]); - expect(controller.state.catalog).toBe(previousCatalog); + expect(controller.state.catalog).toBe(CATALOG); expect(controller.state.discoveryError).toBe("network discovery failed"); expect(controller.state.refreshing).toBe(false); + expect(el("catalog-status").className).toMatch(/^catalog-text warn/); }); - it("asks per role before sending an off-catalog changed model in apply", async () => { - const { document } = fakeDocument(); - const posted: unknown[] = []; - const confirm = vi.fn(() => true); - const controller = createSetupPageController({ - fetcher: async (_path, init) => { - if (init?.method === "POST") posted.push(JSON.parse(init.body ?? "{}")); - return response({ resultado: { status: "applied", saved: true } }); - }, - buildSelection: (values, bindings) => buildSetupSelection(values, bindings, { - roles: ROLES, - efforts: REASONING_EFFORTS, - presets: ORCHESTRATOR_PRESETS, - }), - roles: ROLES, - expiredMessage: SETUP_SESSION_EXPIRED_MESSAGE, - document, - confirm, - }); - controller.state.target = { target: { kind: "global" }, bindings: {}, efforts: {} }; - controller.state.catalog = { - models: [{ - agent: "codex", - available: true, - providers: [{ provider: "openai", models: [{ id: "known", name: "Known", provider: "openai" }] }], - }], - status: "fresh", - source: "cache", - ageMs: 0, - cacheWriteFailed: false, - }; + it("shows the exact reload and restart message after a protected action returns 403", async () => { + const { controller } = makeController({}, (_path, init) => init?.method === "POST" ? response({ error: "forbidden" }, 403) : undefined); + await controller.start(); - await controller.apply(form({ - roles: { ...form().roles, reviewer: { skip: false, binding: "codex:typed-model", effort: "keep" } }, - })); + await controller.apply(form()); - expect(confirm).toHaveBeenCalledWith('Model "typed-model" is not in the codex catalog for reviewer. Apply it anyway?'); - expect((posted[0] as { offCatalogConfirmed: unknown }).offCatalogConfirmed).toEqual({ reviewer: true }); + expect(controller.state.error).toBe( + "This CodeDeck session has expired. Reload the page. If it still fails, restart the command and open its new URL.", + ); }); +}); - it("does not send an off-catalog apply when the per-role confirmation is declined", async () => { - const { document } = fakeDocument(); - const fetcher = vi.fn(async () => response({ resultado: { status: "applied", saved: true } })); - const controller = createSetupPageController({ - fetcher, - buildSelection: (values, bindings) => buildSetupSelection(values, bindings, { - roles: ROLES, - efforts: REASONING_EFFORTS, - presets: ORCHESTRATOR_PRESETS, - }), - roles: ROLES, - expiredMessage: SETUP_SESSION_EXPIRED_MESSAGE, - document, - confirm: () => false, +describe("setup page selection", () => { + it("preserves skipped bindings and unchanged effort, and leaves an omitted orchestrator untouched", () => { + const values = form({ + roles: { + general: { skip: true, binding: "", effort: "keep" }, + reviewer: { skip: false, binding: "codex:typed-model", effort: "keep" }, + auditor: { skip: false, binding: "opencode:code-model", effort: "high" }, + }, + }); + const selection = build(values, { + general: { harness: "claude", model: "legacy", effort: "medium" }, + reviewer: { harness: "codex", model: "typed-model", effort: "high" }, + auditor: { harness: "opencode", model: "code-model", effort: "low" }, }); - controller.state.target = { target: { kind: "global" }, bindings: {}, efforts: {} }; - controller.state.catalog = { - models: [{ agent: "codex", available: true, providers: [{ provider: "openai", models: [] }] }], - status: "fresh", - source: "cache", - ageMs: 0, - cacheWriteFailed: false, - }; - await controller.apply(form({ - roles: { ...form().roles, reviewer: { skip: false, binding: "codex:typed-model", effort: "keep" } }, - })); + expect(selection.agents).toEqual({ + reviewer: { harness: "codex", model: "typed-model", effort: "high" }, + auditor: { harness: "opencode", model: "code-model", effort: "low" }, + }); + expect(Object.hasOwn(selection, "orchestrator")).toBe(false); + }); - expect(fetcher).not.toHaveBeenCalled(); - expect(controller.state.error).toBe("Apply cancelled for the off-catalog model selected for reviewer."); + it("keeps the empty agents sentinel when every first-run role is skipped", () => { + expect(build(form(), {})).toEqual({ agents: {} }); }); - it("shows the exact reload and restart message after a protected action returns 403", async () => { - const { document } = fakeDocument(); - const controller = createSetupPageController({ - fetcher: async (_path, init) => init?.method === "POST" - ? response({ error: "forbidden" }, 403) - : response({}), - buildSelection: (values, bindings) => buildSetupSelection(values, bindings, { - roles: ROLES, - efforts: REASONING_EFFORTS, - presets: ORCHESTRATOR_PRESETS, - }), - roles: ROLES, - expiredMessage: SETUP_SESSION_EXPIRED_MESSAGE, - document, - }); - controller.state.target = { target: { kind: "global" }, bindings: {}, efforts: {} }; + it("stores custom parallelism as a positive finite number and maps the other controls", () => { + const selection = build(form({ + orchestrator: "custom", + investigate: "free", + selfWork: "small", + tools: "edit", + parallelism: "3.5", + sandbox: "danger-full-access", + autocompact: "off", + }), {}); - await controller.apply(form()); + expect(selection.orchestrator).toEqual({ investigate: "free", selfWork: "small", tools: "edit", parallelism: 3.5 }); + expect(selection.sandbox).toBe("danger-full-access"); + expect(selection.autocompact).toEqual({ enabled: false }); + }); - expect(controller.state.error).toBe( - "This CodeDeck session has expired. Reload the page. If it still fails, restart the command and open its new URL.", - ); + it("rejects a non-positive custom parallelism", () => { + expect(() => build(form({ orchestrator: "custom", parallelism: "0" }), {})).toThrow("parallelism must be a positive finite number."); }); }); @@ -606,12 +621,14 @@ describe("setup page inline behavior", () => { runInNewContext(script!, context); await (context as typeof context & { setupPageReady: Promise }).setupPageReady; const page = (context as typeof context & { setupPage: ReturnType }).setupPage; - const selected = page.buildSelection(form()); expect(calls).toEqual(["/api/setup/state", "/api/setup/catalog"]); - expect(selected.agents).toEqual({}); + expect(page.buildSelection(form()).agents).toEqual({}); expect(page.state.target?.target.kind).toBe("global"); + page.choosePreset("balanced"); + expect(page.buildSelection().orchestrator).toEqual({ ...ORCHESTRATOR_PRESETS.balanced }); + await page.refreshCatalog(); expect(calls).toEqual(["/api/setup/state", "/api/setup/catalog", "POST /api/setup/catalog/refresh"]); }); From 506ac699e1d6a02a66c5fedf168f2f02ce34730a Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:53:11 -0300 Subject: [PATCH 2/3] feat(web): Add setup loading skeleton and readable model names While setup state loads, the page showed "Loading" text in every model button and placeholder controls. Render shimmer skeletons shaped like the loaded content instead, and fade the real values in once they arrive. Show the catalog name of a model (for example "Claude Opus 5.5") as the main label in the role buttons, the picker, and the undo hint, with the model id the harness receives shown muted beside it. Co-Authored-By: Claude --- src/web/setup-page.ts | 52 +++++++++++++++++++++++++++++----------- tests/setup-page.test.ts | 14 ++++++++--- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/src/web/setup-page.ts b/src/web/setup-page.ts index d397171..6f46e86 100644 --- a/src/web/setup-page.ts +++ b/src/web/setup-page.ts @@ -359,7 +359,8 @@ export function createSetupPageController(options: SetupPageControllerOptions) { return; } target.textContent = text; - if (!ui.rendered) { + // The first ready render replaces skeleton text, so it fades in too. + if (state.loading || !state.target) { target.className = base; return; } @@ -494,6 +495,14 @@ export function createSetupPageController(options: SetupPageControllerOptions) { return listed && listed.length > 0 ? listed : undefined; } + // The catalog name reads better than the id, but the id is what the harness + // receives, so it stays visible next to the name. + function modelText(harness: string, model: string, titleClass: string, idClass: string): string { + const name = catalogModel(harness, model)?.name; + if (!name || name === model) return `${escapeHtml(model)}`; + return `${escapeHtml(name)}${escapeHtml(model)}`; + } + function isOffCatalog(harness: string, model: string): boolean { const entry = harnessEntry(harness); return Boolean(entry?.available) && !catalogModel(harness, model); @@ -791,11 +800,11 @@ export function createSetupPageController(options: SetupPageControllerOptions) { } function bindingHtml(role: Role): string { - if (state.loading || !state.target) return `Loading${icon("chevron-down", "chev")}`; + if (state.loading || !state.target) return `Loading${icon("chevron-down", "chev")}`; const current = draft.agents[role]; if (!current) return `Choose a model${icon("chevron-down", "chev")}`; const off = modelChanged(role, savedDraft()) && isOffCatalog(current.harness, current.model); - return `${chip(current.harness)}${escapeHtml(current.model)}${off ? 'not in catalog' : ""}${icon("chevron-down", "chev")}`; + return `${chip(current.harness)}${modelText(current.harness, current.model, "model-title", "model-id")}${off ? 'not in catalog' : ""}${icon("chevron-down", "chev")}`; } function roleStateHtml(role: Role, saved: SetupDraft): string { @@ -807,9 +816,9 @@ export function createSetupPageController(options: SetupPageControllerOptions) { const was = !previous ? "not set" : modelChanged(role, saved) - ? previous.model + ? catalogModel(previous.harness, previous.model)?.name || previous.model : `${previous.effort || "default"} effort`; - return `was ${escapeHtml(was)}`; + return `was ${escapeHtml(was)}`; } function pickerEntries() { @@ -817,7 +826,7 @@ export function createSetupPageController(options: SetupPageControllerOptions) { const query = ui.pickerQuery.trim().toLowerCase(); const current = role ? draft.agents[role] : undefined; const saved = role ? savedDraft().agents[role] : undefined; - const groups: Array<{ harness: string; note: string; off: boolean; items: Array<{ harness: string; model: string; name: string; saved: boolean; selected: boolean; custom: boolean }> }> = []; + const groups: Array<{ harness: string; note: string; off: boolean; items: Array<{ harness: string; model: string; saved: boolean; selected: boolean; custom: boolean }> }> = []; for (const harness of harnesses) { const entry = harnessEntry(harness); if (!entry || !entry.available) { @@ -845,7 +854,6 @@ export function createSetupPageController(options: SetupPageControllerOptions) { items: matching.map((model) => ({ harness, model: model.id, - name: model.name && model.name !== model.id ? model.name : "", saved: saved?.harness === harness && saved.model === model.id, selected: current?.harness === harness && current.model === model.id, custom: false, @@ -858,7 +866,7 @@ export function createSetupPageController(options: SetupPageControllerOptions) { harness: typed[1]!, note: "", off: false, - items: [{ harness: typed[1]!, model: typed[2]!.trim(), name: "", saved: false, selected: false, custom: true }], + items: [{ harness: typed[1]!, model: typed[2]!.trim(), saved: false, selected: false, custom: true }], }); } return { groups, items: groups.flatMap((group) => group.items) }; @@ -880,7 +888,7 @@ export function createSetupPageController(options: SetupPageControllerOptions) { const head = `
${chip(group.harness)}${escapeHtml(group.note)}
`; const options = group.items.map((item) => { const id = index++; - return ``; + return ``; }).join(""); return `
${head}${options}
`; }).join(""); @@ -1556,7 +1564,7 @@ function renderRoleRow(role: Role): string { ``).join(""); return `
${capitalize(role)}
${ROLE_COPY[role]}
-
+
${segments}
`; @@ -1739,7 +1747,12 @@ h1,h2,h3,p{margin:0}h1,h2{text-wrap:balance} .model-trigger[aria-expanded="true"]{border-color:var(--blue)} .model-trigger:disabled{opacity:.6} .model-body{display:flex;align-items:center;gap:10px;flex:1;min-width:0} -.model-name{flex:1;min-width:0;font-size:12px;overflow-wrap:anywhere} +.model-text{display:flex;align-items:baseline;gap:8px;flex:1;min-width:0} +.model-title{flex:none;max-width:100%;overflow:hidden;color:var(--text);font-size:12.5px;font-weight:500;text-overflow:ellipsis;white-space:nowrap} +.model-title.mono{flex:0 1 auto;font-size:12px;font-weight:400;white-space:normal;overflow-wrap:anywhere} +.model-id{flex:0 1 auto;min-width:0;overflow:hidden;color:var(--text-faint);font-size:11px;text-overflow:ellipsis;white-space:nowrap} +.model-sk{display:flex;align-items:center;gap:10px;flex:1;min-width:0} +.model-sk::before{content:"";flex:none;width:22px;height:22px;border-radius:6px}.model-sk::after{content:"";width:min(46%,220px);height:10px;border-radius:4px} .model-empty{flex:1;color:var(--text-faint);font-size:12px} .chev{width:14px;height:14px;color:var(--text-faint);transition:transform .2s var(--ease)}.model-trigger[aria-expanded="true"] .chev{transform:rotate(180deg)} .ready .model-body,.ready .r-state>*{animation:cd-in-a .26s var(--ease)} @@ -1758,7 +1771,7 @@ ${LEVEL_RULES} .meter.unbound .meter-note{margin-left:8px} .r-state{display:flex;flex-direction:column;align-items:flex-start;gap:2px;min-width:0;font-size:12px} .state-note{display:inline-flex;align-items:center;gap:5px;color:var(--text-faint)}.state-note .ico{width:13px;height:13px} -.was{color:var(--text-faint);overflow-wrap:anywhere}.was .mono{color:var(--text-muted);text-decoration:line-through;text-decoration-color:var(--text-faint)} +.was{color:var(--text-faint);overflow-wrap:anywhere}.was-value{color:var(--text-muted);text-decoration:line-through;text-decoration-color:var(--text-faint)} .link-btn{display:inline-flex;align-items:center;gap:4px;padding:2px 4px;margin-left:-4px;border:0;border-radius:4px;background:none;color:var(--text-muted);font-size:12px} .link-btn .ico{width:13px;height:13px}.link-btn:hover{color:var(--text)} .bottom{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(0,1fr);gap:48px} @@ -1876,8 +1889,9 @@ ${PICK_RULES} .pk-note{min-width:0;color:var(--text-faint);font-size:11px;text-align:right;overflow-wrap:anywhere} .pk-item{display:flex;align-items:center;gap:8px;width:100%;padding:7px 8px 7px 34px;border:0;border-radius:5px;background:none;color:var(--text-muted);font-size:12px;text-align:left} .pk-item:hover,.pk-item.kbd{background:var(--surface-raised);color:var(--text)} -.pk-model{min-width:0;overflow-wrap:anywhere}.pk-name{flex:1;min-width:64px;overflow:hidden;color:var(--text-faint);font-size:11px;text-align:right;text-overflow:ellipsis;white-space:nowrap} -.pk-item .tag{margin-left:auto}.pk-name+.tag{margin-left:0} +.pk-title{flex:none;max-width:62%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pk-title.mono{flex:0 1 auto;max-width:none;white-space:normal;overflow-wrap:anywhere} +.pk-id{flex:1;min-width:40px;overflow:hidden;color:var(--text-faint);font-size:11px;text-align:right;text-overflow:ellipsis;white-space:nowrap} +.pk-item .tag{margin-left:auto}.pk-id+.tag{margin-left:0} .pk-item.sel{color:var(--text)}.pk-check{width:14px;height:14px;color:var(--blue-text)} .pk-custom{justify-content:space-between;padding-left:8px} .pk-empty{margin:0;padding:12px;color:var(--text-faint);font-size:12px} @@ -1889,6 +1903,16 @@ ${PICK_RULES} @keyframes cd-in-b{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}} @keyframes cd-spin{to{transform:rotate(360deg)}} @keyframes cd-shimmer{from{background-position:200% 0}to{background-position:-200% 0}} +.loading .model-sk::before,.loading .model-sk::after,.loading .seg::before,.loading .r-state::before,.loading .rt-copy::before,.loading .rt-copy::after,.loading .switch,.loading .num,.loading #setup-config-path,.loading .catalog-text,.loading .policy-caption,.loading .cmp-custom-label,.loading .sb-status{color:transparent!important;background:linear-gradient(90deg,#141414 0%,#232323 50%,#141414 100%);background-size:200% 100%;animation:cd-shimmer 1.4s linear infinite;user-select:none} +.loading #setup-config-path,.loading .catalog-text,.loading .policy-caption,.loading .cmp-custom-label,.loading .sb-status{border-radius:4px} +.loading .policy-caption{width:min(100%,420px)} +.loading .seg:disabled::before,.loading .switch:disabled,.loading .num:disabled{opacity:1} +.loading .r-state::before{content:"";width:52px;height:10px;border-radius:4px} +.loading .rt-copy::before,.loading .rt-copy::after{content:"";display:block;width:min(100%,300px);height:10px;margin-top:5px;border-radius:4px}.loading .rt-copy::after{width:min(70%,190px)} +.loading .switch,.loading .num{border-color:transparent}.loading .switch::after{opacity:0}.loading .num::placeholder{color:transparent} +.loading .cmp-hl,.loading .pick-thumb{opacity:0}.loading .cmp-picks .cmp-pick{color:var(--border-strong)!important}.loading .cmp-custom-label{min-width:60px} +.ready .meter-segs,.ready .switch,.ready .num,.ready .seg-ctl,.ready .cmp-picks,.ready .cmp-hl{animation:cd-fade .3s var(--ease)} +@keyframes cd-fade{from{opacity:0}to{opacity:1}} .setup-root:not(.settled) *,.setup-root:not(.settled) *::before,.setup-root:not(.settled) *::after{transition:none!important} @media (prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition-duration:0s!important;transition-delay:0s!important}} @media (max-width:1080px){ diff --git a/tests/setup-page.test.ts b/tests/setup-page.test.ts index c8b288d..506ac55 100644 --- a/tests/setup-page.test.ts +++ b/tests/setup-page.test.ts @@ -214,7 +214,8 @@ describe("setup page controller", () => { expect(el("setup-config-path").textContent).toBe(STATE.config!.path); expect(el("catalog-status").textContent).toBe("Catalog updated 5 min ago"); expect(el("binding-reviewer").innerHTML).toContain('data-h="claude"'); - expect(el("binding-reviewer").innerHTML).toContain("claude-sonnet-5"); + expect(el("binding-reviewer").innerHTML).toContain('Claude Sonnet 5claude-sonnet-5'); + expect(el("binding-orchestrator").innerHTML).toContain('Qwenqwen'); expect(el("binding-general").innerHTML).toContain("Choose a model"); expect(el("role-state-reviewer").innerHTML).toContain("Saved"); expect(el("role-state-general").innerHTML).toContain("Not set"); @@ -265,7 +266,7 @@ describe("setup page controller", () => { controller.setEffort("reviewer", "low"); expect(controller.changeNames()).toEqual(["Reviewer"]); expect(el("effort-reviewer").className).toBe("meter lvl-1 changed"); - expect(el("role-state-reviewer").innerHTML).toContain("was high effort"); + expect(el("role-state-reviewer").innerHTML).toContain("was high effort"); expect(el("setup-action-status").textContent).toBe("1 unsaved change"); expect(el("setup-change-list").textContent).toBe("Reviewer"); expect(el("setup-review").disabled).toBe(false); @@ -282,6 +283,11 @@ describe("setup page controller", () => { }, }); + controller.selectBinding("reviewer", "claude", "claude-opus-5-5"); + expect(el("role-state-reviewer").innerHTML).toContain('was Claude Sonnet 5'); + controller.selectBinding("reviewer", "claude", "claude-sonnet-5"); + controller.setEffort("reviewer", "low"); + controller.revertRole("general"); expect(controller.changeNames()).toEqual(["Reviewer"]); controller.discard(); @@ -358,7 +364,7 @@ describe("setup page controller", () => { expect(el("setup-picker-list").innerHTML).toContain("Unavailable: not installed"); controller.setPickerQuery("opus"); - expect(el("setup-picker-list").innerHTML).toContain('data-model="claude-opus-5-5"'); + expect(el("setup-picker-list").innerHTML).toContain('data-model="claude-opus-5-5">Claude Opus 5.5claude-opus-5-5'); expect(el("setup-picker-list").innerHTML).not.toContain('data-model="claude-sonnet-5"'); controller.setPickerQuery("codex:my-local"); @@ -487,6 +493,8 @@ describe("setup page controller", () => { const starting = controller.start(); expect(el("setup-action-status").textContent).toBe("Loading setup"); expect(el("binding-reviewer").disabled).toBe(true); + expect(el("binding-reviewer").innerHTML).toContain(''); + expect(el("setup-root").className).toBe("setup-root loading"); finishState?.(response(STATE)); await starting; expect(el("binding-reviewer").disabled).toBe(false); From c3eceabf340a3358cf95c2052afb2fde7e1fba4d Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:00:26 -0300 Subject: [PATCH 3/3] fix(web): Center the catalog spinner and show refresh progress The refresh icon spun around a lopsided arrow, so it looked off axis. Swap it for a symmetric arc while the catalog refreshes, and show a progress bar under the status that sweeps across the discovery timeout. A failed refresh printed the raw discovery error after the catalog age. Say what happened first, for example "Refresh timed out after 12 s. Showing models from 1 h ago." Also compare keys with localeCompare in the review diff, as Sonar asks. Co-Authored-By: Claude --- src/core/models.ts | 4 +++- src/web/setup-icons.ts | 1 + src/web/setup-page.ts | 44 +++++++++++++++++++++++++++++----------- src/web/setup-routes.ts | 4 ++-- tests/setup-page.test.ts | 9 ++++++++ 5 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/core/models.ts b/src/core/models.ts index b10d358..454903d 100644 --- a/src/core/models.ts +++ b/src/core/models.ts @@ -249,6 +249,8 @@ function maxAge(entries: Array<{ age: CacheAge }>): number | null { return Math.max(...entries.map((entry) => entry.age.ageMs)); } +export const CATALOG_DISCOVERY_TIMEOUT_MS = 12_000; + async function discoverWithTimeout( registry: DriverRegistry, request: BatchDiscoveryRequest, @@ -319,7 +321,7 @@ export async function getBatchModels( } const controller = new AbortController(); - const timeoutMs = options.timeoutMs ?? 12_000; + const timeoutMs = options.timeoutMs ?? CATALOG_DISCOVERY_TIMEOUT_MS; const discover = options.discover ?? ((selected: DriverRegistry, request: BatchDiscoveryRequest) => discoverAllModels(selected, { agents: request.agents, diff --git a/src/web/setup-icons.ts b/src/web/setup-icons.ts index cc31746..93ff731 100644 --- a/src/web/setup-icons.ts +++ b/src/web/setup-icons.ts @@ -22,6 +22,7 @@ const LINE_ICONS: Record = { "pencil": ``, "shield-check": ``, "shield-alert": ``, + "loader": ``, "rotate-cw": ``, "chevron-down": ``, "check": ``, diff --git a/src/web/setup-page.ts b/src/web/setup-page.ts index 6f46e86..d07a9a3 100644 --- a/src/web/setup-page.ts +++ b/src/web/setup-page.ts @@ -1,7 +1,7 @@ import { REASONING_EFFORTS } from "../core/driver.js"; import { ROLES, type Role } from "../core/roles.js"; import { AGENT_IDS } from "../core/session.js"; -import type { BatchModelsResult } from "../core/models.js"; +import { CATALOG_DISCOVERY_TIMEOUT_MS, 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"; @@ -295,6 +295,7 @@ export function createSetupPageController(options: SetupPageControllerOptions) { reviewOk: false, bound: false, rendered: false, + catalogDone: false, }; let draft: SetupDraft = { agents: {}, custom: false, parallelism: "" }; let refreshInFlight: Promise | undefined; @@ -553,20 +554,25 @@ export function createSetupPageController(options: SetupPageControllerOptions) { return `${Math.floor(hours / 24)} d ago`; } + function refreshErrorText(error: string): string { + const timeout = error.match(/timed out after (\d+) ?ms/); + if (timeout) return `Refresh timed out after ${Math.round(Number(timeout[1]) / 1000)} s.`; + return `Refresh failed: ${error.replace(/\.$/, "")}.`; + } + function catalogText(): string { if (state.refreshing) return "Refreshing catalog"; const catalog = state.catalog; + const error = state.discoveryError ? refreshErrorText(state.discoveryError) : ""; if (!catalog) { - if (state.discoveryError) return `Catalog unavailable: ${state.discoveryError}`; + if (error) return `Catalog unavailable. ${error}`; return state.loading ? "Loading catalog" : "Catalog not loaded"; } const age = typeof catalog.ageMs === "number" ? formatAge(catalog.ageMs) : ""; - const base = catalog.status === "fresh" - ? `Catalog updated ${age}`.trim() - : catalog.status === "offline" - ? `Catalog offline, cached ${age}`.trim() - : "Catalog unavailable"; - return state.discoveryError ? `${base}. ${state.discoveryError}` : base; + if (error) return age ? `${error} Showing models from ${age}.` : error; + if (catalog.status === "fresh") return `Catalog updated ${age}`.trim(); + if (catalog.status === "offline") return `Catalog offline, cached ${age}`.trim(); + return "Catalog unavailable"; } function formatPreviewPath(path: string): string { @@ -608,7 +614,7 @@ export function createSetupPageController(options: SetupPageControllerOptions) { if (Array.isArray(value)) return value.map((item) => sortKeys(item)); if (typeof value === "object" && value !== null) { const record = value as Record; - return Object.fromEntries(Object.keys(record).sort().map((key) => [key, sortKeys(record[key])])); + return Object.fromEntries(Object.keys(record).sort((a, b) => a.localeCompare(b)).map((key) => [key, sortKeys(record[key])])); } return value; } @@ -922,6 +928,7 @@ export function createSetupPageController(options: SetupPageControllerOptions) { if (configPath && state.target?.config?.path) configPath.textContent = state.target.config.path; swapText("catalog-status", `catalog-text${state.discoveryError ? " warn" : ""}`, catalogText()); setClass("setup-refresh", `icon-btn${state.refreshing ? " spinning" : ""}`); + setClass("setup-catalog", `catalog${state.refreshing ? " refreshing" : ui.catalogDone ? " done" : ""}`); setDisabled("setup-refresh", state.refreshing); const loadError = !state.target && !state.loading ? state.error ?? "Could not load setup state." : ""; const errorBanner = element("setup-error"); @@ -1377,7 +1384,13 @@ export function createSetupPageController(options: SetupPageControllerOptions) { } finally { state.refreshing = false; refreshInFlight = undefined; + // Let the progress bar finish its sweep before it fades out. + ui.catalogDone = true; update(); + setTimeout(() => { + ui.catalogDone = false; + update(); + }, 450); } })(); return refreshInFlight; @@ -1614,7 +1627,7 @@ export function renderSetupPage(options: SetupPageOptions = {}): string {

Setup

Pick the harness and model each role runs on. Saving writes config.json.

-
Loading catalog
+
Loading catalog
@@ -1719,10 +1732,17 @@ h1,h2,h3,p{margin:0}h1,h2{text-wrap:balance} .page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;flex-wrap:wrap;margin-bottom:8px} .page-head h1{margin-bottom:6px;font-size:24px;font-weight:600;letter-spacing:-.6px} .lede{max-width:72ch;color:var(--text-muted)}.lede code{color:var(--text);font-size:12px;overflow-wrap:anywhere} -.catalog{display:flex;align-items:center;gap:8px;color:var(--text-faint);font-size:12px}.catalog-text{display:inline-block}.catalog-text.warn{color:var(--warn-text)} +.catalog{display:flex;align-items:center;gap:8px;min-width:0;color:var(--text-faint);font-size:12px}.catalog-text{display:inline-block}.catalog-text.warn{color:var(--warn-text)} +.cat-main{position:relative;min-width:0} +.cat-bar{position:absolute;left:0;right:0;bottom:-7px;height:2px;overflow:hidden;border-radius:2px;background:var(--border);opacity:0;transition:opacity .3s ease} +.cat-bar>span{display:block;height:100%;border-radius:inherit;background:var(--blue);transform:scaleX(0);transform-origin:left;transition:transform 0s .3s} +.catalog.refreshing .cat-bar,.catalog.done .cat-bar{opacity:1;transition:opacity .15s ease} +.catalog.refreshing .cat-bar>span{transform:scaleX(.95);transition:transform var(--refresh-ms,12s) cubic-bezier(.3,.55,.45,1)} +.catalog.done .cat-bar>span{transform:scaleX(1);transition:transform .25s var(--ease)} .icon-btn{display:inline-grid;place-items:center;flex:none;width:30px;height:30px;padding:0;border:1px solid var(--border-strong);border-radius:6px;background:var(--surface);color:var(--text-muted);transition:color .15s,background-color .15s} .icon-btn .ico{width:14px;height:14px}.icon-btn:hover:not(:disabled){color:var(--text);background:var(--surface-raised)} -.icon-btn.spinning .ico{animation:cd-spin .8s linear infinite} +.icon-btn .i-spin,.icon-btn.spinning .i-idle{display:none} +.icon-btn.spinning .i-spin{display:block;color:var(--blue-text);transform-origin:50% 50%;animation:cd-spin .75s linear infinite} .load-error{margin-top:16px;padding:10px 12px;border:1px solid rgba(229,72,77,.5);border-radius:7px;color:var(--err-text);font-size:12px} .sec{margin-top:32px} .sec-head{display:flex;align-items:baseline;justify-content:space-between;gap:6px 12px;flex-wrap:wrap;margin-bottom:12px} diff --git a/src/web/setup-routes.ts b/src/web/setup-routes.ts index 4262054..61611e2 100644 --- a/src/web/setup-routes.ts +++ b/src/web/setup-routes.ts @@ -1,7 +1,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { DriverRegistry } from "../core/driver.js"; import { REASONING_EFFORTS } from "../core/driver.js"; -import { getBatchModels, type BatchModelsOptions, type BatchModelsResult } from "../core/models.js"; +import { CATALOG_DISCOVERY_TIMEOUT_MS, getBatchModels, type BatchModelsOptions, type BatchModelsResult } from "../core/models.js"; import { isAgentId, type AgentId } from "../core/session.js"; import { ROLES, type Role } from "../core/roles.js"; import { @@ -355,7 +355,7 @@ export function createSetupRoutes(dependencies: SetupRoutesDependencies = {}): W function refreshCatalog(): Promise { if (refreshInFlight) return refreshInFlight; - const pending = loadCatalog({ refresh: true, allowNetwork: true, timeoutMs: 12_000 }); + const pending = loadCatalog({ refresh: true, allowNetwork: true, timeoutMs: CATALOG_DISCOVERY_TIMEOUT_MS }); const inFlight = pending.finally(() => { if (refreshInFlight === inFlight) refreshInFlight = undefined; }); diff --git a/tests/setup-page.test.ts b/tests/setup-page.test.ts index 506ac55..306008c 100644 --- a/tests/setup-page.test.ts +++ b/tests/setup-page.test.ts @@ -547,6 +547,15 @@ describe("setup page controller", () => { expect(controller.state.discoveryError).toBe("network discovery failed"); expect(controller.state.refreshing).toBe(false); expect(el("catalog-status").className).toMatch(/^catalog-text warn/); + expect(el("catalog-status").textContent).toBe("Refresh failed: network discovery failed. Showing models from 5 min ago."); + expect(el("setup-catalog").className).toBe("catalog done"); + + const timedOut = controller.refreshCatalog(); + expect(el("setup-catalog").className).toBe("catalog refreshing"); + expect(el("setup-refresh").className).toBe("icon-btn spinning"); + finishRefresh?.(response({ error: "model catalog discovery timed out after 12000 ms" }, 504)); + await timedOut; + expect(el("catalog-status").textContent).toBe("Refresh timed out after 12 s. Showing models from 5 min ago."); }); it("shows the exact reload and restart message after a protected action returns 403", async () => {