merge: integrate reviewed forms and Files track

Synthesize the reviewed Settings, chooser, Files and draft-preservation track into the shared UI foundation.
This commit is contained in:
Ouroboros 2026-09-09 22:00:27 +03:00
parent 227b99b4f3
commit d97b25ae77
29 changed files with 3009 additions and 846 deletions

View file

@ -27,7 +27,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
│ ├── modules/dashboard.js, logs.js, costs.js, files.js ← Dashboard tab-strip host with static guard markers; Logs page (backfill plus live-stream duplicate guard); Costs page (breakdown buckets — an open zero is never shown as free); Files page (file browser over `/api/files/*`, downloads through the host bridge)
│ ├── modules/skills.js, marketplace.js, skill_review_card.js, skill_publish_flow.js ← Installed skills UI (review, grant, enable, repair, update, uninstall, delete); ClawHub marketplace inside the Skills page; Skill Review chat cards; typed detail rows for the publish dialog
│ ├── modules/settings_ui.js, settings_catalog.js, settings_controls.js, settings_local_model.js, mcp_settings.js ← Settings page in reading order (Accounts → Secrets → Models → Agents); model-catalog refresh with a 25-second bound and a sequence guard; effort-segment and form-control binders; the local-model form; MCP settings cards that keep masked tokens until edited
│ ├── modules/model_roles.js ← Shared Models editor for Settings and onboarding: per-role source, model, account and context drafts, plus ordered fallback rows; catalog arrival enriches choices without assigning them
│ ├── modules/model_roles.js, model_chooser.js ← Shared Models editor for Settings and onboarding: per-role source, model, account and context drafts, plus ordered fallback rows; the editable chooser is shared with actor/reviewer route editors, and catalog arrival enriches suggestions without assigning a value or replacing the input
│ ├── modules/subagents_settings.js, subagent_status_primitives.js, reviewer_slots.js, route_editor_primitives.js, harness_accounts.js, harness_login_cards.js, claudexor_status_store.js ← Agents surfaces: the Available subagents editor shared by Settings and first-run onboarding; pure status/meta projection of one subagent card; Review lanes rows; neutral route-editor primitives shared by both editors; Agent accounts; host-neutral agent login cards (controller + view); the ONE client-side store over `GET /api/claudexor/status` (`facetReadState`)
│ ├── modules/onboarding_agents_step.js, onboarding_overlay.js, project_create.js, utils.js ← the first-run "Connect your accounts" step; the framed wizard's sandbox policy kept in one place because it is a security boundary; the New Project dialog and project row actions; shared frontend utilities (escaping, formatting)
│ ├── modules/review_presentation.js, review_dom_patch.js, harness_presentation.js ← Review Checkpoint grouping/status, keyed DOM reconciliation, neutral harness identity presentation; read-side only; `executorIdentityMarkup` renders projected card identity/model facts at that existing presentation owner
@ -698,7 +698,7 @@ Shared frontend primitives keep pages from acquiring competing contracts — fro
`confirm_dialog.js::openConfirmDialog` is the one browser-dialog authority: confirm mode resolves a strict boolean, input mode `{confirmed, value}` (empty on cancellation), alert mode one acknowledgement button; Cancel, Close, backdrop, Escape, and supersession all resolve as non-confirmation. Native `window.prompt`/`confirm`/`alert` are forbidden in `web/modules`: inconsistent across shells, event-loop blocking, and `window.prompt` silently returns `null` in the macOS PyWebView shell. Critical controls act only on the exact confirmed result — Panic's confirm-and-send is one testable operation.
Confirm dialogs use `bindDialogFocus` without changing result semantics. Callers bind after mounting and dispose before removal; restoration does not take focus from a different active surface. Menus close before their actions open a dialog. Editable suggestion lists reuse popup geometry alone so menu focus behavior cannot interfere with native text editing.
Confirm and New Project dialogs use `bindDialogFocus` without sharing result semantics. Callers bind after mounting and dispose before removal; restoration does not take focus from a different active surface. Menus close before their actions open a dialog. `model_chooser.js` supplies the shared editable suggestions for model roles and actor/reviewer route editors, using popup geometry alone so menu focus behavior cannot interfere with native text editing. The input keeps its draft/caret/composition through discovery updates; Arrow keys highlight and Enter or pointer selection assigns a suggestion, while Escape/blur dismiss without assignment. Fixed source/account choices remain native selects, and unknown saved or arbitrary API model ids stay editable without an inventory allowlist.
### Chat and Projects
@ -736,7 +736,7 @@ A Main root card may be turned into a Project: conversion creates or reuses the
A Project chat has one compact navigation pointer in its existing status bar (`project_work_pointer.js`). `projectWorkTarget` selects the last connected non-child card in the existing registry order that is unfinished, or the last represented root when all are finished; it creates no card or execution state. The pointer updates through Chat's existing stable-viewport mutation seam. Clicking scrolls only that chat's messages container to the selected card and records the existing reading intent; it does not select the recipient of the next message. Unless `historyWindow.complete` is explicitly true, the adjacent note says `Loaded messages only`; an empty represented set disables the button as `No task card in loaded messages`, never a claim that the Project has no work. The binding's disposer removes its one listener and button/note with the chat instance. No separate task pane, history fetch, poller or persisted pointer state is introduced.
The New Project dialog supports exactly one source: no folder, a fresh managed genesis workspace, an attached existing folder, or a cloned Git URL. Attach uses a server-side directory browser so the flow works in web and Docker too; a non-git attached folder is rejected unless the owner explicitly requests attach-snapshot initialization — never initialized silently; clone failures distinguish missing credentials. All three disclose that Project tasks receive read, write, and shell access in the chosen folder; provenance remains a durable historical fact, not recomputed from current git state.
The New Project dialog supports exactly one source: no folder, a fresh managed genesis workspace, an attached existing folder, or a cloned Git URL. The selected folder remains visible independently of the directory being browsed, and submission uses that selected target. Its shared focus boundary leaves source/creation and non-confirming cancellation semantics with `project_create.js`. Attach uses a server-side directory browser so the flow works in web and Docker too; a non-git attached folder is rejected unless the owner explicitly requests attach-snapshot initialization — never initialized silently; clone failures distinguish missing credentials. All three disclose that Project tasks receive read, write, and shell access in the chosen folder; provenance remains a durable historical fact, not recomputed from current git state.
Deleting a Project is lifecycle work, not filesystem deletion: the server fences new admission, cancels and quiesces the Project task subtree, then tombstones the registry entry; the UI may acknowledge that deletion started but does not claim completion early. Project id, canonical history, task bindings, memory, provenance, and the working folder remain preserved — deleting the row is not permission to erase the owner's repository or the agent's history.
@ -752,7 +752,7 @@ The agent-facing `chat_history` reader uses the same live-plus-rotated timeline
### Files
Files is a full gateway-backed file manager, not a chat attachment picker: directory navigation, breadcrumbs, filtering, image and sandboxed PDF preview, text preview/editing, an explicit binary/unsupported state, file and directory creation, save, drag-and-drop upload, download, open in the default OS application, copy, move, paste, and recursive delete. The desktop host bridge and web fallback share one download contract. Unsaved text is guarded on selection, directory change, navigation, and unload; Save exists only for a writable complete text read — a truncated preview stays read-only. The backend is the path authority (root confinement and symlink policy in §4); UI path strings and disabled buttons are presentation only.
Files is a full gateway-backed file manager, not a chat attachment picker: directory navigation, breadcrumbs, filtering, image and sandboxed PDF preview, text preview/editing, an explicit binary/unsupported state, file and directory creation, save, drag-and-drop upload, download, open in the default OS application, copy, move, paste, and recursive delete. The desktop host bridge and web fallback share one download contract. Unsaved text is guarded on selection, directory change, navigation, and unload; a cancelled leave keeps the current document/selection, and ordinary directory refresh keeps its editor. Save exists only for a writable complete text read — a truncated preview stays read-only — with one in-flight write for pointer and keyboard submission. Save failures and clipboard feedback occupy a sibling status region rather than replacing editable content. The backend is the path authority (root confinement and symlink policy in §4); UI path strings and disabled buttons are presentation only.
### Skills and Widgets
@ -798,6 +798,8 @@ Updates separates passive status from explicit mutation. Opening the page reads
Settings has Accounts, Secrets, Models, Agents, Behavior, Advanced, and About tabs — a sequence from connections to runtime detail. Accounts: managed subscriptions and their shared service banner, API providers, custom compatible endpoints, local runtime entry points, and the optional non-loopback network gate. Secrets: known provider/integration secrets, skill-requested keys, and owner-defined custom keys, without returning stored values. Models: compact source/model/account role rows, ordered fallbacks, context assertions and effort lanes. Agents: task actors and review lanes, with delegation permissions, per-root and depth limits and subagent path roots; their accounts are managed in Accounts. Behavior: context, safety-supervisor coverage, task acceptance, self-evolution, prompt-cache posture. Advanced: process, timeout, local-model, integration, source-control, and cleanup controls (worker count is process capacity, so it lives here). About reports application/runtime identity.
The Settings client collects and validates the whole current draft before sending Save; any local error keeps all rows/values available for correction and sends no partial save. `settings_controls.js` separates pure custom-key collection from field-error painting and keeps dirty reads passive. Ordinary refresh and failed writes preserve current edits; leaving or explicitly reloading a dirty draft asks before discarding it. The existing write response still distinguishes saved, unsaved and unknown outcomes, with owner-only decisions on their own endpoints. No durable cross-page draft store or secret persistence is introduced.
Each provider card has one compact **Test** action backed by `POST /api/providers/test`: request exactly `{provider_id, overrides?}`, response exactly `{ok, error?}`. Overrides are request-local — an omitted field reads the saved value, an explicitly edited empty field stays empty (for the compatible card it suppresses the corresponding legacy OpenAI fallback) — and draft credentials never mutate Settings, process environment, or `LLMClient` caches. Model selection reuses a configured route, then the provider's maintained main default; only the generic OpenAI-compatible route performs bounded catalogue discovery when neither exists. A configured test is one bounded, physically accounted probe (`ouroboros/llm_probe.py`) with none of the normal-chat retry/fallback/tools/reasoning/web/cache/response-format/capability-learning paths. The card renders `Testing…`, `Works`, or `Not ready` with one controlled short reason; the tooltip notes the request may incur provider charges.
Desktop onboarding and the blocking web overlay are the same served `/onboarding` page — same steps, same backend normalization; context mode stays a separate owner setting. Startup readiness is structural (§2): a configured managed-model Main route can satisfy the gate without an API key; credential validity, entitlement, model availability, and local-process health remain runtime status, not onboarding admission. Every host completes through the single `POST /api/onboarding/complete` transaction (§2), so completion is all-or-nothing and install-time agent defaults are part of the same save.
@ -810,7 +812,7 @@ Connect is link-first and harness-agnostic. A typed disclosure renders the sign-
Account status refresh runs immediately and on visible page/tab activation; hidden pages do not pay for daemon round-trips. Entering Agents is an explicit owner action: after the fresh read, an already-provisioned `stale` home restarts through the wake endpoint, while `not_provisioned`, foreign-owned, and repair states stay behind Connect; background polling is read-only and never wakes the daemon. Job polling uses one request at a time, backs off on consecutive failures, and after ten stops with an honest unconfirmed state — lost contact proves neither failure nor settlement. One transition lock covers Start, Retry, and Dismiss, and a new login begins only once release of the prior job is proven (`loginReleaseProven`): a 2xx cancel alone is not proof, a network/server failure retains the card and job id because dropping it could orphan a live server job, and after each await the handler rechecks whether polling settled the job so a stale cancel continuation cannot overwrite a terminal result.
Review lanes edits one structured reviewer configuration. Each triad, scope, optional advisory, or deep self-review row picks its reviewer from ONE flat select: Available-subagents roster rows lead as references, then the inline channels — API delivery or a coding-agent session — followed by model, optional credential profile, and effort. The two multi-row categories are driven by one `CATEGORIES` table and the two single-row categories (advisory, deep self-review) by one renderer/binder parameterized by a `SINGLETONS` table; the deep self-review block states its one difference from the advisory where the owner picks — an API model there is ONE packed review (Atlas + memory), not an inspection episode — and, while the row is only synthesized from `OUROBOROS_MODEL_DEEP_SELF_REVIEW`, says so — an untouched synthesized (or empty) placeholder is OMITTED from the save payload, so an unrelated save never writes the key's value into the setting and a repair save beside a `config_error` (the endpoint still attaches the synthesized row there) succeeds without it; editing the row materializes it, and a blanked model box is hinted client-side and refused typed at save (owner fork 3 = A). The former Models-tab field is gone (R7). Saved choices that disappear from discovery stay visible as unavailable rather than silently changing; capability labels configure nothing, and server-returned limits plus last effective execution disclose what a saved row actually ran as. An unloaded view authors no replacement, and a successfully loaded empty triad/scope is sent as shown so backend validation returns the real error. A row pinned to an account discovery no longer lists keeps its pin, disclosed once above the rows — and only on the word of a facet actually read (account pins answer to `accounts`, models to `catalog`; an unread or failed facet yields "not checked", not "not in discovery"). The standing note states the rule, never the situation: every review surface — commit, scope, plan, advisory, skill review and task acceptance — follows its configured rows and waits for subscription capacity rather than falling back to API spend (owner R2 retired the task-acceptance API pin and its default-panel disclosure).
Review lanes edits one structured reviewer configuration. Each triad, scope, optional advisory, or deep self-review row picks its reviewer from ONE flat select: Available-subagents roster rows lead as references, then the inline channels — API delivery or a coding-agent session — followed by model, optional credential profile, and effort. The two multi-row categories are driven by one `CATEGORIES` table and the two single-row categories (advisory, deep self-review) by one renderer/binder parameterized by a `SINGLETONS` table; the deep self-review block states its one difference from the advisory where the owner picks — an API model there is ONE packed review (Atlas + memory), not an inspection episode — and, while the row is only synthesized from `OUROBOROS_MODEL_DEEP_SELF_REVIEW`, says so — an untouched synthesized (or empty) placeholder is OMITTED from the save payload, so an unrelated save never writes the key's value into the setting and a repair save beside a `config_error` (the endpoint still attaches the synthesized row there) succeeds without it; editing the row materializes it, and a blanked model box is hinted client-side and refused typed at save (owner fork 3 = A). The former Models-tab field is gone (R7). Saved choices that disappear from discovery stay visible as unavailable rather than silently changing; capability labels configure nothing, and server-returned limits plus last effective execution disclose what a saved row actually ran as. An unloaded view authors no replacement. The pure serializer preserves a successfully loaded empty triad/scope; the Settings local validation gate reports that invalid draft before POST, while the backend independently enforces the same configuration contract. A row pinned to an account discovery no longer lists keeps its pin, disclosed once above the rows — and only on the word of a facet actually read (account pins answer to `accounts`, models to `catalog`; an unread or failed facet yields "not checked", not "not in discovery"). The standing note states the rule, never the situation: every review surface — commit, scope, plan, advisory, skill review and task acceptance — follows its configured rows and waits for subscription capacity rather than falling back to API spend (owner R2 retired the task-acceptance API pin and its default-panel disclosure).
**Available subagents** is the single task-actor editor. Its list-level Enabled flag and at most ten stable rows are the saved `OUROBOROS_SUBAGENTS` intent. The owner sees numbered compact cards (`docs/DESIGN.md` §6 row anatomy) and authors one prose field, Description (`recommended_use`), beside the structured API-model or Agent-session route, optional effort, and optional managed-model/session account pin (empty pin = Claudexor's compatible-account rotation). Each card's status dot composes two axes — intent (Saved / Draft / Generated) and availability (Available / Not checked / Unavailable / No account / Limit reached, or Checked at start for an API-model route) — with the dot's tone the worse of the two (`subagent_status_primitives.sessionRouteVerdict` / `rowStatus`). Identity is the stable internal `subagent_id` plus live route facts; parse accepts and drops a legacy display `name`, and a visual ordinal never becomes durable identity. The editor shares only neutral route/model/account/status primitives with Review lanes. Add and Duplicate reveal the new entry through the shared `ui_helpers.revealNewRow`. A fresh entry is not an error: its meta line carries a neutral hint until a save attempt (`noteSaveAttempt`; `validate()` stays pure), after which the section line summarises and the offending card is tinted and names its error, reconciled in place by one painter; in the wizard the Finish error shows on the summary step with the card already tinted.

View file

@ -1946,6 +1946,19 @@ owner, owed terminal delivery, cascade postconditions — lives in ARCHITECTURE
raw-model sources can supply Main; an Agent-only connection cannot invent one.
Subscription copy says "without an API key", never guaranteed free. Provider
credits/spend settings are not enabled or changed by connecting an account.
- Settings validates the complete draft before any Save request; never omit an
invalid custom-key row and save the remainder. Keep pure draft collection and
dirty comparison separate from field-error painting. A failed save/refresh
preserves edits; leaving or reloading a dirty draft asks before discarding.
Preserve saved/unsaved/unknown write receipts and the independent owner-only
endpoints. Tests: `web/tests/settings_validation.test.js` and the real
`tests/test_ui_smoke_settings_drafts.py` consumer.
- Model-role and actor/reviewer adapters use `model_chooser.js`; the chooser
owns suggestions/keyboard/position only, never route identity or entitlement.
Update options in place and dispose bindings before replacing inputs. Short
source/account choices stay native; retain arbitrary API ids and saved
undiscovered choices. Tests: `web/tests/model_chooser.test.js`,
`tests/test_model_chooser_browser.py`, `tests/test_subscription_role_routes_browser.py`.
- Models, actors and reviewers share source/model/account controls. Preserve
exact pins on ordinary save/reload and catalog failure; a source's credential
harness comes from its metadata, never an assumed equal name. A referenced
@ -3016,6 +3029,13 @@ dialog from its action. `web/tests/ui_interactions.test.js` pins callbacks,
focus, geometry and cleanup; actual menu/chooser/dialog browser consumers
remain necessary for viewport and engine-sensitive behavior.
Files keeps one current editable document through cancelled navigation, ordinary
folder refresh, failed Save and clipboard feedback. Pointer/keyboard submission
shares one in-flight write; newer text remains dirty after an earlier save.
The New Project adapter shares dialog focus and menu behavior while retaining
all source modes and its selected target independently of browser navigation.
`tests/test_ui_smoke_files_project_drafts.py` verifies these real consumers.
### Declarative widgets
`web/modules/widgets.js` is the host for reviewed widget declarations:

View file

@ -65,7 +65,9 @@ def test_a_fresh_subagent_row_invites_and_only_a_save_attempt_makes_it_red() ->
host = _read(MODULES / "settings.js")
# Every Save click is an attempt — including one another field's validation
# then aborts — so the stamp precedes the cadence check's early return.
assert host.index("noteSubagentsSaveAttempt();") < host.index("Every-N cadence needs")
save = host[host.index("byId('btn-save-settings').addEventListener"):]
assert save.index("noteSubagentsSaveAttempt();") < save.index("const errors = renderValidation();")
assert "Every-N cadence needs" in host
assert "agentsStep?.noteSaveAttempt?.();" in _read(MODULES / "onboarding_wizard.js")
# Errors name the card the way its heading does, never a bare "Row N".
assert "`Subagent ${index + 1} ${text}`" in editor

View file

@ -22,10 +22,13 @@ def test_files_page_registers_navigation_guard():
def test_new_file_discard_and_context_menu_clamp_regressions():
source = _read("web/modules/files.js")
shared = _read("web/modules/ui_interactions.js")
assert "createNewFile({ force: true })" in source
assert "window.innerWidth - rect.width" in source
assert "window.innerHeight - rect.height" in source
assert "bindMenu(contextMenuEl" in source
assert "document.body.appendChild(contextMenuEl)" in source
assert "leftEdge + width - rect.width" in shared
assert "topEdge + height - rect.height" in shared
def test_files_page_explains_manager_role_and_directory_affordance():
@ -192,6 +195,7 @@ def test_open_browser_detached_records_outcome(monkeypatch):
def test_files_confirm_dialog_results_are_normalized():
source = _read("web/modules/files.js")
helper = _read("web/modules/ui_helpers.js")
primitives = _read("web/modules/ui_primitives.js")
toast = _read("web/modules/toast.js")
assert "typeof result === 'boolean' ? { confirmed: result, value: '' } : result" in source

View file

@ -0,0 +1,90 @@
"""Browser regressions for generated subagent preview identity preservation."""
from __future__ import annotations
import copy
import json
import pytest
from tests.test_subscription_setup_browser import subscription_ui
pytestmark = [pytest.mark.ui_browser, pytest.mark.serial]
def _watch_previews(page):
page.evaluate("performance.clearResourceTimings()")
def _wait_for_two_previews(page):
page.wait_for_function("""
() => performance.getEntriesByType('resource')
.filter(entry => entry.name.includes('/api/onboarding/subagents/preview')).length >= 2
""")
def test_settings_same_preview_keeps_live_row_handler_and_payload(subscription_ui):
ui, page = subscription_ui, subscription_ui["page"]
roster = copy.deepcopy(ui["fixture"]["preview"]["available_subagents"])
ui["settings"]["OUROBOROS_SUBAGENTS"] = ""
ui["settings"]["_meta"]["available_subagents"] = {"source": "undecided", "candidate": roster}
saves = []
def settings_route(route):
if route.request.method == "POST":
saves.append(route.request.post_data_json)
route.fulfill(content_type="application/json", body=json.dumps({
"status": "saved", "saved": True, "restart_required": False,
}))
else:
route.fulfill(content_type="application/json", body=json.dumps(ui["settings"]))
page.route("**/api/settings", settings_route)
_watch_previews(page)
page.goto(ui["url"] + "/#settings")
with page.expect_response("**/api/onboarding/subagents/preview"):
page.locator('[data-settings-tab="agents"]').click()
page.wait_for_selector("[data-subagent-row]")
_wait_for_two_previews(page)
page.evaluate("window.__rowBefore = document.querySelector('[data-subagent-row]')")
row = page.locator("[data-subagent-row]").first
field = row.locator('[data-subagent-field="recommended_use"]')
field.fill("SETTINGS EDIT 123")
assert page.evaluate("() => document.activeElement === document.querySelector('[data-subagent-field=recommended_use]')")
assert page.evaluate("() => window.__rowBefore === document.querySelector('[data-subagent-row]')")
with page.expect_response("**/api/settings"):
page.locator("#btn-save-settings").click()
assert saves
payload = saves[-1]["OUROBOROS_SUBAGENTS"]
if isinstance(payload, str):
payload = json.loads(payload)
assert payload["items"][0]["recommended_use"] == "SETTINGS EDIT 123"
def test_wizard_same_preview_keeps_live_row_handler_and_finish_payload(subscription_ui):
ui, page = subscription_ui, subscription_ui["page"]
_watch_previews(page)
page.goto(ui["url"] + "/onboarding")
page.wait_for_selector("#quick-start-btn:not([hidden])")
page.click("#next-btn")
page.wait_for_selector('[data-model-role="main"]')
page.locator("details:has(#onboarding-available-subagents) > summary").click()
page.wait_for_selector("#onboarding-available-subagents [data-subagent-row]")
_wait_for_two_previews(page)
page.evaluate("window.__rowBefore = document.querySelector('#onboarding-available-subagents [data-subagent-row]')")
field = page.locator("#onboarding-available-subagents [data-subagent-field='recommended_use']").first
field.fill("WIZARD EDIT 456")
assert page.evaluate("() => window.__rowBefore === document.querySelector('#onboarding-available-subagents [data-subagent-row]')")
page.click("#next-btn")
page.wait_for_selector("#reviewer-slots-section", state="attached")
page.click("#next-btn")
page.wait_for_selector('[data-collapse="api-budget"]')
page.click("#next-btn")
page.wait_for_selector(".summary-card")
with page.expect_response("**/api/onboarding/complete"):
page.click("#next-btn")
writes = [body for path, body in ui["posts"] if path == "/api/onboarding/complete"]
assert len(writes) == 1
payload = writes[0]["OUROBOROS_SUBAGENTS"]
if isinstance(payload, str):
payload = json.loads(payload)
assert payload["items"][0]["recommended_use"] == "WIZARD EDIT 456"

View file

@ -0,0 +1,221 @@
"""Editable model chooser on real Settings/actor/reviewer consumers, no model calls."""
from __future__ import annotations
import json
import pytest
from tests import test_subscription_role_routes_browser as roles
pytestmark = [pytest.mark.ui_browser, pytest.mark.serial]
subscription_ui = roles.subscription_ui
role_ui = roles.role_ui
def catalog(ui):
value = ui["fixture"]["catalog"]
value["items"] = [{"value": f"choice-{i:02}", "id": f"choice-{i:02}"} for i in range(30)]
return value
def select_model_field(ui, consumer):
roles.configure_mixed(ui)
if consumer in ['Scope', 'Deep']:
slots = ui['fixture']['preview']['reviewer_slots']
if consumer == 'Scope': slots['scope'] = [{'slot_id': 'scope_1', 'route': {'kind': 'api_chat', 'target_id': 'owner/model'}}]
else: slots['deep_review'] = {'route': {'kind': 'api_chat', 'target_id': 'owner/model'}}
ui['settings']['OUROBOROS_REVIEWER_SLOTS'] = json.dumps(slots)
data = catalog(ui)
page = roles.open_agents(ui)
page.evaluate("""detail => document.dispatchEvent(new CustomEvent(
'settings-model-catalog:updated', {detail}))""", data)
if consumer in ["Models", "Fallback"]:
page.locator('[data-settings-tab="models"]').click()
if consumer == 'Fallback': page.locator('[data-model-add]').click()
group = page.locator('[data-model-role="main"]') if consumer == 'Models' else page.locator('[data-model-role-group="fallback"] .model-role-row').first
group.locator('[data-model-role-source]').select_option('openrouter')
return page, group.locator('[data-model-role-model]')
if consumer == "Actor":
row = page.locator('[data-subagent-row]').nth(1)
return page, row.locator('[data-subagent-field="model"]')
if consumer == 'Scope': return page, page.locator('[data-slot-id="scope_1"] [data-slot-custom-api]')
if consumer == 'Deep': return page, page.locator('[data-deep-review-api-model]')
row = page.locator('[data-slot-id="triad_1"]') if consumer == "Triad" else page.locator('[data-advisory-row]')
row.locator('[data-slot-route], [data-advisory-route]').select_option('api')
return page, row.locator('[data-slot-custom-api], [data-advisory-api-model]')
@pytest.mark.parametrize("consumer", ["Models", "Fallback", "Actor", "Triad", "Scope", "Advisory", "Deep"])
def test_chooser_keyboard_free_values_and_catalog_identity(role_ui, consumer):
page, field = select_model_field(role_ui, consumer)
field.fill('choice')
popup = page.locator('[id="' + field.get_attribute('aria-controls') + '"]')
assert popup.is_visible()
assert popup.locator('[role="option"]').count() == 30
field.press('ArrowDown')
assert field.get_attribute('aria-activedescendant')
field.press('Escape')
assert field.input_value() == 'choice'
assert not popup.is_visible()
field.press('ArrowDown')
field.press('Enter')
assert field.input_value() == 'choice-00'
assert field.get_attribute('aria-expanded') == 'false'
field.fill('owner/new-unknown-id')
field.evaluate("""e => { window.keptModelField = e; e.setSelectionRange(3, 7);
e.dispatchEvent(new CompositionEvent('compositionstart', {bubbles:true})); }""")
page.evaluate("""() => document.dispatchEvent(new CustomEvent(
'settings-model-catalog:updated', {detail:{items:[],model_sources:[]}}))""")
assert field.evaluate('e => e === window.keptModelField && document.activeElement === e')
assert field.evaluate('e => [e.selectionStart,e.selectionEnd]') == [3, 7]
assert field.input_value() == 'owner/new-unknown-id'
field.evaluate("e => e.dispatchEvent(new CompositionEvent('compositionend', {bubbles:true}))")
field.press('Tab')
assert field.input_value() == 'owner/new-unknown-id'
assert not popup.is_visible()
roles.capture(page, f"chooser-{consumer.lower()}-unknown-kept")
def test_agent_session_uses_same_chooser_and_keeps_native_short_choices(role_ui):
roles.configure_mixed(role_ui)
page = roles.open_agents(role_ui)
row = page.locator('[data-subagent-row]').nth(2)
model = row.locator('[data-subagent-field="model"]')
assert model.evaluate('e => e.tagName') == 'INPUT'
assert model.get_attribute('role') == 'combobox'
for field in ['route', 'account', 'effort']:
assert row.locator(f'[data-subagent-field="{field}"]').evaluate('e => e.tagName') == 'SELECT'
model.fill('future-model')
page.evaluate("""detail => document.dispatchEvent(new CustomEvent(
'settings-model-catalog:updated', {detail}))""", role_ui['fixture']['catalog'])
assert model.input_value() == 'future-model'
assert row.locator('[data-subagent-field="account"]').input_value() == 'personal'
assert page.locator('[data-deep-review-route]').input_value() == 'subagent:native'
roles.capture(page, 'chooser-session-reference-preserved')
def test_incomplete_reviewer_holds_whole_settings_save_until_corrected(role_ui):
roles.configure_mixed(role_ui)
page = roles.open_agents(role_ui)
model = page.locator('[data-slot-id="triad_1"] [data-slot-custom-api]')
model.fill('')
page.locator('#btn-save-settings').click()
assert not [path for path, _ in role_ui['posts'] if path == '/api/settings']
assert model.input_value() == ''
assert model.get_attribute('aria-invalid') == 'true'
model.fill('owner-unknown-review-model')
assert model.get_attribute('aria-invalid') == 'false'
with page.expect_response('**/api/settings'):
page.locator('#btn-save-settings').click()
writes = [payload for path, payload in role_ui['posts'] if path == '/api/settings']
assert len(writes) == 1
roles.capture(page, 'chooser-reviewer-corrected-save')
@pytest.fixture
def touch_role_ui(monkeypatch, request):
monkeypatch.setenv('OUROBOROS_UI_HAS_TOUCH', '1')
return request.getfixturevalue('role_ui')
def test_chooser_touch_selection(touch_role_ui):
page, field = select_model_field(touch_role_ui, 'Triad')
page.set_viewport_size({'width': 390, 'height': 600})
field.tap()
field.fill('choice-')
popup = page.locator('[id="' + field.get_attribute('aria-controls') + '"]')
popup.locator('[data-model-value="choice-01"]').tap()
assert field.input_value() == 'choice-01'
assert field.get_attribute('aria-expanded') == 'false'
roles.capture(page, 'chooser-touch-selected')
def test_partial_numeric_context_blocks_save_and_preserves_auto(role_ui):
roles.configure_mixed(role_ui)
role_ui['settings']['OUROBOROS_MODEL_CONTEXT_WINDOWS'] = {'main': 8192}
page = roles.open_agents(role_ui)
page.locator('[data-settings-tab="models"]').click()
row = page.locator('[data-model-role="main"]')
row.locator('summary').click()
field = row.locator('[data-model-role-context]')
assert field.input_value() == '8192'
field.fill('')
field.press('1')
field.press('e')
assert field.evaluate('e => e.validity.badInput')
page.locator('#btn-save-settings').click()
assert not [path for path, _ in role_ui['posts'] if path == '/api/settings']
assert field.get_attribute('aria-invalid') == 'true'
field.fill('16384')
assert field.get_attribute('aria-invalid') == 'false'
with page.expect_response('**/api/settings'):
page.locator('#btn-save-settings').click()
saved = [payload for path, payload in role_ui['posts'] if path == '/api/settings'][-1]
assert saved['OUROBOROS_MODEL_CONTEXT_WINDOWS']['main'] == 16384
page.wait_for_function("() => !document.querySelector('#btn-save-settings').disabled")
row.locator('summary').click()
field.fill('')
assert not field.evaluate('e => e.validity.badInput')
with page.expect_response('**/api/settings'):
page.locator('#btn-save-settings').click()
saved = [payload for path, payload in role_ui['posts'] if path == '/api/settings'][-1]
assert saved['OUROBOROS_MODEL_CONTEXT_WINDOWS']['main'] == 0
roles.capture(page, 'numeric-context-corrected-auto')
page.wait_for_function("() => !document.querySelector('#btn-save-settings').disabled")
row.locator('summary').click()
field.press('1')
field.press('e')
assert field.evaluate('e => e.validity.badInput')
assert 'is-visible' in page.locator('#settings-unsaved-indicator').get_attribute('class')
def test_chooser_scrolls_with_mobile_keyboard_boundary(role_ui):
page = role_ui['page']
page.set_viewport_size({'width': 390, 'height': 844})
page.add_init_script("""(() => {
const viewport = new EventTarget();
Object.assign(viewport, {width:390,height:844,offsetLeft:0,offsetTop:0,scale:1});
Object.defineProperty(window,'visualViewport',{value:viewport,configurable:true});
window.testViewport = viewport;
})();""")
page, field = select_model_field(role_ui, 'Triad')
field.click()
field.fill('choice')
popup = page.locator('[id="' + field.get_attribute('aria-controls') + '"]')
page.evaluate("""() => { testViewport.height=400;testViewport.dispatchEvent(new Event('resize')); }""")
page.wait_for_function("document.body.classList.contains('keyboard-open')")
assert popup.evaluate('e => e.scrollHeight > e.clientHeight')
def gesture(target):
return target.evaluate("""node => {
for (const [type,y] of [['touchstart',220],['touchmove',170]]) {
const event=new Event(type,{bubbles:true,cancelable:true});
Object.defineProperty(event,'touches',{value:[{clientX:100,clientY:y}]});
node.dispatchEvent(event);
if(type==='touchmove') return event.defaultPrevented;
}
}""")
assert gesture(popup) is False
assert gesture(page.locator('#page-settings .app-page-header')) is True
assert field.evaluate('e => document.activeElement === e')
roles.capture(page, 'chooser-keyboard-touch-scroll')
@pytest.mark.parametrize("width,height", [(320, 480), (390, 600), (640, 360), (641, 540),
(760, 600), (768, 600), (980, 600), (981, 540), (1440, 900), (1920, 900)])
def test_chooser_popup_reachable_in_narrow_short_view(role_ui, width, height):
page, field = select_model_field(role_ui, 'Triad')
page.set_viewport_size({'width': width, 'height': height})
field.click()
field.fill('choice')
popup = page.locator('[id="' + field.get_attribute('aria-controls') + '"]')
assert popup.is_visible()
box = popup.bounding_box()
roles.capture(page, f'chooser-open-{width}-{height}')
assert box and box['x'] >= 0 and box['y'] >= 0
assert box['x'] + box['width'] <= width + 1
assert box['y'] + box['height'] <= height + 1, {'popup': box, 'anchor': field.bounding_box()}
option = popup.locator('[role="option"]').nth(29)
option.scroll_into_view_if_needed()
option.click()
assert field.input_value() == 'choice-29'
roles.capture(page, f'chooser-narrow-{width}-{height}')

View file

@ -0,0 +1,194 @@
"""Wizard navigation and local-action feedback through the production UI.
Reuse the existing static-server/API-boundary fixture: no installed runtime,
account, provider, or settings write is involved in these interaction checks.
"""
from __future__ import annotations
import json
import pytest
from tests.test_subscription_setup_browser import (
capture,
subscription_ui as subscription_ui,
)
pytestmark = [pytest.mark.ui_browser, pytest.mark.serial]
def _open_wizard(ui):
page = ui["page"]
page.goto(ui["url"] + "/onboarding")
page.wait_for_selector("#quick-start-btn:not([hidden])")
assert page.locator(".wizard-step").count() == 5
return page
@pytest.mark.parametrize("viewport", [(390, 600), (1360, 900)])
def test_wizard_navigation_restores_draft_and_back_position(subscription_ui, viewport):
ui, page = subscription_ui, subscription_ui["page"]
page.set_viewport_size(dict(zip(("width", "height"), viewport)))
_open_wizard(ui)
page.click("#next-btn")
page.wait_for_selector('[data-model-role="main"]')
assert page.evaluate("document.activeElement.matches('.step-title')")
assert page.evaluate("window.scrollY") == 0
capture(page, f"wizard-models-arrival-{viewport[0]}")
main = page.locator('[data-model-role="main"] [data-model-role-model]')
main.fill("owner-kept-model")
page.locator('[data-collapse="subagents"] > summary').click()
page.wait_for_selector("#onboarding-available-subagents .available-subagent-row")
page.locator("#next-btn").scroll_into_view_if_needed()
previous_position = page.evaluate("window.scrollY")
page.click("#next-btn")
page.wait_for_selector("#reviewer-slots-section", state="attached")
assert page.evaluate("document.activeElement.matches('.step-title')")
assert page.evaluate("window.scrollY") == 0
page.locator('[data-collapse="reviewers"] > summary').click()
page.evaluate("window.heldReviewers = document.querySelector('[data-collapse=reviewers]')")
# Test keyboard focus retention explicitly: WebKit's native pointer click
# intentionally does not focus a button as Chromium's does.
page.locator('[data-review-mode="blocking"]').focus()
page.locator('[data-review-mode="blocking"]').press("Enter")
assert page.evaluate("heldReviewers === document.querySelector('[data-collapse=reviewers]') && heldReviewers.open")
assert page.locator('[data-review-mode="blocking"]').get_attribute("aria-pressed") == "true"
assert page.locator('[data-review-mode="advisory"]').get_attribute("aria-pressed") == "false"
assert page.evaluate("document.activeElement.matches('[data-review-mode=blocking]')")
page.click("#back-btn")
page.wait_for_selector('[data-model-role="main"]')
assert main.input_value() == "owner-kept-model"
assert page.locator('[data-collapse="subagents"]').evaluate("el => el.open")
assert abs(page.evaluate("window.scrollY") - previous_position) <= 1
assert page.evaluate("document.activeElement.matches('.step-title')")
capture(page, f"wizard-models-back-{viewport[0]}")
assert not any(path == "/api/onboarding/complete" for path, _ in ui["posts"])
def test_wizard_summary_refresh_keeps_focused_action_and_position(subscription_ui):
ui = subscription_ui
page = _open_wizard(ui)
page.set_viewport_size({"width": 390, "height": 600})
page.click("#quick-start-btn")
page.wait_for_selector(".summary-card")
assert page.evaluate("document.activeElement.matches('.step-title') && window.scrollY === 0")
page.locator("#next-btn").focus()
page.evaluate("window.heldFinish = document.activeElement; window.heldPosition = window.scrollY")
# A changed account/model discovery causes a real preview and catalog
# refresh. The summary changes, while its focused Start action stays put.
ui["fixture"]["status"]["harnesses"][0]["models"].append({"id": "new-discovery"})
ui["fixture"]["preview"]["model_settings"]["OUROBOROS_MODEL"] = "claudexor::codex=updated-preview"
page.evaluate("""async () => {
const {claudexorStatus} = await import('/static/modules/claudexor_status_store.js');
await claudexorStatus.refresh({includeModels: true});
}""")
page.wait_for_function("document.querySelector('.summary-card').textContent.includes('updated-preview')")
assert page.evaluate("heldFinish === document.querySelector('#next-btn') && document.activeElement === heldFinish")
assert page.evaluate("Math.abs(window.scrollY - heldPosition) <= 1")
def test_wizard_unchanged_preview_preserves_pristine_model_focus(subscription_ui):
ui = subscription_ui
page = _open_wizard(ui)
page.click("#next-btn")
page.wait_for_selector('[data-model-role="main"] [data-model-role-model]')
page.locator('[data-model-role="main"] [data-model-role-model]').focus()
page.evaluate("""() => {
window.heldModel = document.activeElement;
heldModel.setSelectionRange(1, 4);
window.heldPosition = window.scrollY;
}""")
ui["fixture"]["status"]["harnesses"][0]["models"].append({"id": "another-discovered-model"})
with page.expect_response("**/api/model-catalog") as catalog:
page.evaluate("""async () => {
const {claudexorStatus} = await import('/static/modules/claudexor_status_store.js');
await claudexorStatus.refresh({includeModels: true});
}""")
catalog.value.finished()
page.evaluate("() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))")
assert page.evaluate("document.activeElement === heldModel && heldModel.isConnected")
assert page.evaluate("heldModel.selectionStart === 1 && heldModel.selectionEnd === 4")
assert page.evaluate("Math.abs(window.scrollY - heldPosition) <= 1")
def test_wizard_local_stop_failure_preserves_edit_and_retry_feedback(subscription_ui):
ui, page = subscription_ui, subscription_ui["page"]
runtime = {"status": "ready", "context_length": 16384}
stops = []
def local_controls(route):
response = route.fetch()
body = response.text().replace('"supportsLocalRuntimeControls": false',
'"supportsLocalRuntimeControls": true')
route.fulfill(response=response, body=body)
def stop(route):
stops.append(route.request.method)
if len(stops) == 1:
route.fulfill(status=500, content_type="application/json",
body=json.dumps({"error": "Runtime could not be stopped"}))
else:
runtime["status"] = "offline"
route.fulfill(content_type="application/json", body='{"status":"stopped"}')
page.route("**/onboarding", local_controls)
page.route("**/api/local-model/status", lambda route: route.fulfill(
content_type="application/json", body=json.dumps(runtime)))
page.route("**/api/local-model/stop", stop)
_open_wizard(ui)
page.locator('[data-collapse="api-access"] > summary').click()
page.locator('[data-collapse="local-model"] > summary').click()
page.click("#wizard-local-start")
assert "Enter a local model source" in page.locator(".wizard-error").inner_text()
source = page.get_by_label("Model Source", exact=True)
source.fill("/tmp/owner-model.gguf")
assert page.locator(".wizard-error").inner_text() == ""
page.evaluate("window.heldLocalSource = document.querySelector('#local-source')")
page.click("#wizard-local-stop")
page.wait_for_function("document.querySelector('#wizard-local-test-result').textContent.startsWith('Stop failed:')")
assert source.input_value() == "/tmp/owner-model.gguf"
assert page.evaluate("heldLocalSource === document.querySelector('#local-source')")
assert page.locator("#wizard-local-status").inner_text() == "Status: Ready (ctx: 16384)"
assert page.locator("#wizard-local-stop").is_enabled()
assert page.locator("#wizard-local-test-result").is_visible()
assert page.locator("#wizard-local-test-result").get_attribute("role") == "status"
capture(page, "wizard-local-stop-failed")
page.click("#wizard-local-stop")
page.wait_for_function("document.querySelector('#wizard-local-status').textContent === 'Status: Offline'")
assert page.locator("#wizard-local-test-result").inner_text() == ""
assert page.locator("#wizard-local-test-result").is_hidden()
assert stops == ["POST", "POST"]
assert source.input_value() == "/tmp/owner-model.gguf"
capture(page, "wizard-local-stop-retried")
def test_wizard_fields_have_explicit_names_help_and_shared_family(subscription_ui):
page = _open_wizard(subscription_ui)
page.locator('[data-collapse="api-access"] > summary').click()
page.locator('[data-collapse="local-model"] > summary').click()
page.locator('[data-collapse="more-providers"] > summary').click()
for field in page.locator(".wizard-content input, .wizard-content select").all():
assert field.evaluate("el => el.labels.length > 0 || Boolean(el.getAttribute('aria-label'))")
assert field.evaluate("el => el.classList.contains('ui-control')")
hint = field.get_attribute("aria-describedby")
if hint:
assert page.locator(f"#{hint}").inner_text().strip()
assert page.get_by_role("button", name="Clear OpenAI API Key", exact=True).count() == 1
routing = page.get_by_role("group", name="Local routing")
assert routing.locator('[aria-pressed="true"]').count() == 1
routing.locator('[data-local-mode="fallback"]').click()
assert routing.locator('[data-local-mode="fallback"]').get_attribute("aria-pressed") == "true"
for _ in range(3):
page.click("#next-btn")
page.locator('[data-collapse="api-budget"] > summary').click()
for name in ("Total Budget (USD)", "Per-task Cost Cap (USD)"):
field = page.get_by_label(name, exact=True)
assert field.is_visible()
assert field.get_attribute("type") == "number"
assert field.evaluate("el => el.classList.contains('ui-control')")
assert page.locator(f"#{field.get_attribute('aria-describedby')}").inner_text().strip()

View file

@ -289,6 +289,7 @@ def test_project_sidebar_and_menu_static_contracts():
html = (root / "web" / "index.html").read_text(encoding="utf-8")
app = (root / "web" / "app.js").read_text(encoding="utf-8")
menu = (root / "web" / "modules" / "project_create.js").read_text(encoding="utf-8")
menu_behavior = (root / "web" / "modules" / "ui_interactions.js").read_text(encoding="utf-8")
chat = (root / "web" / "modules" / "chat.js").read_text(encoding="utf-8")
css = (root / "web" / "style.css").read_text(encoding="utf-8")
@ -325,9 +326,10 @@ def test_project_sidebar_and_menu_static_contracts():
assert 'role="menuitem" data-prm="rename"' in menu
assert 'role="menuitem" class="danger" data-prm="delete"' in menu
assert 'data-prm="hide"' not in menu
assert "binding = bindMenu(menu," in menu
for key in ("Escape", "ArrowDown", "ArrowUp", "Home", "End"):
assert key in menu
assert "window.innerWidth" in menu and "window.innerHeight" in menu
assert key in menu_behavior
assert "win.innerWidth" in menu_behavior and "win.innerHeight" in menu_behavior
assert "const PROJECT_NAME_MAX = 80" in menu
assert "newName.length > maxNameLength" in menu
assert 'maxlength="${maxNameLength}"' in menu

View file

@ -327,9 +327,10 @@ def test_settings_ui_renders_runtime_mode_and_skills_path():
def test_settings_js_reads_and_writes_phase2_keys():
src = (REPO / "web" / "modules" / "settings.js").read_text(encoding="utf-8")
src = "\n".join((REPO / "web" / "modules" / name).read_text(encoding="utf-8")
for name in ("settings.js", "settings_local_model.js"))
assert "OUROBOROS_RUNTIME_MODE" in src
assert "OUROBOROS_CONTEXT_MODE_DRAFT" in src
assert "OUROBOROS_CONTEXT_MODE'" in src
assert "OUROBOROS_SKILLS_REPO_PATH" in src
assert "['s-runtime-mode', 'OUROBOROS_RUNTIME_MODE', 'advanced']" in src
assert "['s-context-mode', 'OUROBOROS_CONTEXT_MODE', 'max']" in src

View file

@ -0,0 +1,81 @@
"""Saved repeated model contexts keep native incomplete numbers in the Settings draft."""
from __future__ import annotations
import json
import re
import os
from pathlib import Path
import pytest
from tests import test_subscription_role_routes_browser as roles
pytestmark = [pytest.mark.ui_browser, pytest.mark.serial]
subscription_ui = roles.subscription_ui
role_ui = roles.role_ui
@pytest.mark.parametrize('ordinal', [1, 2])
def test_saved_fallback_context_keeps_incomplete_number_through_stay_and_auto(role_ui, ordinal):
from playwright.sync_api import expect
ui = role_ui
roles.configure_mixed(ui)
ui['settings']['OUROBOROS_MODEL_FALLBACKS'] = 'owner/first, owner/second, owner/third'
ui['settings']['OUROBOROS_MODEL_CONTEXT_WINDOWS'] = {'fallback': [0, 0, 0]}
page = ui["page"]
with page.expect_response(lambda response: response.url.endswith("/api/model-catalog")):
roles.open_agents(ui)
page.wait_for_function("() => !document.querySelector('#btn-refresh-model-catalog').disabled")
page.locator('[data-settings-tab="models"]').click()
row = page.locator('[data-model-role-group="fallback"] .model-role-row').nth(ordinal)
row.locator('summary').click()
field = row.locator('[data-model-role-context]')
expect(field).to_have_value('')
assert not field.get_attribute('id').startswith('s-')
expect(page.locator('#settings-unsaved-indicator')).not_to_have_class(re.compile('is-visible'))
field.press('1')
field.press('e')
assert field.evaluate('input => input.validity.badInput && input.value === ""')
expect(page.locator('#settings-unsaved-indicator')).to_have_class(re.compile('is-visible'))
field.evaluate('input => { window.__fallbackDraftNode = input; }')
page.locator('[data-nav-page="chat"]').click()
expect(page.get_by_role('button', name='Stay', exact=True)).to_be_visible()
page.get_by_role('button', name='Stay', exact=True).click()
expect(page.locator('#page-settings')).to_be_visible()
assert field.evaluate('input => input === window.__fallbackDraftNode && input.validity.badInput')
page.locator('#btn-save-settings').click()
expect(field).to_have_attribute('aria-invalid', 'true')
assert not [path for path, _ in ui['posts'] if path == '/api/settings']
field.fill('')
assert not field.evaluate('input => input.validity.badInput')
expect(field).to_have_attribute('aria-invalid', 'false')
expect(page.locator('#settings-unsaved-indicator')).not_to_have_class(re.compile('is-visible'))
field.fill('32768')
with page.expect_response('**/api/settings'):
page.locator('#btn-save-settings').click()
saved = [body for path, body in ui['posts'] if path == '/api/settings']
assert len(saved) == 1
expected = [0, 0, 0]
expected[ordinal] = 32768
assert saved[0]['OUROBOROS_MODEL_CONTEXT_WINDOWS']['fallback'] == expected
def test_all_settings_number_fields_are_in_complete_raw_draft_selector(role_ui):
"""Enumerate actual consumers: settings/Agents numbers and all role-context rows."""
ui = role_ui
roles.configure_mixed(ui)
ui['settings']['OUROBOROS_MODEL_FALLBACKS'] = 'owner/first, owner/second, owner/third'
page = ui["page"]
with page.expect_response(lambda response: response.url.endswith("/api/model-catalog")):
roles.open_agents(ui)
page.wait_for_function("() => !document.querySelector('#btn-refresh-model-catalog').disabled")
selector = (Path(__file__).resolve().parents[1] / 'web/modules/settings.js').read_text().split('function snapshotSettingsDraft()', 1)[1].split("page.querySelectorAll('", 1)[1].split("')", 1)[0]
facts = page.evaluate('''selector => [...document.querySelectorAll('#page-settings input[type="number"]')]
.filter(input => !input.closest('[data-extension-settings-form]'))
.map(input => ({id:input.id, model_context:input.hasAttribute('data-model-role-context'),
selected:input.matches(selector)}))''', selector)
assert all(row['selected'] for row in facts), facts
assert sum(row['model_context'] for row in facts) >= 7
output = os.environ.get('OUROBOROS_UI_EVIDENCE_DIR')
if output:
Path(output).mkdir(parents=True, exist_ok=True)
(Path(output) / 'settings-number-consumers.json').write_text(json.dumps(facts, indent=2) + '\n')

View file

@ -21,12 +21,15 @@ def role_ui(subscription_ui):
ui["posts"].append(("/api/settings", payload))
ui["settings"].update(payload)
ui["fixture"]["preview"]["reviewer_slots"] = json.loads(payload["OUROBOROS_REVIEWER_SLOTS"])
result = {"ok": True, "saved": True}
result = {"status": "saved", "saved": True, "restart_required": False}
else:
result = ui["settings"]
route.fulfill(content_type="application/json", body=json.dumps(result))
ui["page"].route("**/api/settings", settings_route)
ui["page"].route("**/api/owner/runtime-mode", lambda route: route.fulfill(
content_type="application/json", body=json.dumps({"ok": True, "saved": True,
"runtime_mode": "advanced", "restart_required": False})))
return ui
@ -86,8 +89,10 @@ def test_reviewer_source_roundtrip_restores_its_own_model_and_account(role_ui):
route.select_option('subscription:opaque-source')
page.locator('[data-advisory-row]').scroll_into_view_if_needed()
capture(page, "reviewer-source-roundtrip-restored")
page.locator('[data-slot-custom-api]').fill('temporary-before-reload')
with page.expect_response('**/api/reviewer-slots'):
page.locator('#btn-reload-settings').click()
page.get_by_role('button', name='Discard and continue', exact=True).click()
page.locator('[data-advisory-route]').select_option('api')
assert page.locator('[data-advisory-api-model]').input_value() == ''

View file

@ -0,0 +1,594 @@
"""Real Files/Project consumers with deterministic gateway replies and no paid work."""
from __future__ import annotations
import base64
import json
import os
import re
from pathlib import Path
from urllib.parse import parse_qs, urlparse
import pytest
from tests.test_ui_smoke_playwright import direct_server_with_data # noqa: F401
pytestmark = [pytest.mark.ui_browser, pytest.mark.serial]
def _browser(pw, engine):
try:
return getattr(pw, engine).launch(headless=True)
except Exception as exc:
if "Executable doesn't exist" in str(exc):
pytest.skip(f"Installed {engine} unavailable: {exc}")
raise
def _capture(page, name):
evidence = os.environ.get("OUROBOROS_BROWSER_EVIDENCE_OUT")
if evidence:
destination = Path(evidence)
destination.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(destination / f"{name}.png"))
def _files_gateway(page):
page.on("pageerror", lambda error: print(f"Files browser error: {error}"))
fixture = {
"writes": [], "pending": [], "hold_write": False,
"write_error": "", "list_error": "",
"uploads": 0, "upload_error": "Upload refused.", "directories": [], "deleted": [],
"hold_navigation": False, "pending_navigation": [],
"hold_operation": "", "pending_operations": [], "transfers": [], "list_paths": [], "upload_paths": [],
}
root = "/workspace/" + "long-project-name-" * 5
names = ["sample.txt", "other.txt", "unreadable.txt", "picture.png", "paper.pdf", "archive.bin", "large.txt"]
def reply(route):
url = urlparse(route.request.url)
path = parse_qs(url.query).get("path", ["."])[0]
if url.path.endswith("/list"):
fixture["list_paths"].append(path)
if fixture["list_error"]:
route.fulfill(status=503, json={"error": fixture["list_error"]})
return
entries = ([{"name": name, "path": name, "type": "file", "size": 31} for name in names]
+ [{"name": "docs", "path": "docs", "type": "dir"}]) if path == "." else []
data = {
"path": path, "parent_path": ".", "root_path": root,
"display_path": root if path == "." else f"{root}/{path}",
"breadcrumb": [{"name": "Workspace", "path": "."}], "entries": entries,
}
if fixture["hold_navigation"] and path == "docs":
fixture["pending_navigation"].append((route, data))
else:
route.fulfill(json=data)
elif url.path.endswith("/read"):
if path == "unreadable.txt":
route.fulfill(status=403, json={"error": "File is unavailable."})
return
payload = {"path": path, "name": path, "size": 31, "root_path": root,
"display_path": f"{root}/{path}", "is_text": True,
"content": "Original file contents\nSecond line\n"}
if path in {"picture.png", "paper.pdf", "archive.bin"}:
payload.update(is_text=False, content="")
if path == "picture.png":
payload.update(is_image=True, media_type="image/png", content_url="/api/files/content?path=picture.png")
if path == "paper.pdf":
payload.update(is_pdf=True, content_url="/api/files/content?path=paper.pdf")
if path == "large.txt":
payload.update(truncated=True)
if fixture["hold_navigation"] and path == "other.txt":
fixture["pending_navigation"].append((route, payload))
else:
route.fulfill(json=payload)
elif url.path.endswith("/write"):
payload = route.request.post_data_json
fixture["writes"].append(payload)
if fixture["hold_write"]:
fixture["pending"].append(route)
elif fixture["write_error"]:
route.fulfill(status=400, json={"error": fixture["write_error"]})
else:
route.fulfill(json={"path": payload["path"], "name": payload["path"].split("/")[-1],
"display_path": f'{root}/{payload["path"]}', "size": len(payload["content"])})
elif url.path.endswith("/mkdir"):
fixture["directories"].append(route.request.post_data_json)
route.fulfill(json={"path": "created-dir", "name": "created-dir", "type": "dir"})
elif url.path.endswith("/upload"):
fixture["uploads"] += 1
body = route.request.post_data_buffer.decode("utf-8")
fixture["upload_paths"].append(re.search(r'name="path"\r\n\r\n([^\r]*)', body).group(1))
if fixture["upload_error"]:
route.fulfill(status=400, json={"error": fixture["upload_error"]})
else:
names.append("upload.txt")
data = {"path": "upload.txt", "name": "upload.txt", "size": 11}
if fixture["hold_operation"] == "upload":
fixture["pending_operations"].append((route, data))
else:
route.fulfill(json=data)
elif url.path.endswith("/transfer"):
fixture["transfers"].append(route.request.post_data_json)
data = {"path": "other-copy.txt", "name": "other-copy.txt", "type": "file"}
if fixture["hold_operation"] == "transfer":
fixture["pending_operations"].append((route, data))
else:
route.fulfill(json=data)
elif url.path.endswith("/delete"):
deleted = route.request.post_data_json["path"]
fixture["deleted"].append(deleted)
names.remove(deleted)
data = {"path": deleted, "type": "file", "ok": True}
if fixture["hold_operation"] == "delete":
fixture["pending_operations"].append((route, data))
else:
route.fulfill(json=data)
elif url.path.endswith("/content"):
if path == "picture.png":
route.fulfill(content_type="image/png", body=base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
))
else:
route.fulfill(status=200, body="", content_type="application/pdf")
else:
route.fulfill(status=404, json={"error": "Unexpected file operation in this case."})
page.route("**/api/files/**", reply)
return fixture
def _same_draft(page, value, selection=None):
assert page.evaluate("window.filesEditor === document.querySelector('.files-editor')")
assert page.locator(".files-editor").input_value() == value
if selection:
assert page.locator(".files-editor").evaluate("el => [el.selectionStart, el.selectionEnd]") == selection
def _drop_file(page):
page.evaluate("""() => {
const transfer = new DataTransfer();
transfer.items.add(new File(['upload body'], 'upload.txt', {type:'text/plain'}));
document.querySelector('.files-layout').dispatchEvent(new DragEvent('drop', {
bubbles:true, cancelable:true, dataTransfer:transfer,
}));
}""")
@pytest.mark.parametrize("engine", ["chromium", "webkit"])
def test_files_retains_document_and_submits_one_write(direct_server_with_data, engine): # noqa: F811
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
with sync_playwright() as pw:
browser = _browser(pw, engine)
try:
page = browser.new_page(viewport={"width": 1280, "height": 800})
fixture = _files_gateway(page)
page.goto(direct_server_with_data["url"] + "/#files", wait_until="domcontentloaded")
page.locator(".files-entry").filter(has_text="sample.txt").click()
editor = page.get_by_role("textbox", name="File contents", exact=True)
editor.fill("Draft before save\nKeep this text.")
editor.evaluate("el => { window.filesEditor = el; el.setSelectionRange(2, 8); }")
page.locator("#files-refresh").click()
expect(page.locator(".files-entry.selected")).to_contain_text("sample.txt")
_same_draft(page, "Draft before save\nKeep this text.", [2, 8])
expect(page.locator("#files-save")).to_be_enabled()
for action in ["copy", "move"]:
page.locator(".files-entry").filter(has_text="other.txt").click(button="right")
page.locator(f'#files-context-menu [data-action="{action}"]').click()
expect(page.locator("#files-preview-status")).to_contain_text(f"{action.title()} ready")
_same_draft(page, "Draft before save\nKeep this text.", [2, 8])
expect(page.locator(".files-entry.selected")).to_contain_text("sample.txt")
expect(page.locator("#files-save")).to_be_enabled()
row = page.locator(".files-entry").filter(has_text="other.txt")
row.focus()
row.press("Shift+F10")
expect(page.get_by_role("menuitem", name="Download", exact=True)).to_be_focused()
page.keyboard.press("ArrowDown")
expect(page.get_by_role("menuitem", name="Copy", exact=True)).to_be_focused()
page.keyboard.press("End")
expect(page.get_by_role("menuitem", name="Delete", exact=True)).to_be_focused()
page.keyboard.press("Home")
expect(page.get_by_role("menuitem", name="Download", exact=True)).to_be_focused()
page.keyboard.press("Escape")
expect(page.locator("#files-context-menu")).to_be_hidden()
expect(row).to_be_focused()
row.press("Shift+F10")
page.keyboard.press("End")
page.keyboard.press("Enter")
page.get_by_role("button", name="Stay", exact=True).click()
expect(row).to_be_focused()
_same_draft(page, "Draft before save\nKeep this text.")
page.locator("#nav-projects-add").click()
page.locator("[data-np-name]").fill("Keep name")
page.locator("[data-np-name]").press("Home")
page.locator("[data-np-name]").press("Delete")
expect(page.locator(".confirm-dialog-backdrop")).to_have_count(0)
expect(page.locator(".new-project-backdrop")).to_be_visible()
page.keyboard.press("Escape")
_same_draft(page, "Draft before save\nKeep this text.")
page.evaluate("""() => {
window.downloadCalls = [];
window.pywebview = {api: {download_file_to_downloads: async (...args) => {
window.downloadCalls.push(args); return {ok:true, path:'/Downloads/sample.txt'};
}}};
}""")
page.locator("#files-download").click()
expect(page.locator("#files-preview-status")).to_contain_text("saved to /Downloads/sample.txt")
page.locator("#files-open-external").click()
expect(page.locator("#files-preview-status")).to_contain_text("Opened sample.txt externally")
_same_draft(page, "Draft before save\nKeep this text.", [2, 8])
assert page.evaluate("window.downloadCalls.map(call => call[2])") == [False, True]
page.locator("#files-new-dir").click()
page.locator("[data-confirm-input]").fill("created-dir")
page.get_by_role("button", name="Create", exact=True).click()
expect(page.locator("#files-preview-status")).to_contain_text("Directory created-dir created")
_same_draft(page, "Draft before save\nKeep this text.", [2, 8])
assert fixture["directories"] == [{"path": ".", "name": "created-dir"}]
_drop_file(page)
page.get_by_role("button", name="Discard", exact=True).click()
expect(page.locator("#files-preview-status")).to_contain_text("Upload refused")
_same_draft(page, "Draft before save\nKeep this text.")
assert fixture["uploads"] == 1
page.locator(".files-entry").filter(has_text="docs").click()
page.get_by_role("button", name="Stay", exact=True).click()
_same_draft(page, "Draft before save\nKeep this text.", [2, 8])
expect(page.locator(".files-entry.selected")).to_contain_text("sample.txt")
expect(page.locator("#files-save")).to_be_enabled()
page.locator(".files-entry").filter(has_text="unreadable.txt").click()
page.get_by_role("button", name="Discard", exact=True).click()
expect(page.locator("#files-preview-status")).to_contain_text("File is unavailable")
_same_draft(page, "Draft before save\nKeep this text.")
expect(page.locator(".files-entry.selected")).to_contain_text("sample.txt")
page.locator("#files-new-file").click()
page.get_by_role("button", name="Discard", exact=True).click()
name = page.get_by_role("textbox", name="File name", exact=True)
name.fill("missing/notes.txt")
editor.fill("New file draft")
editor.evaluate("el => { window.filesEditor = el; el.setSelectionRange(3, 6); }")
fixture["write_error"] = "Parent directory not found."
page.locator("#files-save").click()
expect(page.locator("#files-preview-status")).to_contain_text("Parent directory not found")
_same_draft(page, "New file draft", [3, 6])
assert name.input_value() == "missing/notes.txt"
expect(name).to_be_enabled()
expect(page.locator("#files-save")).to_be_enabled()
_capture(page, f"files-{engine}-failed-save")
fixture["write_error"] = ""
fixture["hold_write"] = True
name.fill("notes.txt")
editor.focus()
editor.press("Control+s")
expect(page.locator("#files-save")).to_be_disabled()
expect(name).to_be_disabled()
editor.press("Control+s")
editor.evaluate("el => el.dispatchEvent(new KeyboardEvent('keydown', {key:'s', ctrlKey:true, repeat:true, bubbles:true, cancelable:true}))")
editor.fill("Newer text typed while saving")
assert len(fixture["writes"]) == 2
assert len(fixture["pending"]) == 1
assert fixture["writes"][-1] == {"path": "notes.txt", "content": "New file draft", "create": True}
fixture["pending"].pop().fulfill(json={"path": "notes.txt", "name": "notes.txt", "size": 14})
expect(page.locator("#files-preview-status")).to_contain_text("Newer edits are still unsaved")
_same_draft(page, "Newer text typed while saving")
expect(page.locator("#files-save")).to_be_enabled()
assert name.count() == 0
fixture["hold_write"] = False
editor.press("Meta+s")
expect(page.locator("#files-preview-status")).to_have_text("Saved.")
expect(page.locator("#files-save")).to_be_disabled()
assert len(fixture["writes"]) == 3
assert fixture["writes"][-1] == {"path": "notes.txt", "content": "Newer text typed while saving", "create": False}
editor.press("Meta+s")
assert len(fixture["writes"]) == 3
_same_draft(page, "Newer text typed while saving")
editor.fill("Draft survives list error")
fixture["list_error"] = "Directory temporarily unavailable."
page.locator("#files-refresh").click()
expect(page.locator("#files-preview-status")).to_contain_text("Directory temporarily unavailable")
_same_draft(page, "Draft survives list error")
expect(page.locator("#files-save")).to_be_enabled()
page.locator('[data-nav-page="dashboard"]').click()
page.get_by_role("button", name="Stay", exact=True).click()
expect(page.locator("#page-files")).to_have_class(re.compile(r"\bactive\b"))
_same_draft(page, "Draft survives list error")
fixture["list_error"] = ""
fixture["upload_error"] = ""
_drop_file(page)
page.get_by_role("button", name="Discard", exact=True).click()
expect(page.locator("#files-preview-meta")).to_contain_text("Upload complete")
uploaded = page.locator(".files-entry").filter(has_text="upload.txt")
uploaded.click()
expect(page.locator(".files-editor")).to_have_value("Original file contents\nSecond line\n")
expect(page.locator("#files-save")).to_be_disabled()
uploaded.focus()
uploaded.press("Delete")
page.get_by_role("button", name="Delete", exact=True).click()
expect(uploaded).to_have_count(0)
expect(page.locator("#files-preview-status")).to_contain_text("File deleted")
expect(page.locator("#files-download")).to_be_hidden()
expect(page.locator("#files-save")).to_be_hidden()
assert fixture["uploads"] == 2
assert fixture["deleted"] == ["upload.txt"]
page.locator(".files-entry").filter(has_text="sample.txt").click()
editor.fill("Draft before navigation")
editor.evaluate("el => { window.filesEditor = el; }")
fixture["hold_navigation"] = True
for target in ["other.txt", "docs"]:
page.locator(".files-entry").filter(has_text=target).click()
page.get_by_role("button", name="Discard", exact=True).click()
editor.fill(f"New edits while {target} loads")
assert len(fixture["pending_navigation"]) == 1
pending, payload = fixture["pending_navigation"].pop()
pending.fulfill(json=payload)
expect(page.locator("#files-preview-status")).to_contain_text("New edits kept")
_same_draft(page, f"New edits while {target} loads")
expect(page.locator(".files-entry.selected")).to_contain_text("sample.txt")
expect(page.locator("#files-save")).to_be_enabled()
finally:
browser.close()
@pytest.mark.parametrize("engine", ["chromium", "webkit"])
def test_files_late_mutations_keep_newer_work(direct_server_with_data, engine): # noqa: F811
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
with sync_playwright() as pw:
browser = _browser(pw, engine)
try:
for operation in ["upload", "transfer", "delete"]:
for newer in ["typing", "selection", "directory"]:
page = browser.new_page(viewport={"width": 1280, "height": 800})
fixture = _files_gateway(page)
fixture.update(hold_operation=operation, upload_error="")
page.goto(direct_server_with_data["url"] + "/#files", wait_until="domcontentloaded")
page.locator(".files-entry").filter(has_text="sample.txt").click()
editor = page.locator(".files-editor")
editor.fill("Initial draft")
if operation == "upload":
_drop_file(page)
else:
page.locator(".files-entry").filter(has_text="other.txt").click(button="right")
if operation == "transfer":
page.locator('#files-context-menu [data-action="copy"]').click()
page.locator("#files-paste").click()
else:
page.locator('#files-context-menu [data-action="delete"]').click()
page.get_by_role("button", name="Discard", exact=True).click()
if operation == "delete":
page.get_by_role("button", name="Delete", exact=True).click()
expect(page.locator(".confirm-dialog-backdrop")).to_have_count(0)
assert len(fixture["pending_operations"]) == 1
if newer == "selection":
page.locator(".files-entry").filter(has_text="large.txt").click()
page.get_by_role("button", name="Discard", exact=True).click()
expect(page.locator("#files-preview-path")).to_contain_text("large.txt")
elif newer == "directory":
page.locator(".files-entry").filter(has_text="docs").click()
page.get_by_role("button", name="Discard", exact=True).click()
expect(page.locator("#files-preview-path")).to_contain_text("/docs")
else:
editor.fill("Newer input while action is pending")
editor.evaluate("el => {window.filesEditor = el; el.setSelectionRange(4, 9);}")
page.locator("#files-preview-content").evaluate("el => {window.pendingPreview = el.firstChild;}")
expected_path = page.locator("#files-preview-path").inner_text()
list_count = len(fixture["list_paths"])
pending, data = fixture["pending_operations"].pop()
pending.fulfill(json=data)
expected_status = {"upload": "Upload complete", "transfer": "Copied", "delete": "File deleted"}[operation]
expect(page.locator("#files-preview-status")).to_contain_text(expected_status)
assert page.locator("#files-preview-path").inner_text() == expected_path
assert page.evaluate("window.pendingPreview === document.querySelector('#files-preview-content').firstChild")
if newer == "typing":
_same_draft(page, "Newer input while action is pending", [4, 9])
expect(page.locator("#files-save")).to_be_enabled()
else:
assert len(fixture["list_paths"]) == list_count, "Completion must not cancel/navigate the newer view"
if operation == "transfer":
assert fixture["transfers"] == [{"source_path": "other.txt", "destination_dir": ".", "mode": "copy"}]
if operation == "upload":
assert fixture["uploads"] == 1
assert fixture["upload_paths"] == ["."]
if operation == "delete":
assert fixture["deleted"] == ["other.txt"]
if operation == "upload" and newer == "typing":
_capture(page, f"files-{engine}-late-upload-draft")
page.close()
finally:
browser.close()
@pytest.mark.parametrize("engine", ["chromium", "webkit"])
def test_files_preview_modes_keep_write_capability_honest(direct_server_with_data, engine): # noqa: F811
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
with sync_playwright() as pw:
browser = _browser(pw, engine)
try:
page = browser.new_page(viewport={"width": 1280, "height": 800})
fixture = _files_gateway(page)
page.goto(direct_server_with_data["url"] + "/#files", wait_until="domcontentloaded")
for filename, selector in [("picture.png", ".files-preview-image"), ("paper.pdf", ".files-preview-frame"),
("archive.bin", "#files-preview-content"), ("large.txt", "#files-preview-content")]:
page.locator(".files-entry").filter(has_text=filename).click()
expect(page.locator("#files-preview-path")).to_contain_text(filename)
expect(page.locator(selector)).to_be_visible()
expect(page.locator("#files-save")).to_be_hidden()
expect(page.locator("#files-download")).to_be_visible()
expect(page.locator("#files-open-external")).to_be_visible()
assert page.locator(".files-editor").count() == 0
assert fixture["writes"] == []
assert "truncated" in page.locator("#files-preview-meta").inner_text()
finally:
browser.close()
@pytest.mark.parametrize("engine", ["chromium", "webkit"])
def test_project_sources_keep_selected_target(direct_server_with_data, engine): # noqa: F811
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
with sync_playwright() as pw:
browser = _browser(pw, engine)
try:
page = browser.new_page(viewport={"width": 1280, "height": 800})
page.goto(direct_server_with_data["url"], wait_until="domcontentloaded")
page.wait_for_selector("#nav-projects-add")
page.evaluate("""async () => {
window.projectModule = await import('/static/modules/project_create.js');
window.projectPayloads = [];
window.projectResult = 'pending';
window.openTestProject = () => window.projectModule.openNewProjectDialog({
apiClient: {
fsDirs: async (path) => ({path: path || '/workspace', parent: path && path !== '/workspace' ? '/workspace' : '',
dirs: path && path !== '/workspace' ? [] : [{name:'Chosen folder', path:'/workspace/chosen'}, {name:'Other folder', path:'/workspace/other'}]}),
projectCreate: async payload => { window.projectPayloads.push(payload); return {project: {id:'created', name:payload.name}}; },
},
}).then(value => { window.projectResult = value; });
}""")
for source in ["fileless", "genesis", "attach", "clone"]:
opener = page.locator("#nav-projects-add")
opener.focus()
page.evaluate("window.openTestProject(); undefined")
expect(page.locator("[data-np-name]")).to_be_focused()
close = page.locator('.new-project-dialog [aria-label="Close"]')
close.focus()
page.keyboard.press("Shift+Tab")
expect(page.locator("[data-np-create]")).to_be_focused()
page.keyboard.press("Tab")
expect(close).to_be_focused()
page.locator("[data-np-name]").fill("Scoped project")
page.locator(f'input[name="np-source"][value="{source}"]').check()
expected = {"name": "Scoped project"}
if source == "genesis":
expected["with_workspace"] = True
elif source == "attach":
page.get_by_role("button", name="Chosen folder", exact=True).click()
page.get_by_role("button", name="Select this folder", exact=True).click()
page.get_by_role("button", name=".. (up)", exact=True).click()
page.get_by_role("button", name="Other folder", exact=True).click()
expect(page.locator("[data-np-path]")).to_contain_text("/workspace/other")
expect(page.locator("[data-np-selected]")).to_have_text("Selected folder: /workspace/chosen")
page.locator("[data-np-initgit]").check()
expected.update(path="/workspace/chosen", init_git=True)
_capture(page, f"project-{engine}-selected-target")
elif source == "clone":
page.locator("[data-np-giturl]").fill("https://example.com/repository.git")
expected["git_url"] = "https://example.com/repository.git"
page.locator("[data-np-create]").click()
expect(page.locator(".new-project-backdrop")).to_have_count(0)
expect(opener).to_be_focused()
assert page.evaluate("window.projectPayloads.at(-1)") == expected
assert len(page.evaluate("window.projectPayloads")) == 4
page.evaluate("window.openTestProject(); undefined")
page.keyboard.press("Escape")
expect(page.locator(".new-project-backdrop")).to_have_count(0)
assert page.evaluate("window.projectResult") is None
expect(opener).to_be_focused()
assert len(page.evaluate("window.projectPayloads")) == 4
page.evaluate("""() => window.projectModule.openProjectRowMenu({id:'example', name:'Scoped project'}, {
anchorEl: document.querySelector('#nav-projects-add'), apiClient: {},
})""")
expect(page.get_by_role("menuitem", name="Rename…", exact=True)).to_be_focused()
page.keyboard.press("Enter")
expect(page.locator("[data-confirm-input]")).to_be_focused()
page.keyboard.press("Escape")
expect(opener).to_be_focused()
finally:
browser.close()
def _hit_target(page, selector):
return page.locator(selector).evaluate("""el => {
const rect = el.getBoundingClientRect();
const hit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2);
return {visible: rect.x >= 0 && rect.y >= 0 && rect.right <= innerWidth && rect.bottom <= innerHeight,
hit: !!hit && (hit === el || el.contains(hit)), rect: {x:rect.x, y:rect.y, width:rect.width, height:rect.height}};
}""")
def _resize(page, width, height):
page.set_viewport_size({"width": width, "height": height})
page.evaluate("""async () => {
await new Promise(requestAnimationFrame);
await Promise.all(document.getAnimations().filter(animation =>
animation.effect?.getComputedTiming().iterations !== Infinity
).map(animation => animation.finished.catch(() => {})));
}""")
@pytest.mark.parametrize("engine", ["chromium", "webkit"])
def test_files_project_actions_fit_available_viewport(direct_server_with_data, engine): # noqa: F811
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
with sync_playwright() as pw:
browser = _browser(pw, engine)
try:
page = browser.new_page(viewport={"width": 1280, "height": 800})
_files_gateway(page)
page.goto(direct_server_with_data["url"] + "/#files", wait_until="domcontentloaded")
page.locator(".files-entry").filter(has_text="sample.txt").click()
page.locator(".files-editor").fill("Editable content with a long file path")
failures = []
for width, height in [(320, 640), (390, 844), (640, 360), (641, 640), (760, 640),
(768, 640), (980, 700), (981, 700), (1280, 800)]:
_resize(page, width, height)
for selector in ["#files-save", "#files-download", "#files-open-external"]:
result = _hit_target(page, selector)
if not result["visible"] or not result["hit"]:
failures.append({"surface": "Files", "width": width, "height": height, "selector": selector, **result})
editable = page.locator('.files-editor').evaluate("""el => {
let rect = el.getBoundingClientRect();
let top = Math.max(0, rect.top), bottom = Math.min(innerHeight, rect.bottom);
for (let parent = el.parentElement; parent; parent = parent.parentElement) {
if (getComputedStyle(parent).overflowY !== 'visible') {
const box = parent.getBoundingClientRect();
top = Math.max(top, box.top); bottom = Math.min(bottom, box.bottom);
}
}
return {visibleHeight: bottom - top, lineHeight: parseFloat(getComputedStyle(el).lineHeight)};
}""")
if editable['visibleHeight'] < editable['lineHeight']:
failures.append({"surface": "Files editor", "width": width, "height": height, **editable})
if width in {320, 390}:
_capture(page, f"files-{engine}-{width}-actions")
# Dialog content can scroll; the dialog frame and its actions stay in view.
page.evaluate("""async () => {
const module = await import('/static/modules/project_create.js');
window.openGeometryProject = () => module.openNewProjectDialog({apiClient:{
fsDirs: async () => ({path:'/workspace/' + 'long-folder-name-'.repeat(8), dirs:[]}),
}});
}""")
for width, height in [(320, 640), (390, 844), (640, 360)]:
_resize(page, width, height)
page.evaluate("window.openGeometryProject(); undefined")
page.locator('[name="np-source"][value="attach"]').check()
expect(page.locator("[data-np-path]")).to_contain_text("long-folder-name")
for selector in [".new-project-dialog", "[data-np-create]", ".new-project-dialog .marketplace-modal-actions [data-np-cancel]"]:
result = _hit_target(page, selector)
if not result["visible"] or (selector != ".new-project-dialog" and not result["hit"]):
failures.append({"surface": "Project", "width": width, "height": height, "selector": selector, **result})
_capture(page, f"project-{engine}-{width}x{height}-actions")
page.keyboard.press("Escape")
assert not failures, json.dumps(failures, indent=2)
finally:
browser.close()

