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/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 new file mode 100644 index 0000000..93ff731 --- /dev/null +++ b/src/web/setup-icons.ts @@ -0,0 +1,44 @@ +// 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": ``, + "loader": ``, + "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..d07a9a3 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 type { BatchModelsResult } from "../core/models.js"; +import { AGENT_IDS } from "../core/session.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"; 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,54 @@ 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, + catalogDone: 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 +316,263 @@ 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; + // The first ready render replaces skeleton text, so it fades in too. + if (state.loading || !state.target) { + 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]; + } - const investigate: Record = { - none: "no investigation", - read: "read only", - free: "full access", + 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; + } + + 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; + } + + // 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); + } + + 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 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 (error) return `Catalog unavailable. ${error}`; + return state.loading ? "Loading catalog" : "Catalog not loaded"; + } + const age = typeof catalog.ageMs === "number" ? formatAge(catalog.ageMs) : ""; + 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 { @@ -240,16 +586,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 +610,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((a, b) => a.localeCompare(b)).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 +709,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 +739,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 +765,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)}${modelText(current.harness, current.model, "model-title", "model-id")}${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) + ? catalogModel(previous.harness, previous.model)?.name || 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; 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, + 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(), 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" : ""}`); - 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 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" : ""}`); + 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"); + 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)); + } + + 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 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); - 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; + 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 +1320,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 +1377,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); @@ -588,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; @@ -599,13 +1401,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 +1437,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 +1486,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 +1621,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 +1706,251 @@ 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;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 .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} +.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-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)} +.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-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} +.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-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} +.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}} +.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){ + .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/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 9110d68..306008c 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,490 @@ 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 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");
+    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.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"); - 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++]!)); + 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("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">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"); + 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); + 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); + + 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 +543,77 @@ 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/); + 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("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 +638,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"]); });