View file

@ -0,0 +1,344 @@
"""Settings draft and action receipts through the production browser UI."""
from __future__ import annotations
import re
import json
import os
from pathlib import Path
import pytest
from tests import test_subscription_setup_browser as setup_browser
subscription_ui = setup_browser.subscription_ui
pytest_plugins = ("tests.test_ui_smoke_playwright",)
@pytest.mark.serial
@pytest.mark.ui_browser
@pytest.mark.parametrize("engine", ["chromium", "webkit"])
def test_settings_complete_draft_validation_and_local_stop(direct_server_with_data, engine):
from playwright.sync_api import expect, sync_playwright
saves = []
owner_writes = []
stop_requests = []
delayed_reads = []
hold_read = False
receipt = {"status": 400, "body": {"saved": False, "error": "Synthetic validation refusal"}}
with sync_playwright() as pw:
browser = getattr(pw, engine).launch()
try:
page = browser.new_page(viewport={"width": 1280, "height": 900})
def settings_route(route):
if route.request.method == "POST":
saves.append(route.request.post_data_json)
route.fulfill(status=receipt["status"], json=receipt["body"])
return
response = route.fetch()
data = response.json()
data["TEST_ORIGINAL"] = "abcdefgh..."
data.setdefault("_meta", {})["custom_secret_keys"] = ["TEST_ORIGINAL"]
if hold_read:
delayed_reads.append((route, data))
page.evaluate('window.__heldSettingsReadReady = true')
return
route.fulfill(response=response, json=data)
def owner_route(route):
owner_writes.append(route.request.url)
route.fulfill(json={"ok": True, "runtime_mode": "light", "restart_required": False})
def stop_route(route):
stop_requests.append(route.request.method)
route.fulfill(status=500, json={"error": "Synthetic Stop refusal"})
page.route("**/api/settings", settings_route)
page.route("**/api/owner/**", owner_route)
page.route("**/api/local-model/status", lambda route: route.fulfill(json={
"status": "ready", "runtime_status": "available", "context_length": 8192,
}))
page.route("**/api/local-model/stop", stop_route)
page.goto(direct_server_with_data["url"], wait_until="domcontentloaded")
page.wait_for_selector("#page-chat", timeout=30_000)
page.click('[data-nav-page="settings"]')
expect(page.locator("#btn-save-settings")).to_be_enabled(timeout=30_000)
page.click('[data-settings-tab="secrets"]')
page.click("#btn-add-custom-secret")
new_row = page.locator('[data-custom-secret-row]').last
key = new_row.locator('[data-custom-secret-key]')
value = new_row.locator('[data-custom-secret-value]')
expect(key).to_be_focused()
expect(page.locator("#settings-unsaved-indicator")).to_have_class(
re.compile("is-visible"))
page.evaluate("window.dispatchEvent(new CustomEvent('ouro:settings-updated', {detail: {source: 'test'}}))")
expect(page.locator('[data-custom-secret-row]')).to_have_count(2)
key.fill("bad!")
value.fill("kept secret draft")
expect(key).not_to_have_attribute("aria-invalid", "true")
page.evaluate("""() => {
const input = document.getElementById('s-workers');
input.value = '0'; input.dispatchEvent(new Event('input', {bubbles: true}));
}""")
page.evaluate("window.__settingsDraftNode = document.querySelectorAll('[data-custom-secret-value]')[1]")
page.click("#btn-save-settings")
expect(key).to_have_attribute("aria-invalid", "true")
expect(page.locator("#s-workers")).to_have_attribute("aria-invalid", "true")
assert saves == [] and owner_writes == []
assert key.get_attribute("aria-describedby") == key.get_attribute("id") + "-error"
assert page.evaluate("window.__settingsDraftNode === document.querySelectorAll('[data-custom-secret-value]')[1]")
key.fill("TEST_NEW")
expect(key).not_to_have_attribute("aria-invalid", "true")
expect(page.locator("#" + key.get_attribute("id") + "-error")).to_be_hidden()
expect(value).to_have_value("kept secret draft")
new_row.locator('[data-row-secret-toggle]').click()
expect(value).to_have_attribute("type", "text")
new_row.locator('[data-row-secret-clear]').click()
expect(value).to_have_value("")
expect(value).to_have_attribute("data-force-clear", "1")
expect(value).to_have_attribute("type", "password")
value.fill("kept secret draft")
expect(value).not_to_have_attribute("data-force-clear", "1")
page.click("#btn-save-settings")
assert saves == [], "a corrected key cannot bypass another invalid field"
page.evaluate("""() => {
const input = document.getElementById('s-workers');
input.value = '1'; input.dispatchEvent(new Event('input', {bubbles: true}));
}""")
expect(page.locator("#s-workers")).not_to_have_attribute("aria-invalid", "true")
# A server refusal retains the entire editor and the new row.
page.click("#btn-save-settings")
expect(page.locator("#settings-status")).to_contain_text("Settings were not saved")
assert len(saves) == 1 and saves[0]["TEST_NEW"] == "kept secret draft"
assert "TEST_ORIGINAL" not in saves[0], "the unchanged mask is not a new credential"
expect(value).to_have_value("kept secret draft")
receipt.update(status=500, body={"saved": True, "error": "Synthetic post-commit failure"})
page.click("#btn-save-settings")
expect(page.locator("#settings-status")).to_contain_text("Settings were saved, but a later step failed")
expect(value).to_have_value("kept secret draft")
assert len(saves) == 2 and owner_writes == []
page.click("#btn-reload-settings")
page.get_by_role("button", name="Stay", exact=True).click()
expect(value).to_have_value("kept secret draft")
# A timed-out writer cannot invite a duplicate Save of an unknown outcome.
receipt.update(status=503, body={"saved": None, "error": "Synthetic writer timeout"})
page.click("#btn-save-settings")
expect(page.locator("#settings-status")).to_contain_text("Save outcome unknown")
expect(page.locator("#btn-save-settings")).to_be_disabled()
expect(value).to_have_value("kept secret draft")
assert len(saves) == 3 and owner_writes == []
page.evaluate("document.getElementById('btn-save-settings').click()")
assert len(saves) == 3
evidence = os.environ.get("OUROBOROS_UI_EVIDENCE_OUT")
if evidence:
Path(evidence).mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(Path(evidence) / f"settings-draft-{engine}.png"))
page.click("#btn-reload-settings")
page.locator("[data-confirm-ok]").click()
expect(page.locator("#btn-save-settings")).to_be_enabled()
expect(page.locator('[data-custom-secret-row]')).to_have_count(1)
original = page.locator('[data-custom-secret-row]')
original.locator('[data-custom-secret-key]').fill("TEST_RENAMED")
page.click("#btn-save-settings")
expect(original.locator('[data-custom-secret-value]')).to_have_attribute("aria-invalid", "true")
assert len(saves) == 3
original.locator('[data-custom-secret-remove]').click()
receipt.update(status=200, body={"status": "saved", "next_task_changed": True})
page.click("#btn-save-settings")
expect(page.locator("#settings-status")).to_contain_text("Settings saved")
assert saves[-1]["TEST_ORIGINAL"] == ""
assert "TEST_RENAMED" not in saves[-1]
# A response begun while clean cannot replace a new draft typed during the read.
page.click('[data-settings-tab="advanced"]')
hold_read = True
with page.expect_request(lambda request: request.url.endswith('/api/settings') and request.method == 'GET'):
page.evaluate("window.dispatchEvent(new CustomEvent('ouro:settings-updated', {detail: {source: 'test'}}))")
repo = page.locator("#s-gh-repo")
repo.fill("owner/edited-during-refresh")
page.wait_for_function('window.__heldSettingsReadReady === true')
assert delayed_reads
page.evaluate("window.__settingsRepoNode = document.getElementById('s-gh-repo')")
hold_read = False
for route, data in delayed_reads:
route.fulfill(json=data)
delayed_reads.clear()
page.evaluate("() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))")
expect(repo).to_have_value("owner/edited-during-refresh")
assert page.evaluate("window.__settingsRepoNode === document.getElementById('s-gh-repo')")
# Stop's HTTP failure stays beside the action, without rewriting confirmed state.
expect(page.locator("#btn-local-stop")).to_be_enabled(timeout=10_000)
page.click("#btn-local-stop")
expect(page.locator("#local-model-action-status")).to_contain_text("Stop failed: Synthetic Stop refusal")
assert page.evaluate("""() => {
const result = document.getElementById('local-model-action-status').getBoundingClientRect();
const viewport = document.querySelector('.settings-scroll').getBoundingClientRect();
return result.top >= viewport.top && result.bottom <= viewport.bottom + 1;
}""")
expect(page.locator("#local-model-status")).to_contain_text("Ready")
expect(page.locator("#btn-local-stop")).to_be_enabled()
assert stop_requests == ["POST"]
if evidence:
page.screenshot(path=str(Path(evidence) / f"settings-stop-{engine}.png"))
# Confirmed page leave also discards the reviewer editor, before any future GET.
page.click('[data-settings-tab="agents"]')
reviewer = page.locator('[data-slot-custom-api]').first
saved_model = reviewer.input_value()
reviewer.fill("test/unsaved-reviewer")
page.click('[data-nav-page="chat"]')
page.locator('[data-confirm-ok]').click()
expect(reviewer).to_have_value(saved_model)
finally:
browser.close()
@pytest.mark.serial
@pytest.mark.ui_browser
def test_initial_settings_document_survives_early_edit_while_enrichment_waits(subscription_ui):
"""Hold actual optional reads until after an edit; no timing race or runtime."""
from playwright.sync_api import expect
ui, pending = subscription_ui, {"reviewers": [], "status": [], "catalog": []}
page = ui["page"]
ui["settings"]["OUROBOROS_MODEL"] = "claudexor::opaque-source=gpt-test"
ui["fixture"]["catalog"]["model_sources"] = [
{"id": "opaque-source", "label": "Managed models", "credentialHarness": "codex"},
]
page.route("**/api/reviewer-slots", lambda route: pending["reviewers"].append(route))
page.route("**/api/claudexor/status*", lambda route: pending["status"].append(route))
page.route("**/api/model-catalog", lambda route: pending["catalog"].append(route))
page.goto(ui["url"] + "/#settings")
page.click('[data-settings-tab="models"]')
main = page.locator('[data-model-role="main"]')
main.wait_for(state="visible")
assert pending["reviewers"], "the reviewer read must still be pending"
assert pending["status"], "the status read must still be pending"
assert page.locator('#btn-save-settings').is_enabled(), "the known document can be saved before enrichment"
source = main.locator('[data-model-role-source]')
source.select_option("openrouter")
model = main.locator('[data-model-role-model]')
model.fill("owner-kept-model")
model.evaluate("element => { window.__earlyModel = element; element.setSelectionRange(4, 4); }")
expect(page.locator('#settings-unsaved-indicator')).to_have_class(re.compile('is-visible'))
page.unroute("**/api/reviewer-slots")
page.unroute("**/api/claudexor/status*")
for route in pending["reviewers"]:
route.fulfill(json=ui["fixture"]["preview"]["reviewer_slots"])
for route in pending["status"]:
route.fulfill(json=ui["fixture"]["status"])
expect(page.locator('#settings-status')).to_contain_text('Your edits are kept')
assert pending["catalog"], "a kept draft must not skip global catalog enrichment"
page.unroute("**/api/model-catalog")
for route in pending["catalog"]:
route.fulfill(content_type="application/json", body=json.dumps(ui["fixture"]["catalog"]))
page.wait_for_function("""() => document.querySelector('[data-model-role="main"] [data-model-role-source]')
.querySelector('option[value="subscription:opaque-source"]')""")
expect(model).to_have_value("owner-kept-model")
assert source.input_value() == "openrouter"
assert model.evaluate("element => element === window.__earlyModel && element.selectionStart === 4")
expect(page.locator('#settings-unsaved-indicator')).to_have_class(re.compile('is-visible'))
expect(page.locator('#btn-save-settings')).to_be_enabled()
expect(page.locator('#settings-status')).to_contain_text('Your edits are kept')
assert not any(path == '/api/settings' for path, _ in ui['posts'])
@pytest.mark.serial
@pytest.mark.ui_browser
def test_mcp_local_json_errors_hold_whole_settings_save(subscription_ui):
from playwright.sync_api import expect
ui = subscription_ui
page = ui['page']
page.goto(ui['url'] + '/#settings')
expect(page.locator('#btn-save-settings')).to_be_enabled()
page.locator('[data-settings-tab="advanced"]').click()
timeout = page.locator('#s-mcp-tool-timeout')
timeout.fill('0')
page.locator('#btn-save-settings').click()
expect(page.locator('#settings-status')).to_have_text('Enter a positive tool timeout in seconds.')
assert not [path for path, _ in ui['posts'] if path == '/api/settings']
timeout.fill('60')
expect(timeout).not_to_have_attribute('aria-invalid', 'true')
page.locator('#btn-mcp-add-server').click()
card = page.locator('[data-mcp-card]').last
card.locator('[data-mcp-field="id"]').fill('synthetic-server')
card.locator('[data-mcp-field="transport"]').select_option('stdio')
card.locator('[data-mcp-field="command"]').fill('node')
env = card.locator('[data-mcp-field="env"]')
refs = card.locator('[data-mcp-field="env_from_settings"]')
for invalid in ['{', '[]', '{"PORT": 42}']:
env.fill(invalid)
page.locator('#btn-save-settings').click()
assert not [path for path, _ in ui['posts'] if path == '/api/settings']
expect(env).to_have_value(invalid)
expect(env).to_have_attribute('aria-invalid', 'true')
env.fill('{"PORT":"9000"}')
refs.fill('{')
page.locator('#btn-save-settings').click()
assert not [path for path, _ in ui['posts'] if path == '/api/settings']
expect(refs).to_have_attribute('aria-invalid', 'true')
refs.fill('null')
expect(refs).not_to_have_attribute('aria-invalid', 'true')
with page.expect_request(lambda request: request.url.endswith('/api/settings') and request.method == 'POST'):
page.locator('#btn-save-settings').click()
saved = [body for path, body in ui['posts'] if path == '/api/settings'][-1]
servers = saved['MCP_SERVERS']
if isinstance(servers, str): servers = json.loads(servers)
assert servers[-1]['env'] == {'PORT': '9000'}
assert servers[-1].get('env_from_settings') in ({}, None)
def test_older_failed_settings_reload_cannot_disable_newer_success(subscription_ui):
"""Release an old failure only after the newer real UI load has finished."""
from playwright.sync_api import expect
ui, page = subscription_ui, subscription_ui['page']
with page.expect_response(lambda response: response.url.endswith('/api/model-catalog')):
page.goto(ui['url'] + '/#settings')
expect(page.locator('#btn-save-settings')).to_be_enabled()
page.evaluate("""() => {
const originalFetch = window.fetch;
window.testSettingsReads = [];
window.fetch = (input, init) => {
if (String(input).endsWith('/api/settings') && (init?.method || 'GET') === 'GET') {
return new Promise((resolve, reject) => testSettingsReads.push({resolve, reject}));
}
return originalFetch(input, init);
};
}""")
page.locator('#btn-reload-settings').click()
page.wait_for_function('testSettingsReads.length === 1')
page.locator('#btn-reload-settings').click()
page.wait_for_function('testSettingsReads.length === 2')
newer = {**ui['settings'], 'GITHUB_REPO': 'owner/newer-document'}
page.evaluate("""data => testSettingsReads[1].resolve(new Response(JSON.stringify(data), {
status: 200, headers: {'Content-Type': 'application/json'},
}))""", newer)
expect(page.locator('#s-gh-repo')).to_have_value('owner/newer-document')
expect(page.locator('#settings-status')).to_have_text('Settings loaded')
page.evaluate("""async () => {
testSettingsReads[0].reject(new Error('older read failed'));
// Await rejection handlers before asserting; no elapsed-time race.
await new Promise(resolve => setTimeout(resolve, 0));
}""")
expect(page.locator('#btn-save-settings')).to_be_enabled()
expect(page.locator('#settings-status')).to_have_text('Settings loaded')
expect(page.locator('#s-gh-repo')).to_have_value('owner/newer-document')
# A failure of the current request still reports the real load failure.
page.locator('#btn-reload-settings').click()
page.wait_for_function('testSettingsReads.length === 3')
page.evaluate("testSettingsReads[2].reject(new Error('current read failed'))")
expect(page.locator('#btn-save-settings')).to_be_disabled()
expect(page.locator('#settings-status')).to_contain_text('current read failed')

View file

@ -1,9 +1,10 @@
import { renderPageHeader } from './page_header.js';
import { PAGE_ICONS } from './page_icons.js';
import { escapeHtmlAttr, escapeHtmlText as escapeHtml } from './utils.js';
import { apiFetch, jsonPost } from './api_client.js';
import { fetchJson, jsonPost } from './api_client.js';
import { openConfirmDialog } from './confirm_dialog.js';
import { downloadViaHostBridge } from './ui_helpers.js';
import { downloadViaHostBridge, setInlineStatus } from './ui_helpers.js';
import { bindMenu } from './ui_interactions.js';
function formatFileSize(size) {
const num = Number(size);
@ -38,7 +39,7 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
<div class="files-layout">
<section class="files-sidebar">
<div class="files-toolbar">
<input id="files-search" type="text" placeholder="Filter current folder...">
<input id="files-search" class="ui-control" name="files-filter" type="text" aria-label="Filter current folder" placeholder="Filter current folder...">
</div>
<div class="files-browser-header">
<div id="files-breadcrumb" class="files-breadcrumb"></div>
@ -48,7 +49,7 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
<button class="btn btn-default" id="files-new-dir" title="Create directory">+ Dir</button>
</div>
</div>
<div id="files-list" class="files-list scroll-fade-y"></div>
<div id="files-list" class="files-list scroll-fade-y" tabindex="0" aria-label="Files in current folder"></div>
</section>
<section class="files-preview">
<div class="files-preview-header">
@ -62,17 +63,18 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
<button class="btn btn-primary" id="files-save" hidden disabled>Save</button>
</div>
</div>
<div id="files-preview-status" class="ui-status files-preview-status" role="status" aria-live="polite" hidden></div>
<div id="files-preview-content" class="files-preview-content scroll-fade-y">${defaultDirectoryContent()}</div>
</section>
<div class="files-drop-overlay" aria-hidden="true">
<div class="files-drop-card">Drop files to upload into the current folder</div>
</div>
<div id="files-context-menu" class="files-context-menu" hidden>
<button type="button" class="files-context-item" data-action="download">Download</button>
<button type="button" class="files-context-item" data-action="copy">Copy</button>
<button type="button" class="files-context-item" data-action="move">Move</button>
<button type="button" class="files-context-item" data-action="paste">Paste Here</button>
<button type="button" class="files-context-item files-context-item-danger" data-action="delete">Delete</button>
<div id="files-context-menu" class="files-context-menu ui-popup" role="menu" aria-label="File actions" hidden>
<button type="button" role="menuitem" class="files-context-item" data-action="download">Download</button>
<button type="button" role="menuitem" class="files-context-item" data-action="copy">Copy</button>
<button type="button" role="menuitem" class="files-context-item" data-action="move">Move</button>
<button type="button" role="menuitem" class="files-context-item" data-action="paste">Paste Here</button>
<button type="button" role="menuitem" class="files-context-item files-context-item-danger" data-action="delete">Delete</button>
</div>
</div>
`;
@ -84,10 +86,10 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
const previewPathEl = page.querySelector('#files-preview-path');
const previewMetaEl = page.querySelector('#files-preview-meta');
const previewContentEl = page.querySelector('#files-preview-content');
const previewStatusEl = page.querySelector('#files-preview-status');
const contextMenuEl = page.querySelector('#files-context-menu');
const contextMenuPositionStyle = document.createElement('style');
contextMenuPositionStyle.id = 'files-context-menu-position-style';
page.appendChild(contextMenuPositionStyle);
let contextMenuBinding = null;
let contextAnchor = null;
const saveBtn = page.querySelector('#files-save');
const downloadBtn = page.querySelector('#files-download');
const openExternalBtn = page.querySelector('#files-open-external');
@ -115,10 +117,13 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
editorWritable: false,
editorIsNew: false,
editorFilename: '',
editorSaving: false,
clipboard: null,
contextEntryType: '',
contextDestinationPath: '.',
};
let viewRequest = 0;
let draftRevision = 0;
function updateEditorActions() {
const visible = state.editorWritable && state.selectedType === 'file';
@ -128,7 +133,8 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
: state.selectedPath === state.editorPath
) && (state.editorDirty || state.editorIsNew);
saveBtn.hidden = !visible;
saveBtn.disabled = !canSave;
saveBtn.disabled = !canSave || state.editorSaving;
saveBtn.textContent = state.editorSaving ? 'Saving…' : 'Save';
const fileSelected = state.selectedType === 'file' && Boolean(state.selectedPath);
downloadBtn.hidden = !fileSelected;
openExternalBtn.hidden = !fileSelected;
@ -155,6 +161,7 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
}
function setPreview({ path, meta, content, html, node }) {
showStatus('');
previewPathEl.textContent = path || 'Select a file';
previewMetaEl.textContent = meta || '';
if (node) {
@ -174,36 +181,39 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
if (options.isNew) {
const nameInput = document.createElement('input');
nameInput.className = 'files-editor-name';
nameInput.className = 'files-editor-name ui-control';
nameInput.type = 'text';
nameInput.name = 'filename';
nameInput.setAttribute('aria-label', 'File name');
nameInput.placeholder = 'new-file.txt';
nameInput.value = state.editorFilename || '';
nameInput.autocomplete = 'off';
nameInput.spellcheck = false;
nameInput.addEventListener('input', () => {
draftRevision += 1;
state.editorFilename = nameInput.value;
state.editorDirty = state.editorValue !== state.editorOriginal || state.editorFilename !== state.editorOriginalFilename;
showStatus('');
updateEditorActions();
});
wrapper.appendChild(nameInput);
}
const textarea = document.createElement('textarea');
textarea.className = 'files-editor';
textarea.className = 'files-editor ui-control ui-control-code';
textarea.name = 'file-content';
textarea.setAttribute('aria-label', 'File contents');
textarea.setAttribute('aria-describedby', 'files-preview-path files-preview-meta');
textarea.value = content || '';
textarea.spellcheck = false;
textarea.placeholder = options.isNew ? 'Start typing file contents...' : '';
textarea.addEventListener('input', () => {
draftRevision += 1;
state.editorValue = textarea.value;
state.editorDirty = state.editorValue !== state.editorOriginal || state.editorFilename !== state.editorOriginalFilename;
showStatus('');
updateEditorActions();
});
textarea.addEventListener('keydown', (event) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
saveCurrentFile().catch(showError);
}
});
wrapper.appendChild(textarea);
return wrapper;
}
@ -232,7 +242,8 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
return Boolean(result?.confirmed);
}
function showContextMenu(x, y, path, type, destinationPath = '.') {
function showContextMenu(x, y, path, type, destinationPath = '.', anchor = listEl) {
hideContextMenu();
state.contextPath = path || '';
state.contextEntryType = type || '';
state.contextDestinationPath = destinationPath || '.';
@ -248,22 +259,34 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
if (deleteItem) {
deleteItem.hidden = !path;
}
contextMenuEl.querySelectorAll('[data-action="copy"], [data-action="move"]').forEach((item) => {
item.hidden = !path;
});
contextAnchor = anchor;
contextAnchor.setAttribute('aria-expanded', 'true');
contextAnchor.setAttribute('aria-haspopup', 'menu');
document.body.appendChild(contextMenuEl);
contextMenuEl.hidden = false;
const margin = 8;
const rect = contextMenuEl.getBoundingClientRect();
const left = Math.min(Math.max(margin, x), Math.max(margin, window.innerWidth - rect.width - margin));
const top = Math.min(Math.max(margin, y), Math.max(margin, window.innerHeight - rect.height - margin));
contextMenuPositionStyle.textContent = `#files-context-menu[data-open="1"]{left:${Math.round(left)}px;top:${Math.round(top)}px;}`;
contextMenuEl.dataset.open = '1';
contextMenuBinding = bindMenu(contextMenuEl, {
anchor,
point: Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined,
onClose: resetContextMenu,
});
}
function hideContextMenu() {
function resetContextMenu() {
state.contextPath = '';
state.contextEntryType = '';
state.contextDestinationPath = '.';
delete contextMenuEl.dataset.open;
contextMenuPositionStyle.textContent = '';
contextAnchor?.setAttribute('aria-expanded', 'false');
contextAnchor = null;
contextMenuBinding = null;
contextMenuEl.hidden = true;
layoutEl.appendChild(contextMenuEl);
}
function hideContextMenu(options) {
contextMenuBinding?.close(options);
}
function filteredEntries() {
@ -317,6 +340,10 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
const button = document.createElement('button');
const selected = state.selectedPath === entry.path;
button.type = 'button';
if (!entry.isParentLink) {
button.setAttribute('aria-haspopup', 'menu');
button.setAttribute('aria-expanded', 'false');
}
button.className = `files-entry ${entry.isParentLink ? 'parent-link' : ''} ${selected ? 'selected' : ''}`;
button.innerHTML = `
<span class="files-entry-icon">${iconForEntry(entry)}</span>
@ -326,71 +353,74 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
button.addEventListener('contextmenu', (event) => {
if (entry.isParentLink) return;
event.preventDefault();
state.selectedPath = entry.path;
state.selectedType = entry.type;
renderList();
showContextMenu(
event.clientX,
event.clientY,
entry.path,
entry.type,
entry.type === 'dir' ? entry.path : state.path || '.',
button,
);
});
button.addEventListener('click', async () => {
button.addEventListener('keydown', (event) => {
if (entry.isParentLink || (event.key !== 'ContextMenu' && !(event.shiftKey && event.key === 'F10'))) return;
event.preventDefault();
showContextMenu(undefined, undefined, entry.path, entry.type, entry.type === 'dir' ? entry.path : state.path, button);
});
button.addEventListener('click', () => {
hideContextMenu();
if (entry.type === 'dir') {
state.selectedPath = entry.isParentLink ? '' : entry.path;
state.selectedType = 'dir';
renderList();
loadDirectory(entry.path).catch(showError);
} else {
if (!(await canLeaveEditor())) return;
state.selectedPath = entry.path;
state.selectedType = entry.type;
renderList();
loadFile(entry.path, { skipLeaveCheck: true }).catch(showError);
loadFile(entry.path).catch(showError);
}
});
listEl.appendChild(button);
});
}
function showStatus(text, tone = 'muted') {
setInlineStatus(previewStatusEl, text, tone);
previewStatusEl.hidden = !text;
}
function showError(err) {
setPreview({
path: 'Files',
meta: 'Request failed',
content: err instanceof Error ? err.message : String(err),
});
showStatus(`Request failed: ${err instanceof Error ? err.message : String(err)}`, 'error');
}
async function loadDirectory(path = '.', options = {}) {
if (!options.skipEditorReset) {
if (!options.skipLeaveCheck && !(await canLeaveEditor())) return;
resetEditorState();
}
const request = ++viewRequest;
const preserveEditor = options.skipEditorReset || (!options.useBackendDefault && path === state.path);
if (!preserveEditor && !options.skipLeaveCheck && !(await canLeaveEditor())) return;
if (request !== viewRequest) return;
const revision = draftRevision;
hideContextMenu();
const params = new URLSearchParams();
if (options.useBackendDefault !== true) {
params.set('path', path);
}
const query = params.toString();
const resp = await apiFetch(`/api/files/list${query ? `?${query}` : ''}`);
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
const data = await fetchJson(`/api/files/list${query ? `?${query}` : ''}`);
if (request !== viewRequest) return;
if (!preserveEditor && revision !== draftRevision) {
showStatus('New edits kept. Choose the folder again when ready to leave.');
return;
}
if (!options.keepStatus) showStatus('');
if (!preserveEditor) {
state.selectedPath = '';
state.selectedType = 'dir';
resetEditorState();
}
state.rootPath = data.root_path || state.rootPath;
state.path = data.path || '.';
state.parentPath = data.parent_path || '.';
state.entries = Array.isArray(data.entries) ? data.entries : [];
if (state.selectedPath && !state.entries.some((entry) => entry.path === state.selectedPath)) {
state.selectedPath = '';
state.selectedType = '';
}
renderBreadcrumb(Array.isArray(data.breadcrumb) ? data.breadcrumb : []);
renderList();
if (!state.selectedPath || state.selectedType === 'dir') {
if (!preserveEditor || (!state.selectedPath && !state.editorWritable)) {
setPreview({
path: data.display_path || state.rootPath || 'Files',
meta: data.truncated ? 'Directory listing truncated.' : defaultDirectoryMeta(),
@ -399,14 +429,23 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
}
}
async function loadFile(path, options = {}) {
if (!options.skipLeaveCheck && state.selectedPath !== path && !(await canLeaveEditor())) return;
async function loadFile(path) {
if (state.selectedPath === path && state.editorPath === path) return;
const request = ++viewRequest;
if (!(await canLeaveEditor()) || request !== viewRequest) return;
const revision = draftRevision;
hideContextMenu();
const params = new URLSearchParams({ path });
const resp = await apiFetch(`/api/files/read?${params.toString()}`);
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
const data = await fetchJson(`/api/files/read?${params.toString()}`);
if (request !== viewRequest) return;
if (revision !== draftRevision) {
showStatus('New edits kept. Choose the file again when ready to leave.');
return;
}
state.selectedPath = data.path || path;
state.selectedType = 'file';
renderList();
if (data.is_image && data.content_url) {
resetEditorState();
setPreview({
@ -439,7 +478,7 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
}
const editable = !data.truncated;
state.editorPath = path;
state.editorPath = state.selectedPath;
state.editorOriginalFilename = data.name || '';
state.editorOriginal = data.content || '';
state.editorValue = data.content || '';
@ -468,17 +507,12 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
const filename = filenameFromPath(path);
const result = await downloadViaHostBridge(url, filename, { openExternal });
if (result.native) {
setPreview({
path,
meta: openExternal ? 'Opened externally' : 'Downloaded',
content: `${filename} saved to ${result.path || 'Downloads'}.`,
});
showStatus(openExternal ? `Opened ${filename} externally.` : `${filename} saved to ${result.path || 'Downloads'}.`, 'success');
return;
}
}
async function createDirectory() {
if (!(await canLeaveEditor())) return;
const result = await showModal({
title: 'Create Directory',
message: 'Enter a name for the new directory in the current folder.',
@ -488,39 +522,56 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
});
const name = (result?.value || '').trim();
if (!result?.confirmed || !name) return;
const data = await jsonPost('/api/files/mkdir', { path: state.path || '.', name });
state.selectedPath = '';
state.selectedType = 'dir';
await loadDirectory(state.path || '.', { skipLeaveCheck: true });
await jsonPost('/api/files/mkdir', { path: state.path || '.', name });
await loadDirectory(state.path || '.');
showStatus(`Directory ${name} created.`, 'success');
}
async function finishFileOperation(view, { directory, path = '', type = 'dir', message }) {
const currentView = view.request === viewRequest;
const replacePreview = currentView && view.revision === draftRevision;
if (replacePreview) {
resetEditorState();
state.selectedPath = path;
state.selectedType = type;
updateEditorActions();
setPreview({ path: path || state.rootPath, meta: message, content: '' });
}
showStatus(message, 'success');
// A completed write remains real, but it cannot take over newer work.
// Refreshing the original folder must not cancel a newer navigation.
if (!currentView || (!replacePreview && directory !== state.path)) return;
try {
await loadDirectory(directory, { skipLeaveCheck: true, skipEditorReset: true, keepStatus: true });
showStatus(message, 'success');
} catch (err) {
showStatus(`${message} Folder refresh failed: ${err instanceof Error ? err.message : String(err)}`, 'error');
}
}
async function pasteClipboard(destinationPath = state.path || '.') {
if (!state.clipboard) return;
const clipboard = state.clipboard;
if (!clipboard) return;
if (!(await canLeaveEditor())) return;
const view = { request: viewRequest, revision: draftRevision };
const data = await jsonPost('/api/files/transfer', {
source_path: state.clipboard.path,
source_path: clipboard.path,
destination_dir: destinationPath || '.',
mode: state.clipboard.mode,
mode: clipboard.mode,
});
const pastedMode = state.clipboard.mode;
state.clipboard = null;
if (state.clipboard === clipboard) state.clipboard = null;
updateClipboardActions();
const refreshPath = destinationPath || state.path || '.';
state.selectedPath = data.path || '';
state.selectedType = data.type || '';
await loadDirectory(refreshPath, { skipLeaveCheck: true });
setPreview({
path: data.display_path || state.rootPath || 'Files',
meta: `${pastedMode === 'move' ? 'Moved' : 'Copied'} ${data.type || 'item'}`,
content: '',
await finishFileOperation(view, {
directory: destinationPath || '.', path: data.path || '', type: data.type || '',
message: `${clipboard.mode === 'move' ? 'Moved' : 'Copied'} ${data.display_path || data.path || clipboard.name}.`,
});
}
async function deleteSelectedEntry() {
if (!state.selectedPath) return;
const entry = state.entries.find((item) => item.path === state.selectedPath);
async function deleteSelectedEntry(path = state.selectedPath) {
if (!path) return;
const entry = state.entries.find((item) => item.path === path);
if (!entry) return;
if (!(await canLeaveEditor())) return;
@ -533,90 +584,96 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
cancelLabel: 'Cancel',
});
if (!result?.confirmed) return;
const view = { request: viewRequest, revision: draftRevision };
const directory = state.path || '.';
await jsonPost('/api/files/delete', { path: state.selectedPath });
await jsonPost('/api/files/delete', { path });
resetEditorState();
state.selectedPath = '';
state.selectedType = 'dir';
await loadDirectory(state.path || '.', { skipLeaveCheck: true, skipEditorReset: true });
setPreview({
path: state.rootPath || 'Files',
meta: `${entry.type === 'dir' ? 'Directory' : 'File'} deleted`,
content: '',
await finishFileOperation(view, {
directory, message: `${entry.type === 'dir' ? 'Directory' : 'File'} deleted: ${entry.name}.`,
});
}
async function uploadFiles(fileList) {
const files = Array.from(fileList || []);
if (!files.length) return;
if (!(await canLeaveEditor())) return;
const uploadPath = state.path || '.';
const view = { request: viewRequest, revision: draftRevision };
let uploaded;
for (const file of files) {
const form = new FormData();
form.set('path', state.path || '.');
form.set('path', uploadPath);
form.set('file', file);
setPreview({
path: state.rootPath || 'Files',
meta: `Uploading into ${state.path || '.'}`,
content: `Uploading ${file.name}...`,
});
showStatus(`Uploading ${file.name} into ${uploadPath}…`);
const resp = await apiFetch('/api/files/upload', {
uploaded = await fetchJson('/api/files/upload', {
method: 'POST',
body: form,
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
state.selectedPath = data.path || '';
state.selectedType = 'file';
}
await loadDirectory(state.path || '.');
if (state.selectedPath) {
const selected = state.entries.find((entry) => entry.path === state.selectedPath);
setPreview({
path: selected ? `${state.rootPath || 'Files'}/${selected.name}` : (state.rootPath || 'Files'),
meta: selected ? `${formatFileSize(selected.size)} • uploaded` : 'Upload complete',
content: '',
});
}
await finishFileOperation(view, {
directory: uploadPath, path: uploaded.path || '', type: 'file',
message: `Upload complete: ${files.map(file => file.name).join(', ')} in ${uploadPath}.`,
});
}
async function saveCurrentFile() {
if (!state.editorWritable) return;
if (!state.editorWritable || state.editorSaving || (!state.editorDirty && !state.editorIsNew)) return;
const relName = state.editorFilename.trim();
if (state.editorIsNew && !relName) return;
const savePath = state.editorIsNew
? (state.path && state.path !== '.' ? `${state.path}/${relName}` : relName)
: state.editorPath;
if (!savePath) return;
const data = await jsonPost('/api/files/write', {
path: savePath,
content: state.editorValue,
create: state.editorIsNew,
});
state.selectedPath = data.path || savePath;
state.selectedType = 'file';
state.editorPath = data.path || savePath;
state.editorFilename = data.name || relName;
state.editorOriginalFilename = state.editorFilename;
state.editorIsNew = false;
state.editorOriginal = state.editorValue;
state.editorDirty = false;
const editor = previewContentEl.querySelector('.files-editor');
const nameInput = previewContentEl.querySelector('.files-editor-name');
const content = state.editorValue;
state.editorSaving = true;
if (nameInput) nameInput.disabled = true;
updateEditorActions();
setPreview({
path: data.display_path || state.rootPath || 'Files',
meta: `${formatFileSize(data.size)} • saved`,
node: renderEditor(state.editorValue, { isNew: false }),
});
await loadDirectory(state.path || '.', { skipEditorReset: true, skipLeaveCheck: true });
showStatus('Saving…');
try {
const data = await jsonPost('/api/files/write', {
path: savePath,
content,
create: state.editorIsNew,
});
if (editor !== previewContentEl.querySelector('.files-editor')) return;
state.selectedPath = data.path || savePath;
state.selectedType = 'file';
state.editorPath = state.selectedPath;
state.editorFilename = data.name || relName;
state.editorOriginalFilename = state.editorFilename;
state.editorIsNew = false;
state.editorOriginal = content;
state.editorDirty = state.editorValue !== content;
if (nameInput === document.activeElement) editor?.focus();
nameInput?.remove();
previewPathEl.textContent = data.display_path || state.editorPath;
previewMetaEl.textContent = `${formatFileSize(data.size)} • editable`;
showStatus(state.editorDirty ? 'Saved. Newer edits are still unsaved.' : 'Saved.', 'success');
} finally {
state.editorSaving = false;
if (nameInput) nameInput.disabled = false;
updateEditorActions();
}
try {
await loadDirectory(state.path || '.', { skipEditorReset: true, keepStatus: true });
} catch (err) {
if (editor === previewContentEl.querySelector('.files-editor')) {
showStatus(`Saved. Folder refresh failed: ${err instanceof Error ? err.message : String(err)}`, 'error');
}
}
}
function createNewFile(options = {}) {
if (state.editorDirty && !options.force) return;
hideContextMenu();
viewRequest += 1;
state.selectedPath = '';
state.selectedType = 'file';
state.editorPath = '';
@ -700,11 +757,15 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
});
contextMenuEl.addEventListener('click', (event) => {
const action = event.target instanceof HTMLElement ? event.target.dataset.action : '';
const action = event.target.closest('[data-action]')?.dataset.action;
if (!action) return;
const path = state.contextPath;
const destinationPath = state.contextDestinationPath;
hideContextMenu({ restoreFocus: true });
if (action === 'download') {
downloadFile(state.contextPath).catch(showError);
downloadFile(path).catch(showError);
} else if (action === 'copy' || action === 'move') {
const entry = state.entries.find((item) => item.path === state.contextPath);
const entry = state.entries.find((item) => item.path === path);
if (entry) {
state.clipboard = {
mode: action,
@ -713,22 +774,13 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
type: entry.type,
};
updateClipboardActions();
setPreview({
path: state.rootPath || 'Files',
meta: `${action === 'move' ? 'Move' : 'Copy'} ready`,
content: `${entry.name} will be ${action === 'move' ? 'moved' : 'copied'} into the next folder where you press Paste.`,
});
showStatus(`${action === 'move' ? 'Move' : 'Copy'} ready: ${entry.name} will be ${action === 'move' ? 'moved' : 'copied'} into the next folder where you press Paste.`);
}
} else if (action === 'paste') {
pasteClipboard(state.contextDestinationPath).catch(showError);
pasteClipboard(destinationPath).catch(showError);
} else if (action === 'delete') {
deleteSelectedEntry().catch(showError);
deleteSelectedEntry(path).catch(showError);
}
hideContextMenu();
});
document.addEventListener('click', () => {
hideContextMenu();
});
listEl.addEventListener('contextmenu', (event) => {
@ -738,8 +790,10 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
}
});
window.addEventListener('blur', () => {
hideContextMenu();
listEl.addEventListener('keydown', (event) => {
if (event.target !== listEl || !state.clipboard || (event.key !== 'ContextMenu' && !(event.shiftKey && event.key === 'F10'))) return;
event.preventDefault();
showContextMenu(undefined, undefined, '', 'dir', state.path || '.');
});
refreshBtn.addEventListener('click', () => {
@ -756,20 +810,19 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
const active = document.activeElement;
const inEditor = active && (
active.classList?.contains('files-editor') ||
active.classList?.contains('files-editor-name') ||
active.id === 'files-search' ||
active.matches?.('[data-confirm-input]')
active.classList?.contains('files-editor-name')
);
const dialogOpen = Boolean(document.querySelector('.confirm-dialog-backdrop'));
const dialogOpen = Boolean(document.querySelector('[aria-modal="true"]'));
if (!page.classList.contains('active')) return;
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
if (!inEditor) return;
if (!inEditor || dialogOpen) return;
event.preventDefault();
if (event.repeat) return;
saveCurrentFile().catch(showError);
return;
}
if (event.key === 'Delete') {
if (inEditor || dialogOpen) return;
if (inEditor || active === searchEl || dialogOpen || contextMenuBinding) return;
event.preventDefault();
deleteSelectedEntry().catch(showError);
}
@ -778,6 +831,7 @@ export function initFiles({ state: appState, setBeforePageLeave } = {}) {
if (typeof setBeforePageLeave === 'function') {
setBeforePageLeave(async ({ from }) => {
if (from !== 'files') return true;
hideContextMenu();
return canLeaveEditor();
});
}

View file

@ -0,0 +1,173 @@
// Editable suggestions shared by the product's model editors. The input is
// the draft authority; catalogs enrich its list and never assign a value.
import { escapeHtmlAttr as escapeHtml } from './utils.js';
import { bindPopoverPosition } from './ui_interactions.js';
const choosers = new WeakMap();
export function modelOptions(items = []) {
const seen = new Set();
return items.map((item) => typeof item === 'string' ? { value: item, label: item }
: { value: String(item.value ?? item.id ?? ''), label: String(item.label || item.name || item.value || item.id || '') })
.filter((item) => !seen.has(item.value) && seen.add(item.value));
}
export function matchingModelOptions(items, query) {
const search = String(query || '').trim().toLocaleLowerCase();
return modelOptions(items).filter(({ value, label }) => !search
|| value.toLocaleLowerCase().includes(search) || label.toLocaleLowerCase().includes(search));
}
function optionsHtml(items, listId) {
return modelOptions(items).map(({ value, label }, index) =>
`<div class="ui-model-option" role="option" id="${escapeHtml(listId)}-${index}"
data-model-value="${escapeHtml(value)}" aria-selected="false">${escapeHtml(label || value)}</div>`).join('');
}
export function modelChooserHtml(attrs, value, listId, items = [], { placeholder = 'Choose a model' } = {}) {
return `<span class="ui-model-chooser"><input class="ui-control" type="text" ${attrs}
data-model-chooser role="combobox" aria-autocomplete="list" aria-expanded="false"
aria-controls="${escapeHtml(listId)}" value="${escapeHtml(value)}"
placeholder="${escapeHtml(placeholder)}" autocomplete="off" spellcheck="false">
<div id="${escapeHtml(listId)}" class="ui-popup ui-model-options" role="listbox" hidden>${optionsHtml(items, listId)}</div></span>`;
}
function readOptions(list) {
return [...list.querySelectorAll('[data-model-value]')].map((node) => ({
value: node.dataset.modelValue, label: node.textContent,
}));
}
export function updateModelChooser(input, items) {
choosers.get(input)?.update(items);
}
/** Bind once per real input; dispose before its owner replaces or removes it. */
export function bindModelChooser(input) {
if (!input || choosers.has(input)) return () => {};
const doc = input.ownerDocument;
const list = doc.getElementById(input.getAttribute('aria-controls'));
if (!list) return () => {};
const owner = list.parentElement;
list.setAttribute('aria-label', input.getAttribute('aria-label') || 'Models');
let items = readOptions(list);
let visible = [];
let active = -1;
let composing = false;
let opened = false;
let position = null;
const disposers = [];
const listen = (target, type, listener) => {
target.addEventListener(type, listener);
disposers.push(() => target.removeEventListener(type, listener));
};
function highlight(index) {
active = index;
[...list.querySelectorAll('[role="option"]')].forEach((node, ordinal) => {
node.setAttribute('aria-selected', String(ordinal === active));
if (ordinal === active) {
input.setAttribute('aria-activedescendant', node.id);
node.scrollIntoView({ block: 'nearest' });
}
});
if (active < 0) input.removeAttribute('aria-activedescendant');
}
function render() {
const selected = visible[active]?.value;
visible = matchingModelOptions(items, input.value);
list.innerHTML = optionsHtml(visible, list.id)
|| '<div class="ui-model-empty">No matching suggestions. You can keep this model ID.</div>';
highlight(selected === undefined ? -1 : visible.findIndex((item) => item.value === selected));
position?.reposition();
}
function close() {
opened = false;
list.hidden = true;
input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant');
active = -1;
position?.destroy(); position = null;
if (list.parentElement !== owner) owner.appendChild(list);
doc.removeEventListener('pointerdown', outside);
doc.defaultView.removeEventListener('blur', close);
}
function open() {
if (input.disabled) return;
opened = true;
list.hidden = false;
if (list.parentElement !== doc.body) doc.body.appendChild(list);
input.setAttribute('aria-expanded', 'true');
render();
if (!position) position = bindPopoverPosition(list, { anchor: input });
doc.addEventListener('pointerdown', outside);
doc.defaultView.addEventListener('blur', close);
}
function outside(event) {
if (event.target !== input && !list.contains(event.target)) close();
}
function choose(value) {
input.value = value;
// Existing domain input handlers retain serialization and source pins.
input.dispatchEvent(new doc.defaultView.Event('input', { bubbles: true }));
input.dispatchEvent(new doc.defaultView.Event('change', { bubbles: true }));
close();
input.focus({ preventScroll: true });
}
listen(input, 'focus', open);
listen(input, 'click', () => { if (!opened) open(); });
listen(input, 'input', () => { if (!composing) open(); });
listen(input, 'compositionstart', () => { composing = true; });
listen(input, 'compositionend', () => { composing = false; open(); });
listen(input, 'blur', close);
listen(input, 'keydown', (event) => {
if (composing || event.isComposing) return;
if (event.key === 'Escape' && opened) {
event.preventDefault(); event.stopPropagation(); close();
} else if (event.key === 'Tab') close();
else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
if (!opened) open();
const delta = event.key === 'ArrowDown' ? 1 : -1;
highlight(visible.length ? (active < 0 ? (delta > 0 ? 0 : visible.length - 1)
: (active + delta + visible.length) % visible.length) : -1);
} else if (event.key === 'Enter' && opened && active >= 0) {
event.preventDefault(); choose(visible[active].value);
}
});
// Keep focus/caret in the editable input; a touch scroll is still native.
listen(list, 'mousedown', (event) => event.preventDefault());
listen(list, 'click', (event) => {
const option = event.target.closest('[data-model-value]');
if (option && list.contains(option)) choose(option.dataset.modelValue);
});
choosers.set(input, { update(next) {
items = modelOptions(next);
if (opened && !composing) render();
} });
return () => {
close();
for (const dispose of disposers) dispose();
choosers.delete(input);
};
}
export function bindModelChoosers(container) {
const disposers = [...(container?.querySelectorAll('[data-model-chooser]') || [])].map(bindModelChooser);
return () => { for (const dispose of disposers) dispose(); };
}
/** Refresh only discovery-owned options; actual editable nodes stay mounted. */
export function updateModelChooserOptions(current, desired) {
for (const input of current.querySelectorAll('[data-model-chooser]')) {
const id = input.getAttribute('aria-controls');
const list = [...desired.querySelectorAll('[role="listbox"]')].find((node) => node.id === id);
if (list) updateModelChooser(input, readOptions(list));
}
}

View file

@ -7,6 +7,7 @@ import { bindStatusSurface, claudexorStatus } from './claudexor_status_store.js'
import { parseModelSource, composeModelSource, indexProfilesByHarness, profileOptionsFor, selectHtml, mintStableId } from './route_editor_primitives.js';
import { revealNewRow } from './ui_helpers.js';
import { escapeHtmlAttr as escapeHtml } from './utils.js';
import { modelChooserHtml, bindModelChoosers, updateModelChooser } from './model_chooser.js';
export const MODEL_ACCOUNTS_KEY = 'OUROBOROS_MODEL_ACCOUNTS';
export const MODEL_CONTEXT_KEY = 'OUROBOROS_MODEL_CONTEXT_WINDOWS';
@ -64,6 +65,8 @@ export function createModelRolesEditor({ hostId, store = claudexorStatus,
let loaded = false;
let destroyed = false;
let disposeStatus = null;
let disposeChoosers = () => {};
let validationAttempted = false;
const catalogs = new Map();
const requests = new Map();
const host = () => getDoc()?.getElementById(hostId);
@ -102,6 +105,18 @@ export function createModelRolesEditor({ hostId, store = claudexorStatus,
}
function changed() { onChange(collect()); }
function rowErrors(row) {
const errors = [];
if (['main', 'fallback'].includes(row.slot.slot) && !row.model.trim()) {
errors.push({ field: '[data-model-role-model]', message: `${row.slot.label}: choose a model${row.slot.slot === 'fallback' ? ' or remove this fallback' : ''}.` });
}
const contextInput = getDoc()?.getElementById(`${inputIdFor(row)}-context`);
if (contextInput?.validity?.badInput || !Number.isSafeInteger(Number(row.context || 0)) || Number(row.context || 0) < 0) {
errors.push({ field: '[data-model-role-context]', message: `${row.slot.label}: context window must be a positive whole number, or Auto.` });
}
return errors;
}
function validateAll() { return rows.flatMap((row) => rowErrors(row).map(({ message }) => message)); }
function effectiveSource(row) { return row.source === 'inherit' ? rows.find((entry) => entry.slot.slot === 'main')?.source || 'openrouter' : row.source; }
function sourceId(row) { const source = effectiveSource(row); return source.startsWith('subscription:') ? source.slice(13) : ''; }
function catalogKey(row) { return JSON.stringify([sourceId(row), row.account]); }
@ -124,7 +139,7 @@ export function createModelRolesEditor({ hostId, store = claudexorStatus,
if (!showContext) return '';
return `<details class="model-role-details"><summary>Context</summary>
<div class="model-role-context">
<label>Window <input data-model-role-context type="number" min="0" step="1"
<label class="ui-field">Window <input id="${escapeHtml(inputIdFor(row))}-context" class="ui-control" data-model-role-context type="number" min="0" step="1"
placeholder="Auto" value="${escapeHtml(row.context || '')}" aria-label="${escapeHtml(row.slot.label)} context window"></label>
<span data-model-context-note>${escapeHtml(modelContextNote(currentItem(row), row.context))}</span>
</div></details>`;
@ -135,25 +150,26 @@ export function createModelRolesEditor({ hostId, store = claudexorStatus,
const inputId = inputIdFor(row);
return `<div class="model-role-row" data-model-role="${escapeHtml(row.id)}">
<div class="model-role-controls">
${selectHtml('data-model-role-source aria-label="Source"', modelSourceGroups({ sources, providers, current: row.source }), row.source)}
<input id="${escapeHtml(inputId)}" data-model-role-model aria-label="${escapeHtml(row.slot.label)}" value="${escapeHtml(row.model)}" list="${escapeHtml(hostId)}-${escapeHtml(row.id)}-models" placeholder="${row.slot.slot === 'main' ? 'Choose a model' : 'Empty uses Main'}" autocomplete="off" spellcheck="false">
<select data-model-role-account aria-label="Account" ${sourceId(row) ? '' : 'hidden'}></select>
${selectHtml(`data-model-role-source aria-label="${escapeHtml(row.slot.label)} source"`, modelSourceGroups({ sources, providers, current: row.source }), row.source)}
${modelChooserHtml(`id="${escapeHtml(inputId)}" data-model-role-model aria-label="${escapeHtml(row.slot.label)}${isFallback ? ` ${index + 1}` : ''}"`, row.model, `${hostId}-${row.id}-models`, [], { placeholder: row.slot.slot === 'main' ? 'Choose a model' : 'Empty uses Main' })}
<select class="ui-control" data-model-role-account aria-label="${escapeHtml(row.slot.label)} account" ${sourceId(row) ? '' : 'hidden'}></select>
${isFallback ? `<span class="model-role-order"><button type="button" class="btn btn-default" data-model-up aria-label="Move fallback up" ${index === 0 ? 'disabled' : ''}>↑</button><button type="button" class="btn btn-default" data-model-down aria-label="Move fallback down" ${index === total - 1 ? 'disabled' : ''}>↓</button><button type="button" class="btn btn-default" data-model-remove aria-label="Remove fallback">Remove</button></span>` : ''}
</div>
<datalist id="${escapeHtml(hostId)}-${escapeHtml(row.id)}-models"></datalist>
<div class="model-role-notes"><span class="model-role-meta" data-model-role-status></span>${detailsHtml(row)}</div>
<div class="model-role-notes"><span id="${escapeHtml(hostId)}-${escapeHtml(row.id)}-status" class="model-role-meta ui-field-help" data-model-role-status></span>${detailsHtml(row)}</div>
<div class="ui-status ui-field-help" id="${escapeHtml(hostId)}-${escapeHtml(row.id)}-error" data-model-role-error data-tone="error" hidden></div>
</div>`;
}
function render() {
const element = host();
if (!element || destroyed || !loaded) return;
disposeChoosers();
element.innerHTML = slots.map((slot) => {
const matching = rows.filter((row) => row.slot.slot === slot.slot);
const local = matching[0]?.local || false;
return `<section class="model-role-group" data-model-role-group="${escapeHtml(slot.slot)}">
<div class="model-role-head"><h4 title="${escapeHtml(slot.note || '')}">${escapeHtml(slot.label.replace(/ Model$/, ''))}</h4>
${slot.settingsToggleId ? `<label class="local-toggle"><input id="${escapeHtml(slot.settingsToggleId)}" type="checkbox" data-model-local ${local ? 'checked' : ''}> Local</label>` : ''}
${slot.settingsToggleId ? `<label class="local-toggle ui-field ui-field-inline"><input class="ui-checkbox" id="${escapeHtml(slot.settingsToggleId)}" type="checkbox" data-model-local aria-label="${escapeHtml(slot.label)} local runtime" ${local ? 'checked' : ''}> Local</label>` : ''}
${slot.slot === 'fallback' ? '<button type="button" class="btn btn-default" data-model-add>Add fallback</button>' : ''}
</div>
${slot.slot === 'fallback' ? '<p class="model-role-copy">Tried in this order. Subscription quota waits for your choice before using API.</p>' : ''}
@ -161,6 +177,7 @@ export function createModelRolesEditor({ hostId, store = claudexorStatus,
</section>`;
}).join('');
bindRows(element);
disposeChoosers = bindModelChoosers(element);
updateCatalogViews();
for (const row of rows) void refreshRow(row);
}
@ -182,14 +199,23 @@ export function createModelRolesEditor({ hostId, store = claudexorStatus,
const accountHtml = selectHtml('', [{ options: profileOptionsFor(profiles[credentialHarness], row.account, { accountsKnown: store.accountsKnown && Boolean(credentialHarness) }) }], row.account);
const accountOptions = accountHtml.slice(accountHtml.indexOf('>') + 1, accountHtml.lastIndexOf('</select>'));
if (account.innerHTML !== accountOptions) account.innerHTML = accountOptions;
const list = node.querySelector('datalist');
list.innerHTML = itemsFor(row).map((item) => `<option value="${escapeHtml(parseModelSource(item.value || item.id).model)}">${escapeHtml(item.name || item.label || item.id)}</option>`).join('');
updateModelChooser(node.querySelector('[data-model-role-model]'), itemsFor(row).map((item) => ({
value: parseModelSource(item.value || item.id).model, label: item.name || item.label || item.id || item.value,
})));
if (showContext) node.querySelector('[data-model-context-note]').textContent = modelContextNote(currentItem(row), row.context);
const status = node.querySelector('[data-model-role-status]');
const catalog = catalogs.get(catalogKey(row));
status.textContent = row.local ? 'Uses the local runtime.'
: !row.model ? (row.slot.slot === 'main' ? 'Choose a model to continue.' : 'Uses Main.')
: sourceId(row) ? (catalog?.error || 'Uses your subscription. No API key required.') : '';
const errors = validationAttempted ? rowErrors(row) : [];
const message = node.querySelector('[data-model-role-error]');
Object.assign(message, { textContent: errors.map((error) => error.message).join(' '), hidden: !errors.length });
for (const selector of ['[data-model-role-model]', '[data-model-role-context]']) {
const field = node.querySelector(selector);
field?.setAttribute('aria-describedby', `${status.id} ${message.id}`);
field?.setAttribute('aria-invalid', String(errors.some((error) => error.field === selector)));
}
}
}
@ -266,6 +292,7 @@ export function createModelRolesEditor({ hostId, store = claudexorStatus,
return {
load(value, contract = {}) {
validationAttempted = false;
settings = { ...value }; slots = contract.modelSlots || slots;
providers = contract.providerProfiles || providers;
rows = [];
@ -292,15 +319,14 @@ export function createModelRolesEditor({ hostId, store = claudexorStatus,
updateCatalogViews();
},
collect,
validateAll,
noteSaveAttempt() { validationAttempted = true; updateCatalogViews(); },
validate() {
const missing = rows.find((row) => row.slot.slot === 'main' && !row.model.trim());
if (missing) return 'Choose the Main model.';
if (rows.some((row) => row.slot.slot === 'fallback' && !row.model.trim())) return 'Choose a model for every fallback, or remove the empty row.';
if (rows.some((row) => !Number.isSafeInteger(Number(row.context || 0)) || Number(row.context || 0) < 0)) return 'Context windows must be positive whole numbers, or Auto.';
return '';
return validateAll()[0] || '';
},
destroy() {
destroyed = true; disposeStatus?.(); disposeStatus = null;
disposeChoosers();
for (const request of requests.values()) request.controller.abort();
},
};

View file

@ -73,6 +73,8 @@ import { accountRowFacts } from './harness_accounts.js';
modelContextWindows: {},
apiAccessOpen: false,
apiBudgetOpen: false,
subagentsOpen: false,
reviewersOpen: false,
localSourceOpen: Boolean(INITIAL_STATE.localSource),
moreProvidersOpen: Boolean(
INITIAL_STATE.cloudruKey || INITIAL_STATE.minimaxKey || INITIAL_STATE.deepseekKey
@ -103,6 +105,7 @@ import { accountRowFacts } from './harness_accounts.js';
let agentsStep = null;
let modelSources = [];
let catalogGeneration = 0;
const stepScrollPositions = new Map();
const modelRoles = createModelRolesEditor({ hostId: 'onboarding-model-roles', onChange: (settings) => {
adoptModelSettings(settings);
state.modelsDirty = true;
@ -133,8 +136,9 @@ import { accountRowFacts } from './harness_accounts.js';
function applySetupPreview(response) {
if (!state.modelsDirty && response.model_settings) {
const previous = JSON.stringify(draftSettings());
adoptModelSettings(response.model_settings);
loadModelRoles();
if (JSON.stringify(draftSettings()) !== previous) loadModelRoles();
}
if (!state.reviewerDraftDirty && response.reviewer_slots) {
state.reviewerSlots = typeof response.reviewer_slots === 'string'
@ -148,12 +152,10 @@ import { accountRowFacts } from './harness_accounts.js';
if (generation !== catalogGeneration) return;
modelSources = catalog.model_sources || [];
modelRoles.adoptCatalog(catalog);
if (state.currentStep === 'summary') render();
else syncCurrentStepActionState();
refreshSummary();
}).catch(() => {}); // An unread catalog never invents a model source.
}
if (state.currentStep === 'summary') render();
else syncCurrentStepActionState();
refreshSummary();
}
function loadModelRoles() {
@ -263,6 +265,8 @@ import { accountRowFacts } from './harness_accounts.js';
if (next) next.disabled = nextButtonShouldBeDisabled();
const quick = document.getElementById('quick-start-btn');
if (quick) quick.hidden = !hasModelSubscription();
const error = root.querySelector('.wizard-error');
if (error) error.textContent = state.error;
}
function markStepEdited() {
@ -401,10 +405,7 @@ import { accountRowFacts } from './harness_accounts.js';
function nextStep() {
const error = validateCurrentStep();
state.error = error;
if (error) {
render();
return;
}
if (error) return syncCurrentStepActionState();
if (['accounts', 'providers'].includes(state.currentStep)) applyModelDefaults(false);
if (['accounts', 'providers', 'models', 'review_mode', 'budget'].includes(state.currentStep)) {
// Preview is enrichment, never a navigation gate. Refresh in the
@ -414,17 +415,27 @@ import { accountRowFacts } from './harness_accounts.js';
}
const index = STEP_ORDER.indexOf(state.currentStep);
if (index >= 0 && index < STEP_ORDER.length - 1) {
state.currentStep = STEP_ORDER[index + 1];
navigateStep(STEP_ORDER[index + 1]);
}
state.error = '';
render();
}
function previousStep() {
const index = STEP_ORDER.indexOf(state.currentStep);
if (index > 0) state.currentStep = STEP_ORDER[index - 1];
if (index > 0) navigateStep(STEP_ORDER[index - 1], { restorePosition: true });
}
// Navigation owns the viewport. Status/catalog repaints never reset it or
// take focus from an edited field; Back returns to the retained step draft.
function navigateStep(stepId, { restorePosition = false } = {}) {
if (stepId === state.currentStep) return;
stepScrollPositions.set(state.currentStep, window.scrollY);
state.currentStep = stepId;
state.error = '';
render();
const heading = root.querySelector('.step-title');
heading?.setAttribute('tabindex', '-1');
heading?.focus({ preventScroll: true });
window.scrollTo({ top: restorePosition ? stepScrollPositions.get(stepId) || 0 : 0, left: 0, behavior: 'instant' });
}
const apiRequest = fetchJson;
@ -499,9 +510,9 @@ import { accountRowFacts } from './harness_accounts.js';
<button type="button" class="btn btn-ghost" id="wizard-local-start">Start local runtime</button>
<button type="button" class="btn btn-ghost" id="wizard-local-stop" disabled>Stop</button>
<button type="button" class="btn btn-ghost" id="wizard-local-test" disabled>Test tool calling</button>
<span id="wizard-local-status" class="wizard-runtime-status">Status: Offline</span>
<span id="wizard-local-status" class="wizard-runtime-status" role="status">Status: Offline</span>
</div>
<div id="wizard-local-test-result" class="wizard-test-result"></div>
<div id="wizard-local-test-result" class="wizard-test-result" role="status"></div>
`;
}
@ -595,26 +606,26 @@ import { accountRowFacts } from './harness_accounts.js';
function providerKeyField({ id, label, placeholder, value, note, inputType }) {
const type = inputType || 'password';
return `
<div class="field">
<div class="field ui-field">
<div class="field-label-row">
<label for="${escapeHtml(id)}">${escapeHtml(label)}</label>
<button class="field-clear" data-clear="${escapeHtml(id)}" type="button">Clear</button>
<button class="field-clear" data-clear="${escapeHtml(id)}" type="button" aria-label="Clear ${escapeHtml(label)}">Clear</button>
</div>
<input id="${escapeHtml(id)}" type="${escapeHtml(type)}" placeholder="${escapeHtml(placeholder)}" value="${escapeHtml(value)}">
<div class="field-note">${escapeHtml(note)}</div>
<input id="${escapeHtml(id)}" class="ui-control" type="${escapeHtml(type)}" aria-describedby="${escapeHtml(id)}-help" placeholder="${escapeHtml(placeholder)}" value="${escapeHtml(value)}">
<div id="${escapeHtml(id)}-help" class="field-note ui-field-help">${escapeHtml(note)}</div>
</div>
`;
}
function localInputField([id, stateKey, label, placeholder, note, className, type = 'text', min = '', step = '']) {
const clear = ['local-source', 'local-filename', 'local-chat-format'].includes(id)
? `<button class="field-clear" data-clear="${id}" type="button">Clear</button>`
? `<button class="field-clear" data-clear="${id}" type="button" aria-label="Clear ${label}">Clear</button>`
: '';
return `
<div class="${className}">
<div class="${className} ui-field">
<div class="field-label-row"><label for="${id}">${label}</label>${clear}</div>
<input id="${id}" type="${type}" ${min ? `min="${min}"` : ''} ${step ? `step="${step}"` : ''} placeholder="${placeholder}" value="${escapeHtml(state[stateKey])}">
${note ? `<div class="field-note">${note}</div>` : ''}
<input id="${id}" class="ui-control" type="${type}" ${note ? `aria-describedby="${id}-help"` : ''} ${min ? `min="${min}"` : ''} ${step ? `step="${step}"` : ''} placeholder="${placeholder}" value="${escapeHtml(state[stateKey])}">
${note ? `<div id="${id}-help" class="field-note ui-field-help">${note}</div>` : ''}
</div>
`;
}
@ -662,24 +673,24 @@ import { accountRowFacts } from './harness_accounts.js';
</summary>
<div class="wizard-collapse-body">
<div class="field-grid">
<div class="field">
<div class="field ui-field">
<div class="field-label-row">
<label for="local-preset">Preset</label>
<button class="field-clear" data-clear="local-preset" type="button">Clear</button>
<button class="field-clear" data-clear="local-preset" type="button" aria-label="Clear local preset">Clear</button>
</div>
<select id="local-preset">
<select id="local-preset" class="ui-control" aria-describedby="local-preset-help">
<option value="" ${localPreset === '' ? 'selected' : ''}>None</option>
${Object.entries(LOCAL_PRESETS).map(([id, preset]) => `<option value="${escapeHtml(id)}" ${localPreset === id ? 'selected' : ''}>${escapeHtml(preset.label)}</option>`).join('')}
<option value="custom" ${localPreset === 'custom' ? 'selected' : ''}>Custom source</option>
</select>
<div class="field-note">Most people can ignore this. Open it only if you want local GGUF routing.</div>
<div id="local-preset-help" class="field-note ui-field-help">Most people can ignore this. Open it only if you want local GGUF routing.</div>
</div>
<div class="field">
<div class="field-label-row"><label>Local routing</label></div>
<div class="selection-row">
${LOCAL_ROUTING_MODES.map((mode) => `<button class="selection-pill ${state.localRoutingMode === mode.value ? 'active' : ''}" data-local-mode="${escapeHtml(mode.value)}" type="button">${escapeHtml(mode.buttonLabel || mode.label)}</button>`).join('')}
<div class="field ui-field">
<div class="field-label-row"><label id="local-routing-label">Local routing</label></div>
<div class="selection-row" role="group" aria-labelledby="local-routing-label" aria-describedby="local-routing-help">
${LOCAL_ROUTING_MODES.map((mode) => `<button class="selection-pill ${state.localRoutingMode === mode.value ? 'active' : ''}" data-local-mode="${escapeHtml(mode.value)}" aria-pressed="${state.localRoutingMode === mode.value}" type="button">${escapeHtml(mode.buttonLabel || mode.label)}</button>`).join('')}
</div>
<div class="field-note">Ignored unless a local model source is configured below.</div>
<div id="local-routing-help" class="field-note ui-field-help">Ignored unless a local model source is configured below.</div>
</div>
${LOCAL_FIELDS.map(localInputField).join('')}
</div>
@ -744,13 +755,14 @@ import { accountRowFacts } from './harness_accounts.js';
async function reviewAndStart() {
if (validateProvidersStep()) return;
state.error = '';
const originStep = state.currentStep;
const ready = await agentsStep.refreshSubagentsPreview({ force: true });
if (state.currentStep !== originStep) return;
if (!ready) {
state.error = agentsStep.previewError || 'Setup suggestions could not be prepared. Try again or choose your models manually.';
render(); return;
syncCurrentStepActionState(); return;
}
state.currentStep = 'summary';
render();
navigateStep('summary');
}
function subscriptionQuotaHtml() {
@ -794,7 +806,7 @@ import { accountRowFacts } from './harness_accounts.js';
</div>
${profile === 'openai-compatible' ? renderCompatibleModelLoader() : ''}
${modelRolesHost('onboarding-model-roles')}
<details class="wizard-collapse"><summary>Available subagents</summary>
<details class="wizard-collapse" data-collapse="subagents" ${state.subagentsOpen ? 'open' : ''}><summary>Available subagents</summary>
<div class="wizard-collapse-body">${availableSubagentsEditorHost('onboarding-available-subagents')}</div>
</details>
`;
@ -813,12 +825,12 @@ import { accountRowFacts } from './harness_accounts.js';
</div>
</div>
<div class="wizard-inline-note">${state.reviewerSlots ? 'Your reviewer assignments are ready below. You can change every reviewer, including deep self-review.' : 'Reviewer assignments will be prepared from your connected access.'}</div>
<details class="wizard-collapse"><summary>Reviewers</summary>
<details class="wizard-collapse" data-collapse="reviewers" ${state.reviewersOpen ? 'open' : ''}><summary>Reviewers</summary>
<div class="wizard-collapse-body">${renderReviewerSlotsSection()}</div>
</details>
<div class="wizard-choice-grid">
${REVIEW_MODES.map((mode) => `
<button type="button" class="wizard-choice ${escapeHtml(mode.className || mode.value)} ${state.reviewEnforcement === mode.value ? 'active' : ''}" data-review-mode="${escapeHtml(mode.value)}">
<button type="button" class="wizard-choice ${escapeHtml(mode.className || mode.value)} ${state.reviewEnforcement === mode.value ? 'active' : ''}" data-review-mode="${escapeHtml(mode.value)}" aria-pressed="${state.reviewEnforcement === mode.value}">
<span class="tone">${escapeHtml(mode.tone)}</span>
<h3>${escapeHtml(mode.label)}</h3>
<p>${escapeHtml(mode.copy)}</p>
@ -830,20 +842,20 @@ import { accountRowFacts } from './harness_accounts.js';
<p class="field-note">${escapeHtml(runtimeModeCopy)}</p>
<div class="wizard-choice-grid three">
${RUNTIME_MODES.map((mode) => `
<button type="button" class="wizard-choice ${escapeHtml(mode.className || mode.value)} ${runtimeMode === mode.value ? 'active' : ''}" data-runtime-mode="${escapeHtml(mode.value)}">
<button type="button" class="wizard-choice ${escapeHtml(mode.className || mode.value)} ${runtimeMode === mode.value ? 'active' : ''}" data-runtime-mode="${escapeHtml(mode.value)}" aria-pressed="${runtimeMode === mode.value}">
<span class="tone">${escapeHtml(mode.tone)}</span>
<h3>${escapeHtml(mode.label)}</h3>
<p>${escapeHtml(mode.copy)}</p>
</button>
`).join('')}
</div>
<div class="field">
<div class="field ui-field">
<div class="field-label-row">
<label for="skills-repo-path">External skills repo (optional)</label>
<button class="field-clear" data-clear="skills-repo-path" type="button">Clear</button>
<button class="field-clear" data-clear="skills-repo-path" type="button" aria-label="Clear external skills repo">Clear</button>
</div>
<input id="skills-repo-path" type="text" placeholder="~/Ouroboros/skills or /absolute/path/to/skills" value="${escapeHtml(state.skillsRepoPath || '')}">
<div class="field-note">Optional. Extra discovery root on top of the in-data-plane <code>data/skills/{native,clawhub,external}/</code> tree. Leave empty if you do not maintain your own skills checkout — Ouroboros never clones/pulls this directory.</div>
<input id="skills-repo-path" class="ui-control" type="text" aria-describedby="skills-repo-path-help" placeholder="~/Ouroboros/skills or /absolute/path/to/skills" value="${escapeHtml(state.skillsRepoPath || '')}">
<div id="skills-repo-path-help" class="field-note ui-field-help">Optional. Extra discovery root on top of the in-data-plane <code>data/skills/{native,clawhub,external}/</code> tree. Leave empty if you do not maintain your own skills checkout — Ouroboros never clones/pulls this directory.</div>
</div>
</div>
`;
@ -864,10 +876,10 @@ import { accountRowFacts } from './harness_accounts.js';
${BUDGET_FIELDS.map((field) => `
<div class="panel-card">
<h3>${escapeHtml(field.title)}</h3>
<div class="field">
<div class="field ui-field">
<label for="${escapeHtml(field.inputId)}">${escapeHtml(field.label)}</label>
<input id="${escapeHtml(field.inputId)}" type="number" min="${escapeHtml(field.min || '0.01')}" step="${escapeHtml(field.step || 'any')}" value="${escapeHtml(state[field.stateKey])}">
<div class="field-note">${escapeHtml(field.note)}</div>
<input id="${escapeHtml(field.inputId)}" class="ui-control" type="number" aria-describedby="${escapeHtml(field.inputId)}-help" min="${escapeHtml(field.min || '0.01')}" step="${escapeHtml(field.step || 'any')}" value="${escapeHtml(state[field.stateKey])}">
<div id="${escapeHtml(field.inputId)}-help" class="field-note ui-field-help">${escapeHtml(field.note)}</div>
</div>
</div>
`).join('')}
@ -875,13 +887,22 @@ import { accountRowFacts } from './harness_accounts.js';
`;
}
function renderSummaryStep() {
const summary = summaryRows().map(([label, value]) => `
function summaryRowsHtml() {
return summaryRows().map(([label, value]) => `
<div class="summary-kv">
<strong>${escapeHtml(label)}</strong>
<span>${escapeHtml(value)}</span>
</div>
`).join('');
}
function refreshSummary() {
const summary = root.querySelector('.summary-card');
if (summary) summary.innerHTML = summaryRowsHtml();
syncCurrentStepActionState();
}
function renderSummaryStep() {
return `
<div class="step-header">
<div>
@ -889,7 +910,7 @@ import { accountRowFacts } from './harness_accounts.js';
<p class="step-copy">${escapeHtml(STEP_META.summary.copy)}</p>
</div>
</div>
<div class="summary-card">${summary}</div>
<div class="summary-card">${summaryRowsHtml()}</div>
`;
}
@ -978,7 +999,7 @@ import { accountRowFacts } from './harness_accounts.js';
` : ''}
</div>
</div>
<div class="wizard-error">${escapeHtml(state.error)}</div>
<div class="wizard-error" role="status">${escapeHtml(state.error)}</div>
`}
</div>
</div>
@ -1023,22 +1044,6 @@ import { accountRowFacts } from './harness_accounts.js';
}
function bindProvidersStep() {
// Scoped per-collapse binding: a bare `.wizard-collapse` selector only
// reaches the FIRST details element, which silently drops the toggle
// persistence of every later collapse on the step.
const collapseStateKeys = {
'api-access': 'apiAccessOpen',
'more-providers': 'moreProvidersOpen',
'local-model': 'localSourceOpen',
};
Object.entries(collapseStateKeys).forEach(([collapseId, stateKey]) => {
const details = root.querySelector(`[data-collapse="${collapseId}"]`);
if (details) {
details.addEventListener('toggle', () => {
state[stateKey] = details.open;
});
}
});
const localPreset = document.getElementById('local-preset');
const localSource = document.getElementById('local-source');
const localFilename = document.getElementById('local-filename');
@ -1079,14 +1084,7 @@ import { accountRowFacts } from './harness_accounts.js';
bindStateInput(localContext, 'localContextLength');
bindStateInput(localGpuLayers, 'localGpuLayers');
bindStateInput(localChatFormat, 'localChatFormat');
root.querySelectorAll('[data-local-mode]').forEach((button) => {
button.addEventListener('click', () => {
state.localRoutingMode = button.getAttribute('data-local-mode');
state.error = '';
agentsStep?.invalidateGeneratedPreview();
render();
});
});
bindChoices('data-local-mode', 'localRoutingMode');
if (LOCAL_RUNTIME_CONTROLS) {
startLocalStatusPolling();
document.getElementById('wizard-local-start')?.addEventListener('click', async () => {
@ -1124,6 +1122,7 @@ import { accountRowFacts } from './harness_accounts.js';
document.getElementById('wizard-local-stop')?.addEventListener('click', async () => {
try {
await apiRequest('/api/local-model/stop', { method: 'POST' });
setLocalTestResult('', 'muted');
updateLocalStatus();
} catch (error) {
setLocalTestResult(`Stop failed: ${error.message}`, 'error');
@ -1220,6 +1219,19 @@ import { accountRowFacts } from './harness_accounts.js';
syncCurrentStepActionState();
}
function bindChoices(attribute, stateKey) {
const buttons = root.querySelectorAll(`[${attribute}]`);
buttons.forEach((button) => button.addEventListener('click', () => {
state[stateKey] = button.getAttribute(attribute);
buttons.forEach((choice) => {
const selected = choice.getAttribute(attribute) === state[stateKey];
choice.classList.toggle('active', selected);
choice.setAttribute('aria-pressed', String(selected));
});
markStepEdited();
}));
}
function bindReviewModeStep() {
initReviewerSlots({ onChange: () => {
state.reviewerDraftDirty = true;
@ -1229,30 +1241,14 @@ import { accountRowFacts } from './harness_accounts.js';
} });
adoptSubagentRoster({ OUROBOROS_SUBAGENTS: state.availableSubagents });
if (state.reviewerSlots) applyReviewerSlotsDraft(state.reviewerSlots);
root.querySelectorAll('[data-review-mode]').forEach((button) => {
button.addEventListener('click', () => {
state.reviewEnforcement = button.getAttribute('data-review-mode');
state.error = '';
agentsStep?.invalidateGeneratedPreview();
render();
});
});
root.querySelectorAll('[data-runtime-mode]').forEach((button) => {
button.addEventListener('click', () => {
state.runtimeMode = button.getAttribute('data-runtime-mode');
state.error = '';
agentsStep?.invalidateGeneratedPreview();
render();
});
});
bindChoices('data-review-mode', 'reviewEnforcement');
bindChoices('data-runtime-mode', 'runtimeMode');
const skillsInput = document.getElementById('skills-repo-path');
if (skillsInput) skillsInput.addEventListener('input', () => { state.skillsRepoPath = skillsInput.value; markStepEdited(); });
syncCurrentStepActionState();
}
function bindBudgetStep() {
const disclosure = root.querySelector('[data-collapse="api-budget"]');
disclosure?.addEventListener('toggle', () => { state.apiBudgetOpen = disclosure.open; });
BUDGET_FIELDS.forEach((field) => {
const input = document.getElementById(field.inputId);
if (input) input.addEventListener('input', () => { state[field.stateKey] = input.value; markStepEdited(); });
@ -1422,10 +1418,7 @@ import { accountRowFacts } from './harness_accounts.js';
: '';
state.error = providersError || modelsError || reviewError || budgetError
|| subagentsError || previewError;
if (state.error) {
render();
return;
}
if (state.error) return syncCurrentStepActionState();
state.saving = true;
state.error = '';
render();
@ -1462,6 +1455,17 @@ import { accountRowFacts } from './harness_accounts.js';
return;
}
bindClearButtons();
// Bind every disclosure independently; keeping only the first one
// loses later groups and the position of their draft on Back.
const collapseStateKeys = {
'api-access': 'apiAccessOpen', 'api-budget': 'apiBudgetOpen',
'more-providers': 'moreProvidersOpen', 'local-model': 'localSourceOpen',
subagents: 'subagentsOpen', reviewers: 'reviewersOpen',
};
root.querySelectorAll('[data-collapse]').forEach((details) => {
const key = collapseStateKeys[details.dataset.collapse];
if (key) details.addEventListener('toggle', () => { state[key] = details.open; });
});
document.getElementById('back-btn')?.addEventListener('click', previousStep);
document.getElementById('next-btn')?.addEventListener('click', () => {
if (state.currentStep === 'summary') saveWizard();

View file

@ -7,6 +7,7 @@
// honest trust line: attaching gives the agent write+shell in that folder
// (notification model — attaching IS the grant; no second confirmation).
import { openConfirmDialog } from './confirm_dialog.js';
import { bindDialogFocus, bindMenu } from './ui_interactions.js';
// Mirrored from the frozen backend PROJECT_NAME_MAX contract.
const PROJECT_NAME_MAX = 80;
@ -23,9 +24,9 @@ export function openNewProjectDialog({ apiClient, onCreated }) {
<button type="button" class="btn btn-default btn-sm" data-np-cancel aria-label="Close">Close</button>
</div>
<div class="marketplace-modal-body">
<label class="new-project-field">
<label class="new-project-field ui-field">
<span>Name</span>
<input class="files-modal-input" data-np-name type="text" placeholder="My project" maxlength="${maxNameLength}">
<input class="files-modal-input ui-control" data-np-name name="project-name" type="text" placeholder="My project" maxlength="${maxNameLength}">
</label>
<fieldset class="new-project-sources">
<legend>Working folder</legend>
@ -39,21 +40,22 @@ export function openNewProjectDialog({ apiClient, onCreated }) {
<div class="new-project-browser-path" data-np-path></div>
<div class="new-project-browser-list" data-np-dirs></div>
</div>
<label class="local-toggle" title="Runs git init + an 'attach snapshot' commit of the current state with a local identity. Never done without this checkbox.">
<input type="checkbox" data-np-initgit>
<p class="new-project-selected-target ui-field-help" data-np-selected role="status" aria-live="polite">No folder selected.</p>
<label class="local-toggle ui-field ui-field-inline" title="Runs git init + an 'attach snapshot' commit of the current state with a local identity. Never done without this checkbox.">
<input class="ui-checkbox" type="checkbox" name="project-init-git" data-np-initgit>
Initialize git here if missing (attach-snapshot commit)
</label>
</div>
<div class="new-project-source-detail" data-np-clone hidden>
<label class="new-project-field">
<label class="new-project-field ui-field">
<span>Git URL</span>
<input class="files-modal-input" data-np-giturl type="text" placeholder="https://github.com/user/repo.git or git@github.com:user/repo.git">
<input class="files-modal-input ui-control" data-np-giturl name="project-git-url" type="text" placeholder="https://github.com/user/repo.git or git@github.com:user/repo.git">
</label>
</div>
<p class="new-project-trust-note" data-np-trust hidden>
The agent gets <strong>read, write and shell</strong> in this folder when working on this project's tasks.
</p>
<p class="new-project-error" data-np-error hidden></p>
<p class="new-project-error" data-np-error role="alert" hidden></p>
</div>
<div class="marketplace-modal-actions">
<button type="button" class="btn btn-default" data-np-cancel>Cancel</button>
@ -64,6 +66,9 @@ export function openNewProjectDialog({ apiClient, onCreated }) {
const q = (sel) => backdrop.querySelector(sel);
let selectedDir = '';
let browsePath = '';
let settled = false;
let browseRequest = 0;
let disposeFocus = () => {};
const setError = (text) => {
const el = q('[data-np-error]');
@ -72,11 +77,13 @@ export function openNewProjectDialog({ apiClient, onCreated }) {
};
async function renderDirs(path) {
const request = ++browseRequest;
const listEl = q('[data-np-dirs]');
const pathEl = q('[data-np-path]');
listEl.textContent = 'Loading…';
try {
const data = await apiClient.fsDirs(path);
if (settled || request !== browseRequest) return;
browsePath = data.path;
pathEl.textContent = '';
const cur = document.createElement('strong');
@ -89,7 +96,9 @@ export function openNewProjectDialog({ apiClient, onCreated }) {
select.addEventListener('click', () => {
selectedDir = data.path;
q('[data-np-trust]').hidden = false;
renderDirs(data.path);
q('[data-np-selected]').textContent = `Selected folder: ${selectedDir}`;
select.classList.replace('btn-default', 'btn-primary');
select.textContent = 'Selected ✓';
});
pathEl.appendChild(select);
listEl.textContent = '';
@ -123,6 +132,7 @@ export function openNewProjectDialog({ apiClient, onCreated }) {
listEl.appendChild(more);
}
} catch (e) {
if (settled || request !== browseRequest) return;
listEl.textContent = `Cannot browse: ${e?.message || e}`;
}
}
@ -140,20 +150,16 @@ export function openNewProjectDialog({ apiClient, onCreated }) {
radio.addEventListener('change', syncSource);
});
let settled = false;
const finish = (value) => {
if (settled) return;
settled = true;
document.removeEventListener('keydown', onKey);
disposeFocus();
backdrop.remove();
resolve(value);
};
const onKey = (event) => {
if (event.key === 'Escape') finish(null);
};
document.addEventListener('keydown', onKey);
async function create() {
if (settled || q('[data-np-create]').disabled) return;
setError('');
const name = (q('[data-np-name]').value || '').trim();
const source = backdrop.querySelector('input[name="np-source"]:checked')?.value || 'fileless';
@ -179,6 +185,7 @@ export function openNewProjectDialog({ apiClient, onCreated }) {
finish(data?.project || null);
onCreated?.(data?.project || null);
} catch (e) {
if (settled) return;
const detail = e?.body?.error || e?.message || String(e);
const code = e?.body?.error_code || '';
setError(code === 'auth_required'
@ -193,7 +200,10 @@ export function openNewProjectDialog({ apiClient, onCreated }) {
else if (event.target.closest('[data-np-create]')) create();
});
document.body.appendChild(backdrop);
q('[data-np-name]').focus();
disposeFocus = bindDialogFocus(q('[role="dialog"]'), {
initialFocus: q('[data-np-name]'),
onEscape: () => finish(null),
});
});
}
@ -205,44 +215,19 @@ export async function openProjectRowMenu(project, { apiClient, anchorEl, onChang
const maxNameLength = PROJECT_NAME_MAX;
closeOpenProjectRowMenu?.();
const menu = document.createElement('div');
menu.className = 'project-row-menu';
menu.className = 'project-row-menu ui-popup';
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', `Actions for ${project.name || project.id}`);
menu.innerHTML = `
<button type="button" role="menuitem" data-prm="rename">Rename…</button>
<button type="button" role="menuitem" class="danger" data-prm="delete">Delete project…</button>
`;
const rect = anchorEl.getBoundingClientRect();
const close = ({ restoreFocus = false } = {}) => {
menu.remove();
document.removeEventListener('click', onDoc, true);
document.removeEventListener('keydown', onKey, true);
if (closeOpenProjectRowMenu === close) closeOpenProjectRowMenu = null;
if (restoreFocus && anchorEl.isConnected) anchorEl.focus();
};
const onDoc = (event) => { if (!menu.contains(event.target)) close(); };
const onKey = (event) => {
const items = Array.from(menu.querySelectorAll('[role="menuitem"]'));
const index = items.indexOf(document.activeElement);
if (event.key === 'Escape') {
event.preventDefault();
close({ restoreFocus: true });
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const delta = event.key === 'ArrowDown' ? 1 : -1;
items[(index + delta + items.length) % items.length]?.focus();
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault();
items[event.key === 'Home' ? 0 : items.length - 1]?.focus();
}
};
closeOpenProjectRowMenu = close;
document.addEventListener('click', onDoc, true);
document.addEventListener('keydown', onKey, true);
let binding;
const close = (options) => binding?.close(options);
menu.addEventListener('click', async (event) => {
const action = event.target.closest('[data-prm]')?.dataset?.prm;
if (!action) return;
close();
close({ restoreFocus: true });
if (action === 'rename') {
const res = await openConfirmDialog({
title: 'Rename project',
@ -292,17 +277,15 @@ export async function openProjectRowMenu(project, { apiClient, anchorEl, onChang
}
});
document.body.appendChild(menu);
const menuRect = menu.getBoundingClientRect();
const margin = 8;
const top = Math.min(
Math.max(margin, rect.bottom + 4),
Math.max(margin, window.innerHeight - menuRect.height - margin),
);
const left = Math.min(
Math.max(margin, rect.right - menuRect.width),
Math.max(margin, window.innerWidth - menuRect.width - margin),
);
menu.style.setProperty('--prm-top', `${Math.round(top)}px`);
menu.style.setProperty('--prm-left', `${Math.round(left)}px`);
menu.querySelector('[role="menuitem"]')?.focus();
anchorEl.setAttribute('aria-haspopup', 'menu');
anchorEl.setAttribute('aria-expanded', 'true');
binding = bindMenu(menu, {
anchor: anchorEl,
onClose: () => {
menu.remove();
anchorEl.setAttribute('aria-expanded', 'false');
if (closeOpenProjectRowMenu === close) closeOpenProjectRowMenu = null;
},
});
closeOpenProjectRowMenu = close;
}

View file

@ -12,10 +12,9 @@
// model/effort/account are shown as READ-ONLY derived facts (the roster
// stays their SSOT); the stored forms are mutually exclusive:
// {slot_id, route, effort} XOR {slot_id, subagent_id, effort}.
// * On the API route the model id is a FREE-TEXT input with a datalist of
// catalog suggestions — the same catalog-assisted entry the model cards
// use. On a harness route the MODEL is a dropdown fed by Claudexor
// discovery. No invalid combinations can be composed.
// * Model ids use the same editable chooser as Models and Available subagents.
// Source/account/effort remain short native selects. Catalogs suggest values;
// they never assign one or erase an undiscovered saved selection.
// * The provider shown for a delegated row is the HARNESS NAME (codex,
// claude, cursor, …) — never "Claudexor", and never a `provider::model`
// string syntax.
@ -34,6 +33,7 @@ import {
parseAvailableSubagentsSetting,
} from './subagents_settings.js';
import { escapeHtmlAttr as escapeHtml } from './utils.js';
import { modelChooserHtml, bindModelChoosers } from './model_chooser.js';
export const ROUTE_KIND_API = 'api_chat';
export const ROUTE_KIND_SESSION = routeEditor.ROUTE_KIND_AGENT_SESSION;
@ -480,6 +480,7 @@ export function advisoryRouteTransition(prev, decoded, memory = {}) {
const state = {
loaded: false,
loadedDraft: null,
configError: '',
loadError: '',
source: '',
@ -509,6 +510,8 @@ const state = {
accountsKnown: false,
store: claudexorStatus,
disposers: [],
disposeChoosers: () => {},
saveAttempted: false,
onChange: () => {},
};
@ -615,7 +618,9 @@ export function renderReviewerSlotsSection() {
</div>
<div class="reviewer-slots-group">
<h4 class="reviewer-slots-heading">Deep self-review</h4>
<div class="settings-inline-note">
<details class="settings-subsection">
<summary>How whole-system review works</summary>
<div class="settings-inline-note settings-subsection-body">
Who runs <code>/review</code>, the whole-system review against BIBLE.md. An API model here
receives ONE packed review — the repository Atlas plus the full memory whitelist in a single
large-context call (unlike the advisory, whose API model runs an inspection episode). A
@ -625,6 +630,7 @@ export function renderReviewerSlotsSection() {
inline byte-exact — memory is never receipt-checked. The row's effort outranks the Behavior-tab deep
self-review effort; every report starts with a provenance header naming the delivery.
</div>
</details>
<div id="reviewer-deep-review-row" class="reviewer-slot-rows"></div>
</div>
</div>
@ -694,6 +700,7 @@ function subagentIdentityMarkup(row) {
function rowHtml(row, group) {
const { catalogKnown, accountsKnown } = state;
const label = `${group === 'triad' ? 'Triad' : 'Scope'} reviewer ${categoryRows(group).indexOf(row) + 1}`;
if (row.subagent_id) {
const last = state.lastExecutions[row.slot_id];
const lastText = last ? describeLastExecution(last) : '';
@ -703,8 +710,8 @@ function rowHtml(row, group) {
<div class="reviewer-slot-row" data-slot-group="${group}" data-slot-id="${escapeHtml(row.slot_id)}">
${subagentIdentityMarkup(row)}
<div class="reviewer-slot-controls">
${reviewerPickerHtml('data-slot-route aria-label="Reviewer"', row)}
${effortSelectHtml('data-slot-effort aria-label="Reasoning effort"', row.effort || '', 'subagent default')}
${reviewerPickerHtml(`data-slot-route aria-label="${label} source"`, row)}
${effortSelectHtml(`data-slot-effort aria-label="${label} reasoning effort"`, row.effort || '', 'subagent default')}
<button type="button" class="btn btn-default" data-slot-remove title="Remove this slot">Remove</button>
</div>
<div class="reviewer-slot-meta muted"${last ? ` title="${escapeHtml(lastRunMetaTitle(last))}"` : ''}>${escapeHtml(metaParts.join(' · '))}</div>
@ -731,11 +738,11 @@ function rowHtml(row, group) {
<div class="reviewer-slot-row" data-slot-group="${group}" data-slot-id="${escapeHtml(row.slot_id)}">
${reviewerRouteIdentityMarkup(row.route, harnessesById(), { catalogKnown, modelSources: state.modelSources })}
<div class="reviewer-slot-controls">
${reviewerPickerHtml('data-slot-route aria-label="Reviewer"', row)}
${session ? '' : routeEditor.routeModelInputHtml('data-slot-custom-api aria-label="Model"', row.route, state.catalogModels, `reviewer-${row.slot_id}-models`)}
${session ? selectHtml('data-slot-model aria-label="Harness model"', [{ label: '', options: modelOptions }], split.model) : ''}
${routeEditor.routeSupportsAccount(row.route) ? selectHtml('data-slot-profile aria-label="Credential account"', [{ label: '', options: profileOptions }], row.route.profile_id || '') : ''}
${effortSelectHtml('data-slot-effort aria-label="Reasoning effort"', row.effort, surfaceDefault)}
${reviewerPickerHtml(`data-slot-route aria-label="${label} source"`, row)}
${session ? modelChooserHtml(`data-slot-model aria-label="${label} agent model"`, split.model, `reviewer-${row.slot_id}-models`, modelOptions, { placeholder: 'Engine default model' })
: routeEditor.routeModelInputHtml(`data-slot-custom-api aria-label="${label} model"`, row.route, state.catalogModels, `reviewer-${row.slot_id}-models`)}
${routeEditor.routeSupportsAccount(row.route) ? selectHtml(`data-slot-profile aria-label="${label} account"`, [{ label: '', options: profileOptions }], row.route.profile_id || '') : ''}
${effortSelectHtml(`data-slot-effort aria-label="${label} reasoning effort"`, row.effort, surfaceDefault)}
<button type="button" class="btn btn-default" data-slot-remove title="Remove this slot">Remove</button>
</div>
<div class="reviewer-slot-meta muted"${last ? ` title="${escapeHtml(lastRunMetaTitle(last))}"` : ''}>${escapeHtml(metaParts.join(' · '))}</div>
@ -754,7 +761,7 @@ function singletonHtml(spec) {
const last = state.lastExecutions[spec.lastKey];
const lastText = last ? describeLastExecution(last) : '';
const enabled = spec.enabledToggle
? `<label class="local-toggle"><input type="checkbox" data-${a}-enabled ${row.enabled !== false ? 'checked' : ''}> Enabled</label>`
? `<label class="local-toggle ui-field ui-field-inline"><input class="ui-checkbox" type="checkbox" data-${a}-enabled aria-label="${spec.ariaName} enabled" ${row.enabled !== false ? 'checked' : ''}> Enabled</label>`
: '';
const meta = (parts) => `<div class="reviewer-slot-meta muted"${last ? ` title="${escapeHtml(lastRunMetaTitle(last))}"` : ''}>${escapeHtml(parts.join(' · '))}</div>`;
if (row.subagent_id) {
@ -800,7 +807,7 @@ function singletonHtml(spec) {
${enabled}
${reviewerPickerHtml(`data-${a}-route aria-label="${spec.ariaName} reviewer"`, row, { apiLabel: spec.apiLabel })}
${session
? selectHtml(`data-${a}-model aria-label="${spec.ariaName} harness model"`, [{ label: '', options: modelOptions }], split.model)
? modelChooserHtml(`data-${a}-model aria-label="${spec.ariaName} agent model"`, split.model, `${a}-models`, modelOptions, { placeholder: 'Engine default model' })
: routeEditor.routeModelInputHtml(`data-${a}-api-model aria-label="${spec.ariaName} model id"`, row.route, state.catalogModels, `${a}-models`, { placeholder: split.subscription ? 'Choose a model' : spec.apiPlaceholder })}
${routeEditor.routeSupportsAccount(row.route) ? selectHtml(`data-${a}-profile aria-label="${spec.ariaName} credential account"`, [{ label: '', options: profileOptions }], row.route?.profile_id || '') : ''}
${effortSelectHtml(
@ -814,7 +821,79 @@ function singletonHtml(spec) {
`;
}
function renderRows() {
function reviewerRowError(row, allowEmpty = false) {
const none = { message: '', field: '' };
if (row.subagent_id) return none;
const route = row.route || {};
if (!allowEmpty && !String(route.target_id || '').trim()) return { message: 'Choose a model or reviewer source.', field: 'model' };
if (routeEditor.routeModelFields(route).subscription && !routeEditor.routeModelFields(route).model.trim()) return { message: 'Choose a subscription model.', field: 'model' };
const conflict = route.kind === ROUTE_KIND_SESSION
? routeEditor.compoundSessionEffortConflict(route.target_id, row.effort) : '';
return conflict ? { message: `Reasoning effort conflicts with the model’s ${conflict} effort.`, field: 'effort' } : none;
}
function reviewerValidationRows(draft) {
return [
...['triad', 'scope'].flatMap((group) => (draft[group] || []).map((row, index) => ({
row, key: row.slot_id, label: `${group === 'triad' ? 'Triad' : 'Scope'} reviewer ${index + 1}`,
}))),
...(draft.advisory ? [{ row: draft.advisory, key: 'advisory', label: 'Advisory', allowEmpty: true }] : []),
...(draft.deepReview && draft.deepReview.materialized !== false
? [{ row: draft.deepReview, key: 'deep-review', label: 'Deep self-review' }] : []),
];
}
export function reviewerSlotsDraftErrors(draft) {
if (!draft.loaded) return [];
const errors = ['triad', 'scope'].filter((group) => !draft[group]?.length)
.map((group) => `Add at least one ${group} reviewer.`);
for (const { row, label, allowEmpty } of reviewerValidationRows(draft)) {
const { message: error } = reviewerRowError(row, allowEmpty);
if (error) errors.push(`${label}: ${error}`);
}
return errors;
}
export function validateReviewerSlots() { return reviewerSlotsDraftErrors(state); }
export function noteReviewerSlotsSaveAttempt() {
state.saveAttempted = true;
reviewerValidationRows(state).forEach(({ row }) => { row._uiAttempted = true; });
paintReviewerValidation();
}
function paintReviewerValidation() {
if (typeof document === 'undefined') return;
const section = document.getElementById('reviewer-slots-section');
if (!section) return;
for (const { row, key, allowEmpty } of reviewerValidationRows(state)) {
const el = [...section.querySelectorAll('[data-slot-id]')].find((node) => node.dataset.slotId === key)
|| section.querySelector(`[data-${key}-row]`);
if (!el) continue;
const { message: error, field: invalidField } = row._uiAttempted ? reviewerRowError(row, allowEmpty) : { message: '', field: '' };
let message = el.querySelector('[data-reviewer-validation]');
if (!message) { message = document.createElement('div'); message.dataset.reviewerValidation = ''; message.className = 'ui-status ui-field-help'; el.appendChild(message); }
message.id = `reviewer-${key}-error`;
Object.assign(message, { textContent: error, hidden: !error });
message.dataset.tone = 'error';
el.toggleAttribute('data-invalid', Boolean(error));
const meta = el.querySelector('.reviewer-slot-meta');
if (meta) meta.id = `reviewer-${key}-meta`;
el.querySelectorAll('input, select').forEach((field) => {
field.setAttribute('aria-describedby', `${meta?.id || ''} ${message.id}`.trim());
const isModel = field.hasAttribute('data-model-chooser');
const isEffort = field.matches('[data-slot-effort], [data-advisory-effort], [data-deep-review-effort]');
field.setAttribute('aria-invalid', String(Boolean(error) && (invalidField === 'model' ? isModel : isEffort)));
});
}
if (state.saveAttempted && !state.loadError && !state.configError) {
const box = document.getElementById('reviewer-slots-error');
const errors = validateReviewerSlots();
if (box) Object.assign(box, { textContent: errors.join(' '), hidden: !errors.length });
}
}
function renderRows({ discoveryOnly = false } = {}) {
const active = document.activeElement;
const owner = active?.closest?.('.reviewer-slot-row');
const marker = owner && active.getAttributeNames().find((name) => name.startsWith('data-'));
@ -844,6 +923,26 @@ function renderRows() {
const singles = Object.values(SINGLETONS).map((spec) => [spec, document.getElementById(spec.rowId)]);
const boxes = Object.entries(CATEGORIES).map(([group, cat]) => [group, cat, document.getElementById(cat.rowsId)]);
if (singles.some(([, box]) => !box) || boxes.some(([, , box]) => !box)) return;
const updateDiscovery = (current, html) => {
if (!current) return;
const template = document.createElement('template');
template.innerHTML = html;
const desired = template.content.firstElementChild;
routeEditor.updateRouteControlOptions(current, desired);
for (const selector of ['.reviewer-slot-meta', '.reviewer-slot-route-identity']) {
const before = current.querySelector(selector), after = desired.querySelector(selector);
if (before && after) { before.innerHTML = after.innerHTML; before.title = after.title; }
}
};
if (discoveryOnly) {
for (const [group, , box] of boxes) for (const row of categoryRows(group)) {
updateDiscovery([...box.querySelectorAll('[data-slot-id]')].find((node) => node.dataset.slotId === row.slot_id), rowHtml(row, group));
}
for (const [spec, box] of singles) updateDiscovery(box.firstElementChild, singletonHtml(spec));
paintReviewerValidation();
return;
}
state.disposeChoosers();
for (const [group, cat, box] of boxes) {
box.innerHTML = categoryRows(group).map((row) => rowHtml(row, group)).join('')
|| `<div class="muted">${cat.empty}</div>`;
@ -856,6 +955,8 @@ function renderRows() {
}
for (const [spec, box] of singles) box.innerHTML = singletonHtml(spec);
bindRowEvents();
state.disposeChoosers = bindModelChoosers(document.getElementById('reviewer-slots-section'));
paintReviewerValidation();
if (marker) {
const row = [...document.querySelectorAll('.reviewer-slot-row')]
.find((element) => rowAttrs.every((attr) => element.getAttribute(attr.name) === attr.value));
@ -903,7 +1004,7 @@ function bindRowEvents() {
}
state.onChange();
});
rowEl.querySelector('[data-slot-model]')?.addEventListener('change', (event) => {
rowEl.querySelector('[data-slot-model]')?.addEventListener('input', (event) => {
const split = splitSessionTarget(row.route.target_id);
row.route.target_id = composeSessionTarget(split.harness, event.target.value);
state.onChange();
@ -971,7 +1072,7 @@ function bindSingletonEvents(section, spec) {
// Mirrors of the triad-row model/api/profile handlers. These controls
// exist only on their own kind's branch of singletonHtml, so each
// querySelector binds at most one of them per render.
el.querySelector(`[data-${a}-model]`)?.addEventListener('change', (event) => {
el.querySelector(`[data-${a}-model]`)?.addEventListener('input', (event) => {
const split = splitSessionTarget(row.route?.target_id);
row.route.target_id = composeSessionTarget(split.harness, event.target.value);
edited();
@ -1023,6 +1124,8 @@ function addRow(group) {
// One projection for both persisted settings and the unsaved setup compiler.
export function applyReviewerSlotsDraft(data) {
state.loadedDraft = structuredClone(data);
state.saveAttempted = false;
state.loadError = '';
state.configError = String(data.config_error || '');
state.source = String(data.source || '');
@ -1033,7 +1136,7 @@ export function applyReviewerSlotsDraft(data) {
// normalized to the shared api_chat spelling here — the retired legacy
// 'api' (Claude-SDK) kind is parse-only server-side and this UI never
// writes it again.
const rowIn = (row) => ({ ...row, route: { ...(row.route || {}) } });
const rowIn = (row) => ({ ...row, _uiAttempted: false, route: { ...(row.route || {}) } });
state.triad = Array.isArray(data.triad) ? data.triad.map(rowIn) : [];
state.scope = Array.isArray(data.scope) ? data.scope.map(rowIn) : [];
state.advisory = data.advisory ? rowIn(data.advisory) : state.advisory;
@ -1070,13 +1173,20 @@ export function applyReviewerSlotsDraft(data) {
renderRows();
}
export async function reloadReviewerSlots() {
/** Explicit discard returns to the last loaded view, including unsaved defaults. */
export function discardReviewerSlotsDraft() {
if (state.loadedDraft) applyReviewerSlotsDraft(state.loadedDraft);
}
export async function reloadReviewerSlots({ isCurrent = () => true } = {}) {
try {
const resp = await apiFetch('/api/reviewer-slots', { cache: 'no-store' });
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
if (!isCurrent()) return;
applyReviewerSlotsDraft(data);
} catch (error) {
if (!isCurrent()) return;
// A transport failure is NOT a verdict on the saved configuration: the
// config-error banner accuses the owner's settings of blocking review, and
// a network blip must never say that. Separate field, separate sentence.
@ -1094,7 +1204,7 @@ export async function reloadReviewerSlots() {
// surface binding repaints these rows when the snapshot lands.
await boundedStatusRefresh(state.store);
adoptStatusSnapshot();
renderRows();
renderRows({ discoveryOnly: true });
}
function adoptStatusSnapshot() {
@ -1124,7 +1234,7 @@ export function adoptSubagentRoster(settings) {
export function initReviewerSlots({ onChange, store = claudexorStatus } = {}) {
destroyReviewerSlots();
state.onChange = typeof onChange === 'function' ? onChange : () => {};
state.onChange = () => { paintReviewerValidation(); if (typeof onChange === 'function') onChange(); };
state.store = store;
// Seed from whatever the shared store already holds, so a render triggered
// before the first notify (the model-catalog event) is not rendered from a
@ -1147,7 +1257,7 @@ export function initReviewerSlots({ onChange, store = claudexorStatus } = {}) {
state.harnesses, state.profilesByHarness]);
if (next === signature) return;
signature = next;
renderRows();
renderRows({ discoveryOnly: true });
},
}));
for (const [group, cat] of Object.entries(CATEGORIES)) {
@ -1157,16 +1267,16 @@ export function initReviewerSlots({ onChange, store = claudexorStatus } = {}) {
const items = event?.detail?.items || [];
state.modelSources = event?.detail?.model_sources || [];
state.catalogModels = items.map((item) => String(item.value || item.id || '')).filter(Boolean);
renderRows();
renderRows({ discoveryOnly: true });
};
document.addEventListener('settings-model-catalog:updated', onCatalog);
state.disposers.push(() => document.removeEventListener('settings-model-catalog:updated', onCatalog));
// The initial load is driven by settings.js loadSettings(), which awaits
// reloadReviewerSlots() BEFORE taking the clean-draft baseline — otherwise
// the async arrival of the rows would read as an unsaved edit.
// Settings makes the loaded document usable immediately. Late reviewer
// enrichment updates its baseline only while that draft remains clean.
}
export function destroyReviewerSlots() {
state.disposeChoosers();
for (const dispose of state.disposers.splice(0)) {
try { dispose(); } catch (err) { /* a broken disposer must not block the rest */ }
}
@ -1176,11 +1286,9 @@ export function destroyReviewerSlots() {
// Never author the setting from an UNLOADED view (an unrelated save must not
// overwrite the owner's configuration with an empty page), and a transport
// failure is not a verdict on the saved value either. But a LOADED view always
// sends what it shows — including an empty triad/scope. The old empty-set
// guard silently dropped the key, so deleting every row Saved "successfully"
// while saving nothing; now the backend's own 400 («triad needs at least one
// slot») surfaces through the existing failed-save status. Validation SSOT
// stays on the backend — no client-side duplicate.
// serializes what it shows, including empty groups. Settings validates the
// current draft before submitting; this pure serializer never hides invalid
// rows. Backend validation remains authoritative for every other caller.
export function reviewerSlotsSavePayload({ loaded = false, loadError = '', triad = [], scope = [], advisory, deepReview } = {}) {
if (loadError || !loaded) return {};
return { OUROBOROS_REVIEWER_SLOTS: buildReviewerSlotsSetting({ triad, scope, advisory, deepReview }) };

View file

@ -5,6 +5,7 @@
import { formatRelativeAge } from './ui_helpers.js';
import { escapeHtmlAttr as escapeHtml } from './utils.js';
import { modelChooserHtml, updateModelChooserOptions } from './model_chooser.js';
export const ROUTE_KIND_API_MODEL = 'api_model';
export const ROUTE_KIND_AGENT_SESSION = 'agent_session';
@ -80,9 +81,23 @@ export function routeModelSuggestions(route, items = []) {
/** Catalog suggestions, not an entitlement or context claim for the selected account. */
export function routeModelInputHtml(attrs, route, items, listId, { placeholder = 'Choose a model' } = {}) {
const values = routeModelSuggestions(route, items);
return `<input ${attrs} list="${escapeHtml(listId)}" value="${escapeHtml(routeModelFields(route).model)}"
placeholder="${escapeHtml(placeholder)}" autocomplete="off" spellcheck="false">
<datalist id="${escapeHtml(listId)}">${values.map((value) => `<option value="${escapeHtml(value)}"></option>`).join('')}</datalist>`;
return modelChooserHtml(attrs, routeModelFields(route).model, listId, values, { placeholder });
}
/** Catalog repaint owns suggestions and native option labels, never a draft node. */
export function updateRouteControlOptions(current, desired) {
for (const field of current.querySelectorAll('select')) {
const marker = [...field.attributes].find((attr) => attr.name.startsWith('data-'));
if (!marker) continue;
const next = [...desired.querySelectorAll('select')]
.find((node) => node.getAttribute(marker.name) === marker.value);
if (next && field.innerHTML !== next.innerHTML) {
const value = field.value;
field.innerHTML = next.innerHTML;
field.value = value;
}
}
updateModelChooserOptions(current, desired);
}
export function mintStableId(prefix, takenIds) {
@ -291,7 +306,7 @@ export function selectHtml(attrs, groups, selected) {
return group.label
? `<optgroup label="${escapeHtml(group.label)}">${body}</optgroup>` : body;
}).join('');
return `<select ${attrs}>${options}</select>`;
return `<select class="ui-control" ${attrs}>${options}</select>`;
}
export function effortSelectHtml(attrs, selected, surfaceDefault = 'route default') {

View file

@ -1,8 +1,8 @@
import { refreshModelCatalog } from './settings_catalog.js';
import { bindEffortSegments, syncEffortSegments } from './settings_controls.js';
import { bindEffortSegments, syncEffortSegments, readCustomSecretDraft, collectCustomSecretDraft, paintSettingsFieldErrors, settingsWriteFailure } from './settings_controls.js';
import { bindLocalModelControls } from './settings_local_model.js';
import { applyMcpSettings, collectMcpSettings, initMcpSettings } from './mcp_settings.js';
import { adoptSubagentRoster, collectReviewerSlots, initReviewerSlots, reloadReviewerSlots } from './reviewer_slots.js';
import { applyMcpSettings, collectMcpSettings, initMcpSettings, validateMcpSettings } from './mcp_settings.js';
import { adoptSubagentRoster, collectReviewerSlots, initReviewerSlots, reloadReviewerSlots, validateReviewerSlots, noteReviewerSlotsSaveAttempt, discardReviewerSlotsDraft } from './reviewer_slots.js';
import {
applySubagentsSettings,
availableSubagentsPreviewPayload,
@ -95,9 +95,8 @@ function isTruthySetting(value) {
return value === true || ['true', '1', 'yes', 'on'].includes(normalized);
}
// `owner` names the surface a message belongs to (today only the Available
// subagents roster claims one); a later message from anyone else drops it, so
// an owner may clear its own stale message but never a newer one.
// A loading, validation or editor owner may update its own status. A later
// message from anyone else drops that ownership, protecting the newer result.
function setStatus(text, tone = 'ok', owner = '') {
const status = byId('settings-status');
status.textContent = text;
@ -135,37 +134,31 @@ function applySecretInputs(root, settings) {
}
function wireSecretRow(row) {
const input = row.querySelector('.secret-input');
const toggle = row.querySelector('[data-row-secret-toggle]');
const clear = row.querySelector('[data-row-secret-clear]');
if (input) input.addEventListener('input', () => { if (input.value.trim()) delete input.dataset.forceClear; });
if (toggle && input) toggle.addEventListener('click', () => { input.type = input.type === 'password' ? 'text' : 'password'; toggle.textContent = input.type === 'password' ? 'Show' : 'Hide'; });
if (clear && input) clear.addEventListener('click', () => {
input.value = ''; input.type = 'password'; input.dataset.forceClear = '1';
if (toggle) toggle.textContent = 'Show';
markSettingsDirty();
// Programmatic value changes fire no 'input' event, but a Clear is an
// edit like any other: the provider-test verdict listener must see it.
input.dispatchEvent(new Event('input', { bubbles: true }));
});
}
function customSecretRow(key = '', value = '') {
const id = `custom-secret-${Math.random().toString(36).slice(2)}`;
const ordinal = document.querySelectorAll('[data-custom-secret-row]').length + 1;
const row = document.createElement('div');
row.className = 'settings-custom-secret-row';
row.dataset.customSecretRow = '1';
row.dataset.originalKey = key;
row.innerHTML = `
<div class="form-field settings-custom-secret-key"><label>Key</label><input data-custom-secret-key value="${escapeHtml(key)}" placeholder="SLACK_WEBHOOK_URL" spellcheck="false"></div>
<div class="form-field settings-custom-secret-value"><label>Value</label><div class="secret-input-row">
<input id="${id}" data-custom-secret-value class="secret-input" type="password" value="${escapeHtml(value || '')}" placeholder="Secret value">
<button type="button" class="btn btn-default" data-row-secret-toggle>Show</button>
<button type="button" class="btn btn-default" data-row-secret-clear>Clear</button>
</div><div class="settings-inline-note" data-custom-secret-error hidden></div></div>
<div class="form-field ui-field settings-custom-secret-key"><label for="${id}-key">Custom key ${ordinal}</label><input id="${id}-key" name="custom-key" type="text" class="ui-control" data-custom-secret-key value="${escapeHtml(key)}" placeholder="SLACK_WEBHOOK_URL" spellcheck="false"></div>
<div class="form-field ui-field settings-custom-secret-value"><label for="${id}">Value for ${escapeHtml(key || `custom key ${ordinal}`)}</label><div class="secret-input-row">
<input id="${id}" name="custom-value" data-custom-secret-value class="secret-input ui-control" type="password" value="${escapeHtml(value || '')}" placeholder="Secret value">
<button type="button" class="btn btn-default secret-toggle" data-target="${id}" data-row-secret-toggle>Show</button>
<button type="button" class="btn btn-default secret-clear" data-target="${id}" data-row-secret-clear>Clear</button>
</div></div>
<button type="button" class="btn btn-default settings-custom-secret-remove" data-custom-secret-remove>Remove</button>`;
wireSecretRow(row);
row.querySelector('[data-custom-secret-remove]')?.addEventListener('click', () => { row.dataset.removeCustomSecret = '1'; row.hidden = true; markSettingsDirty(); });
bindSecretInputs(row);
row.querySelector('[data-custom-secret-value]').dataset.appliedValue = value;
row.querySelector('[data-custom-secret-key]').addEventListener('input', (event) => {
row.querySelector(`label[for="${id}"]`).textContent = `Value for ${event.target.value.trim() || `custom key ${ordinal}`}`;
});
row.querySelector('[data-custom-secret-remove]')?.addEventListener('click', () => {
if (row.dataset.originalKey) { row.dataset.removeCustomSecret = '1'; row.hidden = true; }
else row.remove();
markSettingsDirty();
});
return row;
}
@ -195,12 +188,13 @@ function renderRequestedSkillSecrets(root, skills, settings) {
const id = `requested-secret-${idx}`;
const el = document.createElement('div');
el.className = 'settings-requested-secret-row';
el.innerHTML = `<div class="form-field"><label>${escapeHtml(key)}</label><div class="secret-input-row">
<input id="${id}" data-secret-setting="${escapeHtml(key)}" class="secret-input" type="password" value="${escapeHtml(settings[key] || '')}" placeholder="Secret value">
<button type="button" class="btn btn-default" data-row-secret-toggle>Show</button>
<button type="button" class="btn btn-default" data-row-secret-clear>Clear</button>
el.innerHTML = `<div class="form-field ui-field"><label for="${id}">${escapeHtml(key)}</label><div class="secret-input-row">
<input id="${id}" name="${escapeHtml(key)}" data-secret-setting="${escapeHtml(key)}" class="secret-input ui-control" type="password" value="${escapeHtml(settings[key] || '')}" placeholder="Secret value">
<button type="button" class="btn btn-default secret-toggle" data-target="${id}" data-row-secret-toggle>Show</button>
<button type="button" class="btn btn-default secret-clear" data-target="${id}" data-row-secret-clear>Clear</button>
</div></div>`;
wireSecretRow(el); host.appendChild(el);
el.querySelector('.secret-input').dataset.appliedValue = settings[key] || '';
bindSecretInputs(el); host.appendChild(el);
});
}
@ -232,9 +226,9 @@ function renderExtensionSettingsSections(root, sections) {
const disabled = Boolean(component.disabled);
const fieldOptions = {
disabled,
fieldClass: 'form-field',
inlineClass: 'settings-extension-checkbox',
helpClass: 'settings-inline-note',
fieldClass: 'form-field ui-field',
inlineClass: 'settings-extension-checkbox ui-field ui-field-inline',
helpClass: 'settings-inline-note ui-field-help',
};
return `
<form class="settings-extension-form" data-extension-settings-form data-extension-settings-key="${escapeHtml(formKey)}" data-skill="${escapeHtml(section.skill || '')}" data-route="${escapeHtml(rawRoute)}">
@ -316,7 +310,7 @@ function collectSecretValue(id, body) {
return;
}
const value = input.value;
if (value && !value.includes('...')) body[settingKey] = value;
if (value && value !== input.dataset.appliedValue) body[settingKey] = value;
}
@ -383,10 +377,10 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
page.activateSettingsTab(tabName);
}
};
bindSettingsTabs(page, { state });
const disposeSettingsTabs = bindSettingsTabs(page, { state });
bindSecretInputs(page);
bindEffortSegments(page);
bindLocalModelControls({ state });
const disposeLocalModel = bindLocalModelControls({ state });
// Best-effort About version from /api/health.
apiFetch('/api/health')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
@ -400,15 +394,20 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
let settingsLoaded = false;
let settingsBaseline = '';
let settingsDirty = false;
let draftRevision = 0;
let loadSequence = 0;
let settingsSaving = false;
let saveOutcomeUnknown = false;
let validationAttempted = false;
const providerTestGenerations = new Map();
const providerTestsInFlight = new Set();
const modelRoles = createModelRolesEditor({ hostId: 'settings-model-roles',
onChange: () => updateSettingsDirtyState() });
onChange: () => onSettingsEdited() });
modelRoles.mount();
initMcpSettings({ onChange: updateSettingsDirtyState });
initReviewerSlots({ onChange: () => updateSettingsDirtyState() });
initMcpSettings({ onChange: onSettingsEdited });
initReviewerSlots({ onChange: () => onSettingsEdited() });
initSubagentsSection({
onChange: () => updateSettingsDirtyState(),
onChange: () => onSettingsEdited(),
// The roster's section line and the footer message it owns read one
// verdict: when the judged rows come clean, the footer clears with the
// line and the tint — unless someone else has written the footer since.
@ -428,9 +427,9 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
function syncSettingsLoadState() {
const saveBtn = byId('btn-save-settings');
if (saveBtn) {
saveBtn.disabled = !settingsLoaded;
saveBtn.disabled = !settingsLoaded || settingsSaving || saveOutcomeUnknown;
saveBtn.title = settingsLoaded
? ''
? (saveOutcomeUnknown ? 'Reload Settings to check the previous save before saving again.' : '')
: 'Reload current settings successfully before saving.';
}
}
@ -468,8 +467,11 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
function snapshotSettingsDraft() {
return stableSerializeDraft({
...collectBody(),
OUROBOROS_RUNTIME_MODE_DRAFT: byId('s-runtime-mode')?.value || 'advanced',
OUROBOROS_CONTEXT_MODE_DRAFT: byId('s-context-mode')?.value || 'max',
// Raw controls retain invalid/empty values and owner-only settings
// that the transport payload intentionally normalizes or omits.
controls: Array.from(page.querySelectorAll('input[id^="s-"], select[id^="s-"], textarea[id^="s-"], [data-secret-setting], [data-model-role-context]'),
(input) => [input.id, input.value, input.checked, input.validity?.badInput || false, input.dataset.forceClear || '']),
customSecrets: readCustomSecretDraft(page),
});
}
@ -481,16 +483,22 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
}
function updateSettingsDirtyState() {
if (!settingsLoaded || !settingsBaseline) return;
const nextDirty = snapshotSettingsDraft() !== settingsBaseline;
const nextDirty = settingsLoaded && settingsBaseline
? snapshotSettingsDraft() !== settingsBaseline : draftRevision > 0;
if (nextDirty === settingsDirty) return;
settingsDirty = nextDirty;
const indicator = byId('settings-unsaved-indicator');
if (indicator) indicator.classList.toggle('is-visible', settingsDirty);
}
function onSettingsEdited() {
draftRevision += 1;
if (validationAttempted) renderValidation();
updateSettingsDirtyState();
}
let baselineSettleDisposer = null;
function armCleanBaselineOnStatusSettle() {
function armCleanBaselineOnStatusSettle(revision) {
// The sections' Claudexor status probe is fire-and-forget, so the
// baseline can be taken before the store-gated collectors have their
// facts — and their output changes when a snapshot lands (the accounts
@ -509,16 +517,18 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
// copy but never the canonical actor draft; re-baselining a DIRTY
// page would still absorb the owner's real row edit into the clean
// baseline, so it remains forbidden.
// Disclosed residual: a cold-daemon settle landing AFTER an owner
// edit stays inside the unsaved-changes diff until the next save —
// rare (the reloads wait a bounded beat for the probe first) and
// fail-safe (an over-eager indicator, never a lost edit).
if (!settingsDirty && settingsLoaded) setSettingsCleanBaseline();
// After an edit, any availability-only difference remains in the
// draft comparison until the next load/save; it cannot absorb edits.
if (revision === draftRevision && !settingsDirty && settingsLoaded) setSettingsCleanBaseline();
});
}
function discardUnsavedSettingsDraft() {
applySettings(currentSettings || {});
discardReviewerSlotsDraft();
renderCustomSecrets(page, currentSettings || {});
validationAttempted = false;
paintSettingsFieldErrors(page, []);
setSettingsCleanBaseline();
setStatus('', 'ok');
}
@ -658,52 +668,66 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
}
async function loadSettings() {
const sequence = ++loadSequence;
const revision = draftRevision;
const [data, extData] = await Promise.all([
apiClient.settings(),
apiClient.extensions().catch(() => ({})),
]);
if (!data || typeof data !== 'object' || Array.isArray(data) || data.error) {
throw new Error(data?.error || 'The server did not return a settings document.');
}
const sections = Array.isArray(extData?.live?.settings_sections)
? extData.live.settings_sections
: [];
if (sequence !== loadSequence || revision !== draftRevision) return false;
currentSettings = data;
applySettings(data);
renderExtensionSettingsSections(page, sections);
renderRequestedSkillSecrets(page, extData.skills || [], data);
renderCustomSecrets(page, data);
// Await reviewer config and the Available-subagents bounded status beat BEFORE the clean
// baseline: their async arrival must not read as an unsaved owner edit.
// (The Claudexor status probe inside them is fire-and-forget — a cold
// daemon must not hold the Save button — so its LATER settlement is
// re-baselined below.)
await Promise.all([reloadReviewerSlots(), reloadSubagentsSection()]);
// Mark the document loaded before taking the baseline. A generated
// preview may settle in the microtask between these statements; its
// clean-gated callback must be allowed to fold that exact draft into
// the baseline rather than leave a false unsaved change behind.
// This confirmed document can already be edited and saved. Optional
// reviewer/status reads must not hold its baseline or Save capability.
settingsLoaded = true;
saveOutcomeUnknown = false;
validationAttempted = false;
paintSettingsFieldErrors(page, []);
setSettingsCleanBaseline();
armCleanBaselineOnStatusSettle();
armCleanBaselineOnStatusSettle(revision);
_renderNetworkHint(data._meta);
markSettingsDirty = updateSettingsDirtyState;
syncSettingsLoadState();
await Promise.all([reloadReviewerSlots({ isCurrent: () => sequence === loadSequence && revision === draftRevision }), reloadSubagentsSection()]);
if (sequence !== loadSequence || revision !== draftRevision) {
updateSettingsDirtyState();
return false;
}
// Optional enrichment belongs in a still-clean baseline, never in an
// owner edit made while one of those reads was pending.
setSettingsCleanBaseline();
return true;
}
async function reloadSettingsWithFeedback() {
setStatus('Loading settings...', 'muted');
settingsLoaded = false;
syncSettingsLoadState();
if (settingsSaving) return;
if (settingsDirty && !(await confirmDiscardSettings('reload Settings'))) return;
const reloadSequence = loadSequence + 1;
setStatus('Loading settings...', 'muted', 'load');
try {
await loadSettings();
const applied = await loadSettings();
if (!applied && byId('settings-status').dataset.owner === 'load') {
setStatus('Settings were not reloaded because your draft changed while loading. Your edits are kept.', 'warn', 'load');
}
try {
await refreshModelCatalog({ button: byId('btn-refresh-model-catalog') });
setStatus('Settings loaded', 'ok');
if (applied && byId('settings-status').dataset.owner === 'load' && !settingsDirty && !settingsSaving && !saveOutcomeUnknown) setStatus('Settings loaded', 'ok');
} catch (error) {
setStatus(
if (applied && byId('settings-status').dataset.owner === 'load' && !settingsDirty && !settingsSaving && !saveOutcomeUnknown) setStatus(
`Settings loaded. Model catalog refresh failed: ${error.message || error}`,
'warn'
);
}
} catch (error) {
if (reloadSequence !== loadSequence) return;
settingsLoaded = false;
syncSettingsLoadState();
setStatus(
@ -714,14 +738,14 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
}
async function refreshSettingsAfterExtensionChange(reason = 'skills changed') {
if (extensionRefreshPending) return;
if (extensionRefreshPending || settingsSaving || saveOutcomeUnknown) return;
if (settingsDirty) {
setStatus(`Settings changed externally (${reason}). Reload after saving or discarding your draft.`, 'warn');
return;
}
extensionRefreshPending = true;
try {
await loadSettings();
if (!(await loadSettings())) return;
setStatus('Settings refreshed', 'ok');
} catch (error) {
setStatus(`Settings refresh failed: ${error.message || error}`, 'warn');
@ -782,31 +806,68 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
page.querySelectorAll('[data-secret-setting]').forEach((input) => {
collectSecretValue(input.id, body);
});
page.querySelectorAll('[data-custom-secret-row]').forEach((row) => {
const keyInput = row.querySelector('[data-custom-secret-key]');
const valueInput = row.querySelector('[data-custom-secret-value]');
const key = (keyInput?.value || '').trim().toUpperCase();
const error = row.querySelector('[data-custom-secret-error]');
if (!key) return;
if (!/^[A-Z][A-Z0-9_]{2,}$/.test(key)) { if (error) { error.hidden = false; error.textContent = 'Use uppercase letters, numbers, and underscores.'; } return; }
if (row.dataset.removeCustomSecret === '1' || valueInput?.dataset.forceClear === '1') { body[key] = ''; return; }
const value = valueInput?.value || '';
if (value && !value.includes('...')) body[key] = value;
});
Object.assign(body, collectCustomSecrets().values);
return body;
}
async function saveRuntimeModeViaNativeBridgeIfNeeded() {
const nextMode = byId('s-runtime-mode').value || 'advanced';
function collectCustomSecrets() {
const customKeys = new Set(currentSettings?._meta?.custom_secret_keys || []);
return collectCustomSecretDraft(readCustomSecretDraft(page),
Object.keys(currentSettings).filter((key) => !customKeys.has(key)));
}
function validationSummary(errors) {
return errors.length > 1 ? `${errors[0]} (${errors.length} fields need attention.)` : errors[0] || '';
}
function collectValidation() {
const fields = validateMcpSettings();
const cadence = byId('s-evo-cadence-n');
if (byId('s-post-task-evolution-mode')?.value === 'every_n' && !/^[1-9]\d*$/.test(cadence.value.trim())) {
fields.push({ input: cadence, message: 'Every-N cadence needs a whole number ≥ 1.' });
}
page.querySelectorAll('input[id^="s-"], select[id^="s-"], textarea[id^="s-"]').forEach((input) => {
if (input === cadence || fields.some((error) => error.input === input)
|| input.hasAttribute('data-model-role-context') || !input.willValidate || input.validity.valid) return;
const label = input.labels?.[0]?.textContent || input.name || input.id;
fields.push({ input, message: `${label.trim()}: ${input.validationMessage}` });
});
const rows = [...page.querySelectorAll('[data-custom-secret-row]')];
collectCustomSecrets().errors.forEach(({ index, field, message }) => {
if (rows[index]?.dataset.judged === '1') fields.push({ input: rows[index].querySelector(`[data-custom-secret-${field}]`), message });
});
const messages = [
...fields.map(({ message }) => message),
...modelRoles.validateAll(),
...validateSubagentsDraft().map((error) => `Available subagents: ${error}`),
...validateReviewerSlots(),
].filter(Boolean);
return { fields, messages };
}
function renderValidation() {
const { fields, messages } = collectValidation();
paintSettingsFieldErrors(page, fields);
if (byId('settings-status').dataset.owner === 'validation') setStatus(validationSummary(messages), 'warn', 'validation');
return messages;
}
async function confirmDiscardSettings(action) {
return openConfirmDialog({
title: 'Unsaved settings',
body: `You have unsaved settings changes. Discard them and ${action}?`,
confirmLabel: 'Discard and continue', cancelLabel: 'Stay',
});
}
async function saveRuntimeModeViaNativeBridgeIfNeeded(nextMode) {
const currentMode = currentSettings?.OUROBOROS_RUNTIME_MODE || 'advanced';
const bridge = window.pywebview?.api?.request_runtime_mode_change;
if (nextMode === currentMode) {
return bridge ? await bridge(nextMode) : await apiClient.ownerRuntimeMode(nextMode);
}
// Only the browser-side confirm is migrated to the in-house dialog; the
// desktop pywebview bridge path above stays exactly as it was.
const result = bridge
const result = nextMode === currentMode
? (bridge ? await bridge(nextMode) : await apiClient.ownerRuntimeMode(nextMode))
: bridge
? await bridge(nextMode)
: ((await openConfirmDialog({
title: 'Change runtime mode',
@ -814,17 +875,14 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
confirmLabel: 'Change mode',
}))
? await apiClient.ownerRuntimeMode(nextMode)
: { ok: false, error: 'Runtime mode change cancelled.' });
: { ok: false, saved: false, error: 'Runtime mode change cancelled.' });
if (!result || result.ok !== true) {
throw new Error(result?.error || 'Runtime mode change was cancelled.');
throw Object.assign(new Error(result?.error || 'Runtime mode change was cancelled.'), { body: result });
}
return result;
}
async function saveAutoGrantViaNativeBridgeIfNeeded() {
const checkbox = byId('s-auto-grant-reviewed-skills');
if (!checkbox) return null;
const nextEnabled = Boolean(checkbox.checked);
async function saveAutoGrantViaNativeBridgeIfNeeded(nextEnabled) {
const currentEnabled = isTruthySetting(currentSettings?.OUROBOROS_AUTO_GRANT_REVIEWED_SKILLS);
if (nextEnabled === currentEnabled) return null;
const bridge = window.pywebview?.api?.request_auto_grant_reviewed_skills_change;
@ -837,19 +895,16 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
confirmLabel: nextEnabled ? 'Enable' : 'Disable',
}))
? await apiClient.ownerAutoGrant(nextEnabled)
: { ok: false, error: 'Reviewed-skill auto-grant change cancelled.' });
: { ok: false, saved: false, error: 'Reviewed-skill auto-grant change cancelled.' });
if (!result || result.ok !== true) {
throw new Error(result?.error || 'Reviewed-skill auto-grant change was cancelled.');
throw Object.assign(new Error(result?.error || 'Reviewed-skill auto-grant change was cancelled.'), { body: result });
}
return result;
}
async function saveSafetyModeViaOwnerEndpointIfNeeded() {
async function saveSafetyModeViaOwnerEndpointIfNeeded(next) {
// Owner-only, dropped from the generic /api/settings POST — saved through the
// dedicated audited endpoint. Confirm on LOWERING coverage (full > light > off).
const input = byId('s-safety-mode');
if (!input) return null;
const next = input.value || 'full';
const current = currentSettings?.OUROBOROS_SAFETY_MODE || 'full';
if (next === current) return null;
const lowering = (_SAFETY_MODE_RANK[next] ?? 2) < (_SAFETY_MODE_RANK[current] ?? 2);
@ -862,11 +917,11 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
confirmLabel: 'Lower safety mode',
danger: true,
});
if (!ok) throw new Error('Safety mode change was not confirmed.');
if (!ok) throw Object.assign(new Error('Safety mode change was not confirmed.'), { body: { saved: false } });
}
const result = await apiClient.ownerSafetyMode(next);
if (!result || result.ok !== true) {
throw new Error(result?.error || 'Safety mode change failed.');
throw Object.assign(new Error(result?.error || 'Safety mode change failed.'), { body: result });
}
return result;
}
@ -943,19 +998,17 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
return acked;
}
async function saveContextModeViaOwnerEndpointIfNeeded() {
const input = byId('s-context-mode');
if (!input) return null;
const next = input.value || 'max';
async function saveContextModeViaOwnerEndpointIfNeeded(next) {
const current = currentSettings?.OUROBOROS_CONTEXT_MODE || 'max';
if (next === current) return null;
const result = await apiClient.ownerContextMode(next);
if (!result || result.ok !== true) {
throw new Error(result?.error || 'Context mode change failed.');
throw Object.assign(new Error(result?.error || 'Context mode change failed.'), { body: result });
}
return result;
}
markSettingsDirty = onSettingsEdited;
syncSettingsLoadState();
syncRuntimeModeBridgeState();
syncAutoGrantBridgeState();
@ -967,25 +1020,22 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
// discard: opening a second dialog resolves the first as false (stay),
// so at most one confirmed leave runs discardUnsavedSettingsDraft().
setBeforePageLeave(async ({ from }) => {
if (from !== 'settings' || !settingsDirty) return true;
const leave = await openConfirmDialog({
title: 'Unsaved settings',
body: 'You have unsaved settings changes. Discard them and leave Settings?',
confirmLabel: 'Discard and leave',
cancelLabel: 'Stay',
});
if (from !== 'settings') return true;
if (settingsSaving) return false;
if (!settingsDirty) return true;
const leave = await confirmDiscardSettings('leave Settings');
if (leave) discardUnsavedSettingsDraft();
return leave;
});
}
page.addEventListener('input', updateSettingsDirtyState);
page.addEventListener('change', updateSettingsDirtyState);
page.addEventListener('input', onSettingsEdited);
page.addEventListener('change', onSettingsEdited);
page.addEventListener('click', (event) => {
if (event.target.closest('[data-effort-value], .secret-clear, [data-row-secret-clear], [data-custom-secret-remove]')) {
queueMicrotask(() => {
syncPostTaskEvolutionUi();
updateSettingsDirtyState();
onSettingsEdited();
});
}
});
@ -1020,9 +1070,17 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
});
const onModelCatalog = (event) => modelRoles.adoptCatalog(event.detail);
const beforeUnload = (event) => {
if (settingsDirty || settingsSaving) { event.preventDefault(); event.returnValue = ''; }
};
window.addEventListener('beforeunload', beforeUnload);
document.addEventListener('settings-model-catalog:updated', onModelCatalog);
window.addEventListener('pagehide', (event) => {
if (event.persisted) return;
disposeSettingsTabs();
window.removeEventListener('beforeunload', beforeUnload);
disposeLocalModel();
baselineSettleDisposer?.();
modelRoles.destroy();
document.removeEventListener('settings-model-catalog:updated', onModelCatalog);
});
@ -1110,6 +1168,7 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
let restartPending = false;
byId('btn-save-settings').addEventListener('click', async () => {
if (settingsSaving || saveOutcomeUnknown) return;
if (!settingsLoaded) {
setStatus('Reload current settings successfully before saving.', 'warn');
return;
@ -1118,21 +1177,24 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
// whichever validation aborts it below — so from here the roster shows
// its own errors beside the rows they name, not only in this status.
noteSubagentsSaveAttempt();
// Validate Every-N cadence before save: malformed N must NOT silently coerce
// into a valid (e.g. every-task) cadence. Abort with a visible error instead.
if (byId('s-post-task-evolution-mode')?.value === 'every_n'
&& !/^[1-9]\d*$/.test((byId('s-evo-cadence-n')?.value || '').trim())) {
setStatus('Every-N cadence needs a whole number ≥ 1.', 'warn');
return;
}
const subagentErrors = validateSubagentsDraft();
const modelError = modelRoles.validate();
if (modelError) { setStatus(modelError, 'warn'); return; }
if (subagentErrors.length) {
setStatus(`Available subagents: ${subagentErrors[0]}`, 'warn', 'subagents');
noteReviewerSlotsSaveAttempt();
modelRoles.noteSaveAttempt();
page.querySelectorAll('[data-custom-secret-row]').forEach((row) => { row.dataset.judged = '1'; });
validationAttempted = true;
const errors = renderValidation();
if (errors.length) {
setStatus(validationSummary(errors), 'warn', 'validation');
return;
}
const body = collectBody();
loadSequence += 1;
const sentRevision = draftRevision;
const ownerDraft = {
runtime: byId('s-runtime-mode').value || 'advanced',
autoGrant: Boolean(byId('s-auto-grant-reviewed-skills').checked),
context: byId('s-context-mode').value || 'max',
safety: byId('s-safety-mode').value || 'full',
};
const subagentsChanged = subagentSettingsFingerprint(body.OUROBOROS_SUBAGENTS)
!== subagentSettingsFingerprint(currentSettings?.OUROBOROS_SUBAGENTS);
@ -1140,13 +1202,21 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
// Capability probes on review-route changes make a save take seconds;
// an idle "Save Settings" over that window reads as a dead click.
const saveButton = byId('btn-save-settings');
settingsSaving = true;
setButtonBusy(saveButton, true);
setStatus('Saving…', 'muted');
// A pending restart LATCHES: a later save that needs no restart must
// not hide the button while the process still runs the old config.
if (!restartPending) byId('btn-restart-now')?.setAttribute('hidden', '');
let saved = false;
try {
const data = await apiClient.saveSettings(body);
if (data?.status !== 'saved') {
const error = new Error(data?.error || 'The server did not confirm the settings save.');
error.body = data;
throw error;
}
saved = true;
let runtimeModeResult = null;
let runtimeModeError = '';
let autoGrantResult = null;
@ -1156,24 +1226,32 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
let safetyModeResult = null;
let safetyModeError = '';
try {
runtimeModeResult = await saveRuntimeModeViaNativeBridgeIfNeeded();
runtimeModeResult = await saveRuntimeModeViaNativeBridgeIfNeeded(ownerDraft.runtime);
} catch (error) {
runtimeModeError = error.message || String(error);
const failure = settingsWriteFailure(error, "Runtime mode");
runtimeModeError = failure.text;
saveOutcomeUnknown ||= failure.unknown;
}
try {
autoGrantResult = await saveAutoGrantViaNativeBridgeIfNeeded();
autoGrantResult = await saveAutoGrantViaNativeBridgeIfNeeded(ownerDraft.autoGrant);
} catch (error) {
autoGrantError = error.message || String(error);
const failure = settingsWriteFailure(error, "Reviewed-skill auto-grant");
autoGrantError = failure.text;
saveOutcomeUnknown ||= failure.unknown;
}
try {
contextModeResult = await saveContextModeViaOwnerEndpointIfNeeded();
contextModeResult = await saveContextModeViaOwnerEndpointIfNeeded(ownerDraft.context);
} catch (error) {
contextModeError = error.message || String(error);
const failure = settingsWriteFailure(error, "Context mode");
contextModeError = failure.text;
saveOutcomeUnknown ||= failure.unknown;
}
try {
safetyModeResult = await saveSafetyModeViaOwnerEndpointIfNeeded();
safetyModeResult = await saveSafetyModeViaOwnerEndpointIfNeeded(ownerDraft.safety);
} catch (error) {
safetyModeError = error.message || String(error);
const failure = settingsWriteFailure(error, "Safety mode");
safetyModeError = failure.text;
saveOutcomeUnknown ||= failure.unknown;
}
let reviewAcks = 0;
let reviewAckError = '';
@ -1182,7 +1260,8 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
} catch (error) {
reviewAckError = error.message || String(error);
}
await loadSettings();
const ownerError = runtimeModeError || autoGrantError || contextModeError || safetyModeError;
const draftKept = ownerError || sentRevision !== draftRevision || !(await loadSettings());
syncAutoGrantBridgeState();
let statusMsg;
let statusType = 'ok';
@ -1215,7 +1294,7 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
statusType = 'warn';
}
if (runtimeModeError) {
statusMsg = `${statusMsg} Runtime mode was not changed: ${runtimeModeError}`;
statusMsg = `${statusMsg} ${runtimeModeError}`;
statusType = 'warn';
}
if (autoGrantResult) {
@ -1225,18 +1304,18 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
statusMsg = `${statusMsg} Context mode saved as ${contextModeResult.context_mode}.`;
}
if (contextModeError) {
statusMsg = `${statusMsg} Context mode was not changed: ${contextModeError}`;
statusMsg = `${statusMsg} ${contextModeError}`;
statusType = 'warn';
}
if (safetyModeResult?.safety_mode) {
statusMsg = `${statusMsg} Safety supervisor saved as ${safetyModeResult.safety_mode}.`;
}
if (safetyModeError) {
statusMsg = `${statusMsg} Safety mode was not changed: ${safetyModeError}`;
statusMsg = `${statusMsg} ${safetyModeError}`;
statusType = 'warn';
}
if (autoGrantError) {
statusMsg = `${statusMsg} Reviewed-skill auto-grant was not changed: ${autoGrantError}`;
statusMsg = `${statusMsg} ${autoGrantError}`;
statusType = 'warn';
}
if (reviewAcks > 0) {
@ -1246,6 +1325,10 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
statusMsg = `${statusMsg} The scope-reviewer window confirmation was not saved: ${reviewAckError}`;
statusType = 'warn';
}
if (draftKept) {
statusMsg += saveOutcomeUnknown ? '. Your draft is kept. Reload Settings to check before saving again.' : '. Your current draft is kept.';
statusType = 'warn';
}
setStatus(statusMsg, statusType);
if (data.restart_required || runtimeModeResult?.restart_required) {
restartPending = true;
@ -1253,9 +1336,18 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
if (restartPending) byId('btn-restart-now')?.removeAttribute('hidden');
window.dispatchEvent(new CustomEvent('ouro:settings-updated', { detail: { reason: 'settings saved', source: 'settings' } }));
} catch (e) {
setStatus('Failed to save: ' + e.message, 'warn');
const receipt = e?.body || e?.payload;
const confirmedSaved = saved || receipt?.saved === true;
saveOutcomeUnknown = !confirmedSaved && receipt?.saved !== false;
setStatus(confirmedSaved
? `Settings were saved, but a later step failed: ${e.message}. Your draft is kept.`
: saveOutcomeUnknown
? `Save outcome unknown: ${e.message}. Your draft is kept. Reload Settings to check before saving again.`
: `Settings were not saved: ${e.message}. Your draft is kept.`, 'warn');
} finally {
settingsSaving = false;
setButtonBusy(saveButton, false);
syncSettingsLoadState();
}
});

View file

@ -4,10 +4,17 @@ export function bindEffortSegments(root) {
const input = root.querySelector(`#${targetId}`);
if (!input) return;
const buttons = Array.from(group.querySelectorAll('[data-effort-value]'));
const label = input.previousElementSibling;
if (label?.tagName === 'LABEL') {
label.id = `${targetId}-label`;
group.setAttribute('role', 'group');
group.setAttribute('aria-labelledby', label.id);
}
function sync() {
buttons.forEach((button) => {
button.classList.toggle('active', button.dataset.effortValue === input.value);
button.setAttribute('aria-pressed', String(button.dataset.effortValue === input.value));
});
}
@ -30,6 +37,89 @@ export function syncEffortSegments(root) {
if (!input) return;
group.querySelectorAll('[data-effort-value]').forEach((button) => {
button.classList.toggle('active', button.dataset.effortValue === input.value);
button.setAttribute('aria-pressed', String(button.dataset.effortValue === input.value));
});
});
}
/** Read the complete custom-key draft, including blank and removed rows. No paint. */
export function readCustomSecretDraft(root) {
return Array.from(root.querySelectorAll('[data-custom-secret-row]'), (row) => {
const input = row.querySelector('[data-custom-secret-value]');
return {
key: row.querySelector('[data-custom-secret-key]')?.value || '',
value: input?.value || '',
appliedValue: input?.dataset.appliedValue || '',
originalKey: row.dataset.originalKey || '',
clear: input?.dataset.forceClear === '1',
removed: row.dataset.removeCustomSecret === '1',
};
});
}
/** Compile the existing custom-key wire shape and return every local error. */
export function collectCustomSecretDraft(rows, knownKeys = []) {
const values = {};
const errors = [];
const known = new Set(knownKeys);
const seen = new Map();
rows.forEach((row, index) => {
const key = row.key.trim().toUpperCase();
const fail = (message, field = 'key') => errors.push({ index, field, message });
if (row.removed) {
if (row.originalKey) values[row.originalKey] = '';
return;
}
if (!key) fail('Enter a key name, or remove this row.');
else if (!/^[A-Z][A-Z0-9_]{2,}$/.test(key)) fail('Use at least three letters, numbers or underscores, starting with a letter.');
else if (key.startsWith('OUROBOROS_') || known.has(key)) fail('This name is a built-in setting. Use its existing field.');
else if (seen.has(key)) {
fail(`Duplicate key ${key}. Keep one row for this key.`);
errors.push({ index: seen.get(key), field: 'key', message: `Duplicate key ${key}. Keep one row for this key.` });
} else if (row.originalKey && key !== row.originalKey && row.value === row.appliedValue && !row.clear) {
fail('Enter a value for the new key, or keep the saved name.', 'value');
} else if (row.clear) values[key] = '';
else if (row.value && row.value !== row.appliedValue) values[key] = row.value;
else if (!row.originalKey) fail('Enter a value for this key, or remove this row.', 'value');
if (key) seen.set(key, index);
});
return { values, errors };
}
/** Paint only errors owned by the Settings save gate; dirty reads never call this. */
export function paintSettingsFieldErrors(root, errors) {
root.querySelectorAll('[data-settings-validation]').forEach((input) => {
input.removeAttribute('aria-invalid');
const hint = root.querySelector(`#${input.id}-error`);
if (hint) { hint.hidden = true; hint.textContent = ''; }
delete input.dataset.settingsValidation;
});
errors.forEach(({ input, message }) => {
if (!input?.id) return;
let hint = root.querySelector(`#${input.id}-error`);
if (!hint) {
hint = input.ownerDocument.createElement('div');
hint.id = `${input.id}-error`;
hint.className = 'settings-inline-note ui-field-help';
hint.dataset.tone = 'error';
hint.setAttribute('role', 'status');
(input.closest('.form-field') || input.parentElement).appendChild(hint);
}
input.dataset.settingsValidation = '1';
input.setAttribute('aria-invalid', 'true');
const described = new Set((input.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean));
described.add(hint.id);
input.setAttribute('aria-describedby', [...described].join(' '));
hint.textContent = message;
hint.hidden = false;
});
}
/** Saved/unsaved/unknown are server receipts, never inferred from HTTP success. */
export function settingsWriteFailure(error, label = 'Settings') {
const receipt = error?.body || error?.payload;
const detail = error?.message || String(error);
if (receipt?.saved === true) return { unknown: false, text: `${label} was saved, but a later step failed: ${detail}` };
if (receipt?.saved === false) return { unknown: false, text: `${label} was not changed: ${detail}` };
return { unknown: true, text: `${label} outcome is unknown: ${detail}` };
}

View file

@ -1,5 +1,6 @@
import { showToast } from './toast.js';
import { apiFetch } from './api_client.js';
import { apiFetch, fetchJson } from './api_client.js';
import { setInlineStatus } from './ui_helpers.js';
function readLocalModelBody() {
@ -45,14 +46,18 @@ function setProgressBar(fraction) {
}
export function bindLocalModelControls({ state }) {
let destroyed = false;
let stopPending = false;
let ready = false;
async function updateLocalStatus() {
if (state.activePage !== 'settings') return;
if (destroyed || state.activePage !== 'settings') return;
try {
const resp = await apiFetch('/api/local-model/status', { cache: 'no-store' });
const d = await resp.json();
const d = await fetchJson('/api/local-model/status', { cache: 'no-store' });
if (destroyed) return;
const el = document.getElementById('local-model-status');
if (!el) return;
const isReady = d.status === 'ready';
ready = isReady;
const isInstalling = d.runtime_status === 'installing';
const isDownloading = d.status === 'downloading';
const runtimeMissing = d.runtime_status === 'missing' || d.runtime_status === 'install_error';
@ -74,7 +79,7 @@ export function bindLocalModelControls({ state }) {
el.textContent = text;
el.dataset.tone = isReady ? 'ok' : (d.status === 'error' || d.runtime_status === 'install_error' ? 'error' : 'muted');
document.getElementById('btn-local-stop').disabled = !isReady;
document.getElementById('btn-local-stop').disabled = stopPending || !isReady;
document.getElementById('btn-local-test').disabled = !isReady;
document.getElementById('btn-local-start').disabled = isInstalling || isDownloading;
@ -144,12 +149,29 @@ export function bindLocalModelControls({ state }) {
document.getElementById('btn-local-start').addEventListener('click', triggerStart);
document.getElementById('btn-local-stop').addEventListener('click', async () => {
if (destroyed || stopPending) return;
const button = document.getElementById('btn-local-stop');
const status = document.getElementById('local-model-action-status');
stopPending = true;
button.disabled = true;
button.setAttribute('aria-busy', 'true');
setInlineStatus(status, 'Stopping…', 'muted');
try {
await apiFetch('/api/local-model/stop', { method: 'POST' });
const result = await fetchJson('/api/local-model/stop', { method: 'POST' }, { rejectOkFalse: true });
if (destroyed) return;
if (result?.status !== 'stopped' || result?.error) throw new Error(result?.error || 'The server did not confirm Stop.');
setProgressBar(null);
updateLocalStatus();
setInlineStatus(status, 'Local model stopped.', 'ok');
status?.scrollIntoView({ block: 'nearest', behavior: 'auto' });
await updateLocalStatus();
} catch (e) {
showToast('Failed: ' + e.message, 'error');
if (!destroyed) {
setInlineStatus(status, `Stop failed: ${e.message}. You can retry Stop.`, 'error');
status?.scrollIntoView({ block: 'nearest', behavior: 'auto' });
}
} finally {
stopPending = false;
if (!destroyed) { button.disabled = !ready; button.removeAttribute('aria-busy'); }
}
});
@ -197,5 +219,6 @@ export function bindLocalModelControls({ state }) {
}
updateLocalStatus();
setInterval(updateLocalStatus, 3000);
const interval = setInterval(updateLocalStatus, 3000);
return () => { destroyed = true; clearInterval(interval); };
}

View file

@ -1,4 +1,4 @@
import { renderPageHeader, renderSegmentedField, renderTabStrip } from './page_header.js';
import { renderPageHeader, renderSegmentedField, renderTabStrip, bindTabStrip } from './page_header.js';
import { PAGE_ICONS } from './page_icons.js';
import { renderAgentAccountsSection, renderAgentsServiceBanner } from './harness_accounts.js';
import { renderReviewerSlotsSection } from './reviewer_slots.js';
@ -48,10 +48,10 @@ function providerCard({ id, title, icon, hint, body, open = false }) {
function secretField({ id, settingKey, label, placeholder }) {
return `
<div class="form-field">
<label>${label}</label>
<div class="form-field ui-field">
<label for="${id}">${label}</label>
<div class="secret-input-row">
<input id="${id}" data-secret-setting="${settingKey}" class="secret-input" type="password" placeholder="${placeholder}">
<input id="${id}" name="${settingKey}" data-secret-setting="${settingKey}" class="secret-input ui-control" type="password" placeholder="${placeholder}">
<button type="button" class="btn btn-default secret-toggle" data-target="${id}">Show</button>
<button type="button" class="btn btn-default secret-clear" data-target="${id}">Clear</button>
</div>
@ -60,7 +60,7 @@ function secretField({ id, settingKey, label, placeholder }) {
}
function plainField({ id, label, placeholder }) {
return `<div class="form-field"><label>${label}</label><input id="${id}" placeholder="${placeholder}"></div>`;
return `<div class="form-field ui-field"><label for="${id}">${label}</label><input id="${id}" name="${id}" type="text" class="ui-control" placeholder="${placeholder}"></div>`;
}
const PROVIDER_CARDS = [
@ -161,7 +161,11 @@ export const PROVIDER_TEST_INPUTS = Object.fromEntries(
function providerSettingsCard(spec) {
const fields = (spec.fields || [])
.map((field) => field.settingKey ? secretField(field) : plainField(field))
.map((field) => {
const named = { ...field, label: field.label === "API Key" || field.label === "Base URL"
? `${spec.title} ${field.label}` : field.label };
return field.settingKey ? secretField(named) : plainField(named);
})
.join('');
const test = spec.testProvider ? `
<div class="settings-action-row">
@ -200,7 +204,7 @@ const EFFORT_OPTIONS = [
function effortField({ id, label, defaultValue }) {
return `
<div class="settings-effort-card">
<label>${label}</label>
<label for="${id}">${label}</label>
<input id="${id}" type="hidden" value="${defaultValue}">
${renderSegmentedField({ target: id, options: EFFORT_OPTIONS })}
</div>
@ -272,7 +276,8 @@ export function renderSettingsPage() {
<div class="settings-tabs-bar">
<button type="button" class="settings-mobile-back" data-settings-back hidden>Settings</button>
${renderTabStrip({
items: SETTINGS_TABS,
items: SETTINGS_TABS.map((item) => ({ ...item,
tabId: `settings-tab-${item.value}`, panelId: `settings-panel-${item.value}` })),
active: 'providers',
dataAttr: 'data-settings-tab',
ariaLabel: 'Settings sections',
@ -305,9 +310,9 @@ export function renderSettingsPage() {
<div class="form-section compact">
<h3>Legacy Compatibility</h3>
<div class="form-row">
<div class="form-field">
<label>Legacy OpenAI Base URL</label>
<input id="s-openai-base-url" placeholder="https://api.openai.com/v1 or compatible endpoint">
<div class="form-field ui-field">
<label for="s-openai-base-url">Legacy OpenAI Base URL</label>
<input id="s-openai-base-url" placeholder="https://api.openai.com/v1 or compatible endpoint" class="ui-control" name="s-openai-base-url" type="text">
</div>
</div>
<div class="settings-inline-note">Backward-compatibility escape hatch for older installs. For new custom providers, use the dedicated <code>OpenAI Compatible</code> card instead.</div>
@ -321,10 +326,10 @@ export function renderSettingsPage() {
placeholder: 'Leave blank to keep the network surface open',
})}</div>
<div class="form-row">
<div class="form-field">
<label>Server Bind Host</label>
<input id="s-server-host" placeholder="127.0.0.1 or 0.0.0.0">
<div class="settings-inline-note">Use <code>127.0.0.1</code> for this machine only. Use <code>0.0.0.0</code> for LAN/Docker access with a Network Password in the same save. Specific LAN IP binds are manual/env-only.</div>
<div class="form-field ui-field">
<label for="s-server-host">Server Bind Host</label>
<input id="s-server-host" placeholder="127.0.0.1 or 0.0.0.0" class="ui-control" name="s-server-host" type="text" aria-describedby="s-server-host-help">
<div class="settings-inline-note ui-field-help" id="s-server-host-help">Use <code>127.0.0.1</code> for this machine only. Use <code>0.0.0.0</code> for LAN/Docker access with a Network Password in the same save. Specific LAN IP binds are manual/env-only.</div>
</div>
</div>
<div class="settings-inline-note">Adds a password wall only for non-localhost app and API access. If you expose Ouroboros on LAN or Docker, set a password before sharing the URL.</div>
@ -361,10 +366,10 @@ export function renderSettingsPage() {
<div class="form-section">
<h3>Other Model Slots</h3>
<div class="form-grid two">
<div class="form-field">
<label>Web Search Model</label>
<input id="s-websearch-model" placeholder="gpt-5.2">
<div class="settings-inline-note">OpenAI model for <code>web_search</code>. Requires <code>OPENAI_API_KEY</code> and an empty Legacy Base URL.</div>
<div class="form-field ui-field">
<label for="s-websearch-model">Web Search Model</label>
<input id="s-websearch-model" placeholder="gpt-5.2" class="ui-control" name="s-websearch-model" type="text" aria-describedby="s-websearch-model-help">
<div class="settings-inline-note ui-field-help" id="s-websearch-model-help">OpenAI model for <code>web_search</code>. Requires <code>OPENAI_API_KEY</code> and an empty Legacy Base URL.</div>
</div>
</div>
</div>
@ -471,8 +476,8 @@ export function renderSettingsPage() {
Closed-loop skill development can auto-grant the keys and host permissions a skill declares after a fresh executable review for the current content hash.
Leave this off when every skill permission should require a separate human approval.
</div>
<label class="local-toggle" title="Applies only after a fresh executable skill review and only to manifest-declared grants for that exact content hash.">
<input type="checkbox" id="s-auto-grant-reviewed-skills">
<label class="local-toggle ui-field ui-field-inline" title="Applies only after a fresh executable skill review and only to manifest-declared grants for that exact content hash.">
<input type="checkbox" id="s-auto-grant-reviewed-skills" class="ui-checkbox" name="s-auto-grant-reviewed-skills">
Auto-grant reviewed skills' keys and permissions
</label>
</div>
@ -620,23 +625,23 @@ export function renderSettingsPage() {
<div class="settings-inline-note"><strong>Counts every eligible task, including trivial chats.</strong> <code>Every N=1</code> means Ouroboros considers self-improvement after every task, then runs the actual cycle later on an idle supervisor tick.</div>
</div>
<div class="form-row">
<div class="form-field">
<div class="form-field ui-field">
<div data-evo-every-n-row>
<label>Every N Tasks</label>
<input id="s-evo-cadence-n" type="number" min="1" step="1" placeholder="3">
<div class="settings-inline-note">Visible only when Self-Improvement Trigger = Every N Tasks.</div>
<label for="s-evo-cadence-n">Every N Tasks</label>
<input id="s-evo-cadence-n" type="number" min="1" step="1" placeholder="3" class="ui-control" name="s-evo-cadence-n" aria-describedby="s-evo-cadence-n-help">
<div class="settings-inline-note ui-field-help" id="s-evo-cadence-n-help">Visible only when Self-Improvement Trigger = Every N Tasks.</div>
</div>
</div>
<div class="form-field">
<label>Per-Cycle Budget Reserve (USD)</label>
<input id="s-evo-budget" placeholder="0">
<div class="settings-inline-note">Minimum remaining global budget required to start a post-task cycle. <code>0</code> = rely on the normal gates. Running cycles still inherit the global per-task hard cost cap and the supervisor's reserved-budget floor.</div>
<div class="form-field ui-field">
<label for="s-evo-budget">Per-Cycle Budget Reserve (USD)</label>
<input id="s-evo-budget" placeholder="0" class="ui-control" name="s-evo-budget" type="text" aria-describedby="s-evo-budget-help">
<div class="settings-inline-note ui-field-help" id="s-evo-budget-help">Minimum remaining global budget required to start a post-task cycle. <code>0</code> = rely on the normal gates. Running cycles still inherit the global per-task hard cost cap and the supervisor's reserved-budget floor.</div>
</div>
</div>
<div class="form-field">
<label>Standing Objective (optional)</label>
<input id="s-evo-objective" placeholder="(none) — e.g. prioritize test coverage and latency">
<div class="settings-inline-note">Optional steer appended to every evolution cycle objective. It never overrides the LLM-first promotion; leave empty for pure LLM choice.</div>
<div class="form-field ui-field">
<label for="s-evo-objective">Standing Objective (optional)</label>
<input id="s-evo-objective" placeholder="(none) — e.g. prioritize test coverage and latency" class="ui-control" name="s-evo-objective" type="text" aria-describedby="s-evo-objective-help">
<div class="settings-inline-note ui-field-help" id="s-evo-objective-help">Optional steer appended to every evolution cycle objective. It never overrides the LLM-first promotion; leave empty for pure LLM choice.</div>
</div>
</div>
@ -646,17 +651,17 @@ export function renderSettingsPage() {
Cadence for Ouroboros's background cognition loop. These values are read at startup; save them, then restart for the new timing to take effect.
</div>
<div class="form-row">
<div class="form-field">
<label>BG Wakeup Min (sec)</label>
<input id="s-bg-wakeup-min" type="number" min="1" step="1" placeholder="30">
<div class="form-field ui-field">
<label for="s-bg-wakeup-min">BG Wakeup Min (sec)</label>
<input id="s-bg-wakeup-min" type="number" min="1" step="1" placeholder="30" class="ui-control" name="s-bg-wakeup-min">
</div>
<div class="form-field">
<label>BG Wakeup Max (sec)</label>
<input id="s-bg-wakeup-max" type="number" min="1" step="1" placeholder="7200">
<div class="form-field ui-field">
<label for="s-bg-wakeup-max">BG Wakeup Max (sec)</label>
<input id="s-bg-wakeup-max" type="number" min="1" step="1" placeholder="7200" class="ui-control" name="s-bg-wakeup-max">
</div>
<div class="form-field">
<label>BG Max Rounds</label>
<input id="s-bg-max-rounds" type="number" min="1" step="1" placeholder="10">
<div class="form-field ui-field">
<label for="s-bg-max-rounds">BG Max Rounds</label>
<input id="s-bg-max-rounds" type="number" min="1" step="1" placeholder="10" class="ui-control" name="s-bg-max-rounds">
</div>
</div>
<div class="settings-inline-note"><strong>Applies after restart:</strong> BG Wakeup Min/Max and BG Max Rounds are read when the background cognition loop starts.</div>
@ -671,10 +676,10 @@ export function renderSettingsPage() {
cloning or pulling them. Leave empty to use only the data plane.
</div>
<div class="form-row">
<div class="form-field">
<label>Skills Repo Path</label>
<input id="s-skills-repo-path" placeholder="~/Ouroboros/skills or /absolute/path/to/skills">
<div class="settings-inline-note">Absolute or <code>~</code>-prefixed path. Ouroboros never clones/pulls this directory — you manage it yourself.</div>
<div class="form-field ui-field">
<label for="s-skills-repo-path">Skills Repo Path</label>
<input id="s-skills-repo-path" placeholder="~/Ouroboros/skills or /absolute/path/to/skills" class="ui-control" name="s-skills-repo-path" type="text" aria-describedby="s-skills-repo-path-help">
<div class="settings-inline-note ui-field-help" id="s-skills-repo-path-help">Absolute or <code>~</code>-prefixed path. Ouroboros never clones/pulls this directory — you manage it yourself.</div>
</div>
</div>
</div>
@ -691,10 +696,10 @@ export function renderSettingsPage() {
are filtered out — only skill packages are installable.
</div>
<div class="form-row">
<div class="form-field">
<label>Registry URL</label>
<input id="s-clawhub-registry-url" placeholder="https://clawhub.ai/api/v1">
<div class="settings-inline-note">Override only for self-hosted mirrors. Hostname must be <code>clawhub.ai</code> or localhost.</div>
<div class="form-field ui-field">
<label for="s-clawhub-registry-url">Registry URL</label>
<input id="s-clawhub-registry-url" placeholder="https://clawhub.ai/api/v1" class="ui-control" name="s-clawhub-registry-url" type="text" aria-describedby="s-clawhub-registry-url-help">
<div class="settings-inline-note ui-field-help" id="s-clawhub-registry-url-help">Override only for self-hosted mirrors. Hostname must be <code>clawhub.ai</code> or localhost.</div>
</div>
</div>
</div>
@ -718,13 +723,13 @@ export function renderSettingsPage() {
</div>
</div>
<div class="form-grid two">
<label class="local-toggle">
<input type="checkbox" id="s-mcp-enabled">
<label class="local-toggle ui-field ui-field-inline">
<input type="checkbox" id="s-mcp-enabled" class="ui-checkbox" name="s-mcp-enabled">
Enable MCP client
</label>
<div class="form-field">
<label>Per-tool timeout (s)</label>
<input id="s-mcp-tool-timeout" type="number" min="1" value="60">
<div class="form-field ui-field">
<label for="s-mcp-tool-timeout">Per-tool timeout (s)</label>
<input id="s-mcp-tool-timeout" type="number" min="1" value="60" class="ui-control" name="s-mcp-tool-timeout">
</div>
</div>
<div id="mcp-global-status" class="settings-inline-status">Checking MCP status…</div>
@ -735,9 +740,9 @@ export function renderSettingsPage() {
<h3>Source Control</h3>
<div class="settings-section-copy">Repository metadata for GitHub integration. Tokens live in Secrets; this is not secret.</div>
<div class="form-row">
<div class="form-field">
<label>GitHub Repo</label>
<input id="s-gh-repo" placeholder="owner/repo-name">
<div class="form-field ui-field">
<label for="s-gh-repo">GitHub Repo</label>
<input id="s-gh-repo" placeholder="owner/repo-name" class="ui-control" name="s-gh-repo" type="text">
</div>
</div>
</div>
@ -746,31 +751,31 @@ export function renderSettingsPage() {
<h3>Local Model Runtime</h3>
<div class="settings-section-copy">Only fill this in when you want Ouroboros to start and route to a GGUF model on this machine.</div>
<div class="form-grid two">
<div class="form-field">
<label>Model Source</label>
<input id="s-local-source" placeholder="bartowski/Llama-3.3-70B-Instruct-GGUF or /path/to/model.gguf">
<div class="form-field ui-field">
<label for="s-local-source">Model Source</label>
<input id="s-local-source" placeholder="bartowski/Llama-3.3-70B-Instruct-GGUF or /path/to/model.gguf" class="ui-control" name="s-local-source" type="text">
</div>
<div class="form-field">
<label>GGUF Filename (for HF repos)</label>
<input id="s-local-filename" placeholder="Llama-3.3-70B-Instruct-Q4_K_M.gguf">
<div class="form-field ui-field">
<label for="s-local-filename">GGUF Filename (for HF repos)</label>
<input id="s-local-filename" placeholder="Llama-3.3-70B-Instruct-Q4_K_M.gguf" class="ui-control" name="s-local-filename" type="text">
</div>
</div>
<div class="form-grid four">
<div class="form-field">
<label>Port</label>
<input id="s-local-port" type="number" value="8766">
<div class="form-field ui-field">
<label for="s-local-port">Port</label>
<input id="s-local-port" type="number" value="8766" class="ui-control" name="s-local-port">
</div>
<div class="form-field">
<label>GPU Layers (-1 = all)</label>
<input id="s-local-gpu-layers" type="number" value="-1">
<div class="form-field ui-field">
<label for="s-local-gpu-layers">GPU Layers (-1 = all)</label>
<input id="s-local-gpu-layers" type="number" value="-1" class="ui-control" name="s-local-gpu-layers">
</div>
<div class="form-field">
<label>Context Length</label>
<input id="s-local-ctx" type="number" value="16384">
<div class="form-field ui-field">
<label for="s-local-ctx">Context Length</label>
<input id="s-local-ctx" type="number" value="16384" class="ui-control" name="s-local-ctx">
</div>
<div class="form-field">
<label>Chat Format</label>
<input id="s-local-chat-format" placeholder="auto-detect">
<div class="form-field ui-field">
<label for="s-local-chat-format">Chat Format</label>
<input id="s-local-chat-format" placeholder="auto-detect" class="ui-control" name="s-local-chat-format" type="text">
</div>
</div>
<div class="settings-toolbar">
@ -778,7 +783,8 @@ export function renderSettingsPage() {
<button class="btn btn-primary" id="btn-local-stop">Stop</button>
<button class="btn btn-primary" id="btn-local-test">Test Tool Calling</button>
</div>
<div id="local-model-status" class="settings-inline-status">Status: Offline</div>
<div id="local-model-status" class="settings-inline-status" role="status" aria-live="polite">Status: Offline</div>
<div id="local-model-action-status" class="settings-inline-status" role="status" aria-live="polite" aria-atomic="true"></div>
<div id="local-model-progress-wrap" class="local-model-progress-wrap local-model-hidden" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div id="local-model-progress-bar" class="local-model-progress-bar"></div>
</div>
@ -794,25 +800,25 @@ export function renderSettingsPage() {
runtime worker processes, not an agent setting. -->
<div class="settings-section-copy">Workers control parallel task capacity. Task liveness is governed automatically by progress, deadlines, the absolute ceiling, and the reaper. Budget limits control runtime cost thresholds. How many subagents a task may run, and how deep they may nest, live in <code>Agents</code>.</div>
<div class="form-grid two">
<div class="form-field">
<label>Max Workers</label>
<input id="s-workers" type="number" min="1" max="50" value="10">
<div class="form-field ui-field">
<label for="s-workers">Max Workers</label>
<input id="s-workers" type="number" min="1" max="50" value="10" class="ui-control" name="s-workers">
</div>
<div class="form-field">
<label>Concurrent Presence Conversations</label>
<input id="s-presence-max-active" type="number" min="1" max="20" value="2">
<div class="form-field ui-field">
<label for="s-presence-max-active">Concurrent Presence Conversations</label>
<input id="s-presence-max-active" type="number" min="1" max="20" value="2" class="ui-control" name="s-presence-max-active">
</div>
<div class="form-field">
<label>Tool Timeout (s)</label>
<input id="s-tool-timeout" type="number" value="600">
<div class="form-field ui-field">
<label for="s-tool-timeout">Tool Timeout (s)</label>
<input id="s-tool-timeout" type="number" value="600" class="ui-control" name="s-tool-timeout">
</div>
<div class="form-field">
<label>Total Budget (USD)</label>
<input id="s-total-budget" type="number" min="0.01" step="any" value="200.0">
<div class="form-field ui-field">
<label for="s-total-budget">Total Budget (USD)</label>
<input id="s-total-budget" type="number" min="0.01" step="any" value="200.0" class="ui-control" name="s-total-budget">
</div>
<div class="form-field">
<label>Per-Task Cost Cap (USD)</label>
<input id="s-settings-per-task-cost" type="number" min="0.01" step="any" value="50.0">
<div class="form-field ui-field">
<label for="s-settings-per-task-cost">Per-Task Cost Cap (USD)</label>
<input id="s-settings-per-task-cost" type="number" min="0.01" step="any" value="50.0" class="ui-control" name="s-settings-per-task-cost">
</div>
</div>
</div>
@ -827,9 +833,9 @@ export function renderSettingsPage() {
<strong>GC Retention</strong> is the single age knob (days) for all disposable runtime artifacts the startup garbage collector removes: acting-subagent worktrees, terminal task drives, and leftover service logs (hard max 365). Genesis projects are durable and never auto-removed. Where subagents check out that work is set in <code>Agents</code>.
</div>
<div class="form-grid two">
<div class="form-field">
<label>GC Retention (days)</label>
<input id="s-gc-retention-days" type="number" min="1" max="365" value="7">
<div class="form-field ui-field">
<label for="s-gc-retention-days">GC Retention (days)</label>
<input id="s-gc-retention-days" type="number" min="1" max="365" value="7" class="ui-control" name="s-gc-retention-days">
</div>
</div>
</div>
@ -884,7 +890,7 @@ export function renderSettingsPage() {
</div>
<div class="settings-footer-status">
<span id="settings-unsaved-indicator" class="settings-inline-status settings-unsaved-indicator" aria-hidden="true">Unsaved changes</span>
<div id="settings-status" class="settings-inline-status"></div>
<div id="settings-status" class="settings-inline-status" role="status" aria-live="polite" aria-atomic="true"></div>
</div>
</div>
</div>
@ -892,48 +898,52 @@ export function renderSettingsPage() {
}
export function bindSettingsTabs(root, options = {}) {
const tabs = Array.from(root.querySelectorAll('.settings-tab'));
const panels = Array.from(root.querySelectorAll('.settings-panel'));
const scrollRoot = root.querySelector('.settings-scroll');
const state = options.state || null;
const onActivate = typeof options.onActivate === 'function' ? options.onActivate : null;
// All viewports use horizontal tab pills; mobile back remains DOM-only for compat.
function activate(tabName) {
const tabs = bindTabStrip(root.querySelector('.settings-tabs'), {
dataAttr: 'data-settings-tab', onChange: (value) => activate(value),
});
panels.forEach((panel) => {
panel.id = `settings-panel-${panel.dataset.settingsPanel}`;
panel.setAttribute('role', 'tabpanel');
panel.setAttribute('aria-labelledby', `settings-tab-${panel.dataset.settingsPanel}`);
});
function activate(tabName, notify = true) {
if (!tabs.select(tabName)) return;
const changed = root.dataset.activeSettingsTab !== tabName;
root.dataset.activeSettingsTab = tabName;
let activeButton = null;
tabs.forEach((button) => {
const isActive = button.dataset.settingsTab === tabName;
button.classList.toggle('active', isActive);
button.setAttribute('aria-selected', String(isActive));
if (isActive) activeButton = button;
});
panels.forEach((panel) => {
panel.classList.toggle('active', panel.dataset.settingsPanel === tabName);
panel.hidden = panel.dataset.settingsPanel !== tabName;
});
if (scrollRoot) scrollRoot.scrollTop = 0;
if (scrollRoot && changed && notify) scrollRoot.scrollTop = 0;
if (state) state.settingsActiveSubtab = tabName;
// Keep active pill visible in the horizontal strip.
if (activeButton && typeof activeButton.scrollIntoView === 'function') {
activeButton.scrollIntoView({
behavior: 'auto',
inline: 'center',
block: 'nearest',
});
if (notify && changed) {
if (onActivate) onActivate(tabName);
window.dispatchEvent(new CustomEvent('ouro:settings-subtab-shown', { detail: { tab: tabName } }));
}
if (onActivate) onActivate(tabName);
window.dispatchEvent(new CustomEvent('ouro:settings-subtab-shown', { detail: { tab: tabName } }));
}
tabs.forEach((button) => {
button.addEventListener('click', () => activate(button.dataset.settingsTab));
});
root.activateSettingsTab = activate;
if (state && !state.settingsActiveSubtab) state.settingsActiveSubtab = 'providers';
root.dataset.activeSettingsTab = state?.settingsActiveSubtab || 'providers';
activate(state?.settingsActiveSubtab || 'providers', false);
return () => { tabs.destroy(); delete root.activateSettingsTab; };
}
export function bindSecretInputs(root) {
root.querySelectorAll('.secret-toggle, .secret-clear').forEach((button) => {
const input = root.querySelector(`#${button.dataset.target}`);
if (!input) return;
button.setAttribute('aria-controls', input.id);
const label = input.labels?.[0];
if (label) {
label.id ||= `${input.id}-label`;
button.setAttribute('aria-describedby', label.id);
}
});
root.querySelectorAll('.secret-input').forEach((input) => {
input.addEventListener('input', () => {
if (input.value.trim()) delete input.dataset.forceClear;

View file

@ -3,38 +3,19 @@
// route/model/account/effort presentation is shared.
import {
FACET_ACCOUNTS,
FACET_CATALOG,
FACET_QUOTA,
READ_OK,
accountRows,
bindStatusSurface,
boundedStatusRefresh,
claudexorStatus,
familyLabel,
FACET_ACCOUNTS, FACET_CATALOG, FACET_QUOTA, READ_OK, accountRows,
bindStatusSurface, boundedStatusRefresh, claudexorStatus, familyLabel,
} from './claudexor_status_store.js';
import { renderSegmentedField } from './page_header.js';
import { harnessIdentityMarkup } from './harness_presentation.js';
import {
EFFORT_CHOICES,
ROUTE_KIND_AGENT_SESSION,
ROUTE_KIND_API_MODEL,
compoundSessionEffortConflict,
changeRouteChoice,
routeModelFields,
routeModelInputHtml,
routeTargetFromModel,
routeSupportsAccount,
effortSelectHtml,
encodeRouteChoice,
indexProfilesByHarness,
mintStableId,
profileOptionsFor,
routeChoiceGroups,
selectHtml,
serializeRouteSpec,
sessionModelOptions,
EFFORT_CHOICES, ROUTE_KIND_AGENT_SESSION, ROUTE_KIND_API_MODEL,
compoundSessionEffortConflict, changeRouteChoice, routeModelFields,
routeModelInputHtml, routeTargetFromModel, routeSupportsAccount, effortSelectHtml,
encodeRouteChoice, indexProfilesByHarness, mintStableId, profileOptionsFor,
routeChoiceGroups, selectHtml, serializeRouteSpec, sessionModelOptions, updateRouteControlOptions,
} from './route_editor_primitives.js';
import { modelChooserHtml, bindModelChoosers } from './model_chooser.js';
import { harnessMap, rowMeta, rowStatus, sessionRouteVerdict } from './subagent_status_primitives.js';
import { revealNewRow } from './ui_helpers.js';
import { escapeHtmlAttr as escapeHtml } from './utils.js';
@ -378,20 +359,20 @@ export function availableSubagentRowMarkup(row, state, index = 0) {
<button type="button" class="btn btn-default" data-subagent-remove aria-label="Remove Subagent ${ordinal}">Remove</button>
</div>
</div>
<label class="available-subagent-purpose">Description
<textarea data-subagent-field="recommended_use" rows="1" aria-label="Description for Subagent ${ordinal}" placeholder="When should Ouroboros choose this subagent?">${escapeHtml(row.recommended_use)}</textarea>
<label class="available-subagent-purpose ui-field">Description
<textarea class="ui-control" data-subagent-field="recommended_use" rows="1" aria-label="Description for Subagent ${ordinal}" placeholder="When should Ouroboros choose this subagent?">${escapeHtml(row.recommended_use)}</textarea>
</label>
<div class="available-subagent-route">
${selectHtml(`data-subagent-field="route" aria-label="Source for Subagent ${ordinal}"`, routeGroups, encodeRouteChoice(row))}
${session
? selectHtml(`data-subagent-field="model" aria-label="Agent session model for Subagent ${ordinal}"`, [{ label: '', options: modelOptions }], split.model)
? modelChooserHtml(`data-subagent-field="model" aria-label="Agent session model for Subagent ${ordinal}"`, split.model, `actor-${rowKey}-models`, modelOptions, { placeholder: 'Engine default model' })
: routeModelInputHtml(`data-subagent-field="model" aria-label="${split.subscription ? 'Subscription' : 'API'} model for Subagent ${ordinal}"`, row.route, state.apiModels, `actor-${rowKey}-models`)}
${routeSupportsAccount(row.route)
? selectHtml(`data-subagent-field="account" aria-label="Account for Subagent ${ordinal}"`, [{ label: '', options: profileOptions }], row.route.credential_profile_id || '')
: ''}
${effortSelectHtml(`data-subagent-field="effort" aria-label="Reasoning effort for Subagent ${ordinal}"`, row.effort || '', 'route default')}
</div>
<div class="available-subagent-meta" data-subagent-meta${meta.tone ? ` data-tone="${escapeHtml(meta.tone)}"` : ''} title="${escapeHtml(meta.text)}"${meta.text ? '' : ' hidden'}>${escapeHtml(meta.text)}</div>
<div id="actor-${escapeHtml(rowKey)}-meta" class="available-subagent-meta ui-field-help" data-subagent-meta${meta.tone ? ` data-tone="${escapeHtml(meta.tone)}"` : ''} title="${escapeHtml(meta.text)}"${meta.text ? '' : ' hidden'}>${escapeHtml(meta.text)}</div>
</article>`;
}
@ -435,8 +416,10 @@ export function createAvailableSubagentsEditor({
} = {}) {
const getDoc = typeof doc === 'function' ? doc : () => doc;
const getWin = typeof win === 'function' ? win : () => win;
let disposeChoosers = () => {};
const state = {
loaded: false,
destroyed: false,
parseError: '',
unloadedOmissionAllowed: false,
setting: { enabled: true, items: [] },
@ -506,6 +489,10 @@ export function createAvailableSubagentsEditor({
const el = container.querySelector(`[data-subagent-row="${row._uiKey || row.subagent_id}"]`);
if (!el) return;
el.toggleAttribute('data-invalid', judged);
el.querySelectorAll('[data-subagent-field]').forEach((field) => {
field.setAttribute('aria-describedby', `actor-${row._uiKey || row.subagent_id}-meta`);
if (field.dataset.subagentField !== 'recommended_use') field.setAttribute('aria-invalid', String(judged));
});
const status = rowStatus(row, state);
const statusEl = el.querySelector('[data-subagent-status]');
if (statusEl) {
@ -565,7 +552,7 @@ export function createAvailableSubagentsEditor({
paint();
});
rowElement.querySelector('[data-subagent-field="model"]')?.addEventListener(
row.route.kind === ROUTE_KIND_AGENT_SESSION ? 'change' : 'input',
'input',
(event) => {
const previous = encodeRouteChoice(row);
row.route.target_id = routeTargetFromModel(row.route, event.target.value);
@ -609,9 +596,9 @@ export function createAvailableSubagentsEditor({
});
}
function paint() {
function paint({ discoveryOnly = false } = {}) {
const container = host();
if (!container) return false;
if (!container || state.destroyed) return false;
const nextSignature = availableSubagentsRenderSignature(state);
if (nextSignature === state.signature) return false;
const focused = focusSnapshot(container, getDoc());
@ -621,10 +608,26 @@ export function createAvailableSubagentsEditor({
const source = state.source ? `Source: ${state.source}.` : '';
const readProblem = state.statusError
? 'Live agent availability could not be read. Saved rows remain unchanged.' : '';
if (discoveryOnly && container.querySelector('.available-subagents-list')) {
state.setting.items.forEach((row, index) => {
const el = container.querySelector(`[data-subagent-row="${row._uiKey || row.subagent_id}"]`);
if (!el) return;
const template = getDoc().createElement('template');
template.innerHTML = availableSubagentRowMarkup(row, state, index);
const desired = template.content.firstElementChild;
updateRouteControlOptions(el, desired);
el.querySelector('.available-subagent-route-identity-wrap').innerHTML = desired.querySelector('.available-subagent-route-identity-wrap').innerHTML;
});
container.querySelector('.available-subagents-source').textContent = [source, readProblem].filter(Boolean).join(' ');
Object.assign(container.querySelector('[data-subagents-diagnostics]'), { textContent: diagnostics.join(' · '), hidden: !diagnostics.length });
renderValidation();
return true;
}
disposeChoosers();
container.innerHTML = `
<div class="available-subagents-toolbar">
<label class="local-toggle">
<input type="checkbox" data-subagents-enabled ${state.setting.enabled ? 'checked' : ''} ${state.loaded ? '' : 'disabled'}>
<label class="local-toggle ui-field ui-field-inline">
<input class="ui-checkbox" type="checkbox" data-subagents-enabled aria-label="Available subagents enabled" ${state.setting.enabled ? 'checked' : ''} ${state.loaded ? '' : 'disabled'}>
Enabled
</label>
<span class="available-subagents-count">${state.setting.items.length}/${MAX_AVAILABLE_SUBAGENTS}</span>
@ -659,6 +662,7 @@ export function createAvailableSubagentsEditor({
revealRow(uiKey);
});
bindRows(container);
disposeChoosers = bindModelChoosers(container);
restoreFocus(container, focused);
renderValidation();
return true;
@ -708,18 +712,20 @@ export function createAvailableSubagentsEditor({
outerDraftClean,
parsedSetting: parsed.setting,
});
const sameAssignment = parsed.setting && state.loaded
&& JSON.stringify(buildAvailableSubagentsSetting(parsed.setting)) === JSON.stringify(buildAvailableSubagentsSetting(state.setting));
if (canApply) {
state.loaded = true;
state.parseError = '';
state.saveAttempted = false;
state.setting = attachUiKeys(parsed.setting, state.setting.items);
if (!sameAssignment) state.setting = attachUiKeys(parsed.setting, state.setting.items);
onDirtyChange(false);
onGeneratedApply(buildAvailableSubagentsSetting(state.setting));
} else if (!parsed.setting && !state.loaded) {
state.parseError = parsed.error;
}
state.signature = '';
paint();
paint({ discoveryOnly: state.loaded && (!canApply || sameAssignment) });
return { applied: canApply, error: parsed.error };
}
@ -736,13 +742,13 @@ export function createAvailableSubagentsEditor({
state.parseError = `Available subagents preview failed: ${state.diagnostics.join(' · ')}`;
}
state.signature = '';
paint();
paint({ discoveryOnly: state.loaded });
}
async function reloadStatus() {
await boundedStatusRefresh(store);
adoptStatus();
paint();
paint({ discoveryOnly: true });
// Generated rows are enrichment, never a second unbounded gate on the
// Settings critical path. The response is generation- and clean-gated.
void maybeRefreshGeneratedPreview({ force: true });
@ -794,7 +800,7 @@ export function createAvailableSubagentsEditor({
win: getWin,
listener: () => {
adoptStatus();
paint();
paint({ discoveryOnly: true });
void maybeRefreshGeneratedPreview();
},
});
@ -807,7 +813,7 @@ export function createAvailableSubagentsEditor({
.map((item) => String(item.value || item.id || ''))
.filter(Boolean);
state.signature = '';
paint();
paint({ discoveryOnly: true });
};
target?.addEventListener?.('settings-model-catalog:updated', onCatalog);
state.catalogDisposer = () => target?.removeEventListener?.('settings-model-catalog:updated', onCatalog);
@ -817,6 +823,9 @@ export function createAvailableSubagentsEditor({
}
function destroy() {
state.destroyed = true;
state.previewGeneration += 1;
disposeChoosers();
state.statusDisposer?.();
state.catalogDisposer?.();
state.statusDisposer = null;
@ -878,14 +887,14 @@ export function renderSubagentsSection() {
</div>
</div>
<div class="form-grid two">
<div class="form-field">
<label>Active subagents per root</label>
<input id="s-active-subagents" type="number" min="1" max="500" value="6">
<div class="form-field ui-field">
<label for="s-active-subagents">Active subagents per root</label>
<input class="ui-control" id="s-active-subagents" type="number" min="1" max="500" value="6">
<div class="settings-inline-note">How many children one root task may run at once.</div>
</div>
<div class="form-field">
<label>Subagent depth</label>
<input id="s-subagent-depth" type="number" min="0" max="10" value="3">
<div class="form-field ui-field">
<label for="s-subagent-depth">Subagent depth</label>
<input class="ui-control" id="s-subagent-depth" type="number" min="0" max="10" value="3">
<div class="settings-inline-note">How deep the chain may nest. <code>0</code> turns delegation off entirely.</div>
</div>
</div>
@ -893,13 +902,13 @@ export function renderSubagentsSection() {
<summary>Advanced — where subagents check out their work</summary>
<div class="settings-subsection-body">
<div class="form-grid two">
<div class="form-field">
<label>Subagent worktree root</label>
<input id="s-subagent-worktree-root" type="text" placeholder="~/Ouroboros/subagent_worktrees">
<div class="form-field ui-field">
<label for="s-subagent-worktree-root">Subagent worktree root</label>
<input class="ui-control" id="s-subagent-worktree-root" type="text" placeholder="~/Ouroboros/subagent_worktrees">
</div>
<div class="form-field">
<label>Subagent projects root (genesis)</label>
<input id="s-subagent-projects-root" type="text" placeholder="~/Ouroboros/projects">
<div class="form-field ui-field">
<label for="s-subagent-projects-root">Subagent projects root (genesis)</label>
<input class="ui-control" id="s-subagent-projects-root" type="text" placeholder="~/Ouroboros/projects">
</div>
</div>
<div class="settings-inline-note">
@ -969,27 +978,19 @@ export function applySubagentsSettings(settings) {
});
}
export async function reloadSubagentsSection() {
await settingsEditor?.reloadStatus();
}
export async function reloadSubagentsSection() { await settingsEditor?.reloadStatus(); }
export function destroySubagentsSection() {
settingsEditor?.destroy();
settingsEditor = null;
}
export function collectSubagentsSettings() {
return settingsEditor?.collect() || {};
}
export function collectSubagentsSettings() { return settingsEditor?.collect() || {}; }
export function validateSubagentsDraft() {
return settingsEditor?.validate() || ['Available subagents editor is not loaded.'];
}
export function validateSubagentsDraft() { return settingsEditor?.validate() || ['Available subagents editor is not loaded.']; }
/** Settings' Save button: the draft's own errors become visible from here on. */
export function noteSubagentsSaveAttempt() {
settingsEditor?.noteSaveAttempt();
}
export function noteSubagentsSaveAttempt() { settingsEditor?.noteSaveAttempt(); }
// Compatibility name retained for focused callers; the signature now covers
// the actor list rather than the retired singleton route.

View file

@ -5872,8 +5872,10 @@ textarea.chat-input {
padding: 6px 10px;
border-bottom: 1px solid var(--divider);
font-size: 12px;
word-break: break-all;
}
.new-project-browser-path > strong { min-width: 0; overflow-wrap: anywhere; }
.new-project-browser-path > button { flex: 0 0 auto; white-space: nowrap; }
.new-project-browser-list {
display: flex;
@ -7155,78 +7157,6 @@ textarea.chat-input {
}
.files-context-menu:not(.ui-popup) {
position: fixed;
min-width: 160px;
padding: 6px;
border-radius: var(--radius);
border: 1px solid var(--divider);
background: rgba(18, 20, 26, 0.96);
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35);
z-index: 20;
}
.files-context-menu:not(.ui-popup) .files-context-item {
width: 100%;
border: none;
border-radius: var(--radius-sm);
padding: 10px 12px;
background: transparent;
color: var(--text-primary);
text-align: left;
cursor: pointer;
}
.files-context-menu:not(.ui-popup) .files-context-item:hover {
background: var(--menu-item-hover);
}
.files-context-menu:not(.ui-popup) .files-context-item-danger {
color: var(--status-error-fg);
}
.files-context-menu:not(.ui-popup) .files-context-item-danger:hover {
background: rgba(239, 68, 68, 0.12);
color: #fecaca;
}
.project-row-menu:not(.ui-popup) {
position: fixed;
top: var(--prm-top, 0);
left: var(--prm-left, 0);
z-index: 120;
display: flex;
flex-direction: column;
min-width: 180px;
padding: 4px;
border: 1px solid var(--divider);
border-radius: var(--radius-md, 8px);
background: var(--ui-modal-bg);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
max-width: min(260px, calc(100vw - 16px));
}
.project-row-menu:not(.ui-popup) button {
text-align: left;
padding: 7px 10px;
border: none;
background: transparent;
color: var(--text-primary);
font-size: 13px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.project-row-menu:not(.ui-popup) button:hover {
background: var(--menu-item-hover);
}
.project-row-menu:not(.ui-popup) button.danger {
color: var(--status-error-fg);
}
.chat-photo-menu:not(.ui-popup) {
position: absolute;
top: 34px;
@ -7256,41 +7186,9 @@ textarea.chat-input {
.chat-photo-menu:not(.ui-popup) button:hover { background: var(--menu-item-hover); }
.files-modal-input:not(.ui-control) {
width: 100%;
margin-top: 14px;
border: 1px solid var(--divider);
border-radius: var(--radius-md);
padding: 11px 12px;
background: rgba(255, 255, 255, 0.04);
color: var(--text-primary);
font: inherit;
}
.files-editor-name:not(.ui-control) {
width: 100%;
border: 1px solid var(--divider);
border-radius: var(--radius-md);
padding: 10px 12px;
background: rgba(255, 255, 255, 0.04);
color: var(--text-primary);
font: inherit;
}
.files-editor:not(.ui-control) {
width: 100%;
flex: 1;
min-height: 320px;
border: 0;
outline: none;
resize: none;
margin: 0;
padding: 0;
background: transparent;
color: inherit;
font: inherit;
line-height: inherit;
white-space: pre;
tab-size: 4;
}
.form-field input:not(.ui-control):not(.ui-checkbox), .form-field select:not(.ui-control):not(.ui-checkbox),
.reviewer-slot-controls input:not(.ui-control):not(.ui-checkbox), .reviewer-slot-controls select:not(.ui-control):not(.ui-checkbox) {
padding: 8px 12px;
@ -7359,12 +7257,4 @@ textarea.chat-input {
}
.files-toolbar input:not(.ui-control) {
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--divider);
color: var(--text-primary);
border-radius: var(--radius-md);
padding: 10px 12px;
}
/* track-migration:end */

View file

@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { matchingModelOptions, modelChooserHtml, modelOptions } from '../modules/model_chooser.js';
import { reviewerSlotsDraftErrors } from '../modules/reviewer_slots.js';
test('model suggestions filter values and labels without a result-count ceiling', () => {
const items = Array.from({ length: 40 }, (_, i) => ({ value: `provider/model-${i}`, label: `Reasoner ${i}` }));
assert.equal(matchingModelOptions(items, 'provider/').length, 40);
assert.equal(matchingModelOptions(items, 'reasoner 12')[0].value, 'provider/model-12');
assert.deepEqual(matchingModelOptions(items, 'new-owner-model'), []);
assert.equal(items.length, 40);
});
test('model choice markup escapes catalog text and retains arbitrary current IDs', () => {
const html = modelChooserHtml('aria-label="Main model"', 'owner/"<new>', 'main-options', [
{ value: '"<script>', label: '<img src=x>' },
]);
assert.match(html, /value="owner\/&quot;&lt;new&gt;"/);
assert.doesNotMatch(html, /<script>|<img/);
assert.match(html, /role="combobox"/);
assert.match(html, /aria-controls="main-options"/);
assert.doesNotMatch(html, /datalist/);
assert.deepEqual(modelOptions(['a', 'a', { id: 'b', name: 'B' }, { value: '', label: 'Engine default' }]), [
{ value: 'a', label: 'a' }, { value: 'b', label: 'B' }, { value: '', label: 'Engine default' },
]);
});
test('reviewer local errors hold incomplete rows without catalog or delivery inference', () => {
const state = { loaded: true, triad: [{ slot_id: 't', route: { kind: 'api_chat', target_id: '' } }],
scope: [{ slot_id: 's', subagent_id: 'saved-unknown' }],
advisory: { route: { kind: 'api_chat', target_id: '' } },
deepReview: { materialized: false, route: { kind: 'api_chat', target_id: '' } } };
const before = JSON.stringify(state);
assert.deepEqual(reviewerSlotsDraftErrors(state), ['Triad reviewer 1: Choose a model or reviewer source.']);
assert.equal(JSON.stringify(state), before);
state.triad[0].route.target_id = 'owner/new-model';
assert.deepEqual(reviewerSlotsDraftErrors(state), []);
state.triad = [];
assert.deepEqual(reviewerSlotsDraftErrors(state), ['Add at least one triad reviewer.']);
assert.deepEqual(reviewerSlotsDraftErrors({ loaded: false }), []);
});

View file

@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { collectCustomSecretDraft, paintSettingsFieldErrors, settingsWriteFailure } from '../modules/settings_controls.js';
const draft = (overrides = {}) => ({
key: '', value: '', appliedValue: '', originalKey: '', clear: false, removed: false,
...overrides,
});
test('custom-key collection returns every invalid row without changing the draft', () => {
const rows = [draft({ value: 'keep this draft' }), draft({ key: '!bad', value: 'second' }),
draft({ key: 'VALID_KEY', value: 'accepted' })].map(Object.freeze);
const before = JSON.stringify(rows);
const result = collectCustomSecretDraft(rows);
assert.deepEqual(result.values, { VALID_KEY: 'accepted' });
assert.deepEqual(result.errors.map(({ index, field }) => [index, field]), [[0, 'key'], [1, 'key']]);
assert.equal(JSON.stringify(rows), before);
const corrected = collectCustomSecretDraft([
draft({ key: 'first_key', value: 'keep this draft' }),
draft({ key: 'SECOND_KEY', value: 'second' }), rows[2],
]);
assert.deepEqual(corrected.errors, []);
assert.deepEqual(corrected.values, { FIRST_KEY: 'keep this draft', SECOND_KEY: 'second', VALID_KEY: 'accepted' });
});
test('custom-key masks, explicit clear and remove retain their different meanings', () => {
const saved = draft({ key: 'SAVED_KEY', originalKey: 'SAVED_KEY', value: 'abcdefgh...', appliedValue: 'abcdefgh...' });
assert.deepEqual(collectCustomSecretDraft([saved]), { values: {}, errors: [] });
assert.deepEqual(collectCustomSecretDraft([{ ...saved, clear: true, value: '' }]), { values: { SAVED_KEY: '' }, errors: [] });
assert.deepEqual(collectCustomSecretDraft([{ ...saved, removed: true, key: 'RENAMED_KEY' }]), { values: { SAVED_KEY: '' }, errors: [] });
assert.deepEqual(collectCustomSecretDraft([draft({ removed: true, key: '!unfinished' })]), { values: {}, errors: [] });
// Literal ellipses are valid new secret bytes, not a mask heuristic.
assert.deepEqual(collectCustomSecretDraft([draft({ key: 'NEW_KEY', value: 'literal...inside' })]).values,
{ NEW_KEY: 'literal...inside' });
});
test('renaming a saved key cannot pretend its masked value was copied', () => {
const renamed = draft({ key: 'NEW_KEY', originalKey: 'SAVED_KEY', value: 'abcdefgh...', appliedValue: 'abcdefgh...' });
const invalid = collectCustomSecretDraft([renamed]);
assert.equal(invalid.errors[0].field, 'value');
assert.deepEqual(invalid.values, {});
const entered = collectCustomSecretDraft([{ ...renamed, value: 'new value' }]);
assert.deepEqual(entered, { values: { NEW_KEY: 'new value' }, errors: [] });
assert.equal('SAVED_KEY' in entered.values, false, 'a new value does not implicitly delete the old key');
});
test('duplicate and built-in names cannot silently overwrite another field', () => {
const result = collectCustomSecretDraft([
draft({ key: 'same_key', value: 'one' }), draft({ key: 'SAME_KEY', value: 'two' }),
draft({ key: 'OUROBOROS_PRIVATE', value: 'three' }), draft({ key: 'TOTAL_BUDGET', value: 'four' }),
], ['TOTAL_BUDGET']);
assert.deepEqual(new Set(result.errors.map(({ index }) => index)), new Set([0, 1, 2, 3]));
assert.equal(result.values.TOTAL_BUDGET, undefined);
assert.equal(result.values.OUROBOROS_PRIVATE, undefined);
});
test('field validation clears the old error and keeps its accessible association', () => {
const attributes = new Map([['aria-describedby', 'custom-key-help']]);
const hint = { id: 'custom-key-error', hidden: true, textContent: '' };
const input = { id: 'custom-key', dataset: {},
getAttribute: (name) => attributes.get(name),
setAttribute: (name, value) => attributes.set(name, value),
removeAttribute: (name) => attributes.delete(name) };
const root = {
querySelectorAll: () => input.dataset.settingsValidation ? [input] : [],
querySelector: () => hint,
};
paintSettingsFieldErrors(root, [{ input, message: 'Invalid key' }]);
assert.equal(attributes.get('aria-invalid'), 'true');
assert.equal(attributes.get('aria-describedby'), 'custom-key-help custom-key-error');
assert.equal(hint.hidden, false);
paintSettingsFieldErrors(root, []);
assert.equal(attributes.has('aria-invalid'), false);
assert.equal(hint.hidden, true);
assert.equal(hint.textContent, '');
assert.equal(attributes.get('aria-describedby'), 'custom-key-help custom-key-error');
});
test('settings write errors preserve saved, unsaved and unknown receipts', () => {
const error = new Error('stage refused');
error.body = { saved: true };
assert.deepEqual(settingsWriteFailure(error, 'Runtime mode'), {
unknown: false, text: 'Runtime mode was saved, but a later step failed: stage refused',
});
error.body = { saved: false };
assert.match(settingsWriteFailure(error).text, /was not changed/);
for (const body of [{ saved: null }, {}, undefined]) {
error.body = body;
const result = settingsWriteFailure(error);
assert.equal(result.unknown, true);
assert.match(result.text, /outcome is unknown/);
}
});

View file

@ -366,7 +366,7 @@ test('the editor shows a numbered row and only one owner-authored prose field',
}, 2);
assert.match(html, /class="available-subagent-heading"[^>]*>Subagent 3</);
assert.match(html, />Description\s*<textarea data-subagent-field="recommended_use"/);
assert.match(html, />Description\s*<textarea\b[^>]*data-subagent-field="recommended_use"/);
assert.equal((html.match(/<textarea\b/g) || []).length, 1);
assert.doesNotMatch(html, /data-subagent-field="(?:id|name)"/);
assert.doesNotMatch(html, />Stable ID<|<label>Name/);
@ -698,7 +698,7 @@ test('the card head carries the ordinal, the route mark, a two-word status and t
assert.match(head, /class="settings-inline-status" data-subagent-status data-tone="neutral" title="Saved intent · Agent session · live availability not checked">Saved · Not checked</);
assert.match(head, /data-subagent-duplicate/);
assert.match(head, /data-subagent-remove/);
assert.match(html, /<textarea data-subagent-field="recommended_use" rows="1"/);
assert.match(html, /<textarea\b[^>]*data-subagent-field="recommended_use" rows="1"/);
assert.equal((html.match(/<textarea/g) || []).length, 1);
// A routed row with no run evidence carries no meta band at all.
assert.match(html, /data-subagent-meta[^>]*hidden/);