feat(web): Profiles tab -- browse, search, create, edit, clone, delet… - #692
feat(web): Profiles tab -- browse, search, create, edit, clone, delet…#692sujoydc wants to merge 15 commits into
Conversation
haofeif
left a comment
There was a problem hiding this comment.
Thanks for the very thorough write-up — the self-review section made this much faster to audit, and every hardening claim in it held up under execution (details below).
Reviewed at 0948572a (merge-base b6a0520b). Web-only: no Python file changes.
Author claims I reproduced
| Claim | Result |
|---|---|
| 63 new UI tests | Exact. merge-base 177 passed, PR 240 passed -> +63 |
| Full web suite 240/240 | Exact. Test Files 16 passed, Tests 240 passed |
tsc clean, production build clean |
Confirmed. npm run build exit 0 |
| No pre-existing regressions | Confirmed. merge-base 177/177 green; 0 tests regressed |
rewriteFrontmatterName handles $-patterns |
Confirmed for $&, $', $`, $1, $$, both in the typed name and pre-existing in the frontmatter |
| CRLF documents rewrite rather than silently no-op | Confirmed |
Editor loads unresolved /source, never the resolved parse |
Confirmed, and genuinely pinned (mutation below) |
| Truncation contract rendered exactly | Confirmed against live validator output |
| Search order never re-sorted | Confirmed — the fixture is genuinely discriminating |
| Backdrop inert while saving | Confirmed in both modals |
ConfirmModal.confirmationText additive |
Confirmed — gated on !== undefined; no existing caller passes it |
Truncation contract, end-to-end
Rather than trust the copied constant, I drove the real validator to truncation:
300 non-string capabilities: total=100 markers=1 marker_idx=[99] last_sev=error
last_message='Additional validation findings omitted.'
EXACT-MATCH-UI-CONSTANT=True
100 including the marker, exactly one marker, last position, error severity because errors were dropped, and byte-identical to ValidationFindings.OMISSION_MESSAGE. isOmissionMarker requires index === all.length - 1, so the marker text in a non-final position does render as an ordinary finding, as documented.
The secret-leak path is really pinned
I mutated getProfileSource to drop /source (so the editor would read the resolved profile):
Tests 9 failed | 6 passed (15)
Nine editor tests go red. That behaviour is enforced, not just asserted in prose.
Things I suspected and cleared
- Portaled
CustomSelectmenu breaking existing callers.z-[80]clears thez-[60]maximum elsewhere, and the two backdrop closers (AgentPanel:518,FlowsPanel:300) are element-levelonClicks, not document-level handlers, so a click on the portaled menu never reaches them. The pre-existingmemory-graphtest opens the menu and clicks an option, so this is covered by tests that predate the PR. - Mixed line endings from a CRLF clone.
rewriteFrontmatterNamedoes emit a mixed-ending document (---\n...\r\n...---\r\n). I fed it to the real backend parser and it round-trips cleanly (meta={'name': 'newname', 'description': 'd'}), as does${VAR}inside a JSON-valued field. Not a defect — noting it only so it doesn't get re-litigated. - Route ordering. All 14 URLs match
api/main.py, and every literal path is declared before/agents/profiles/{name}. deleteProfileagainst a 204.fetchJSON's empty-body branch already covers it.
What blocks
Two defects, same root cause: a debounced effect whose early return does not invalidate the in-flight request token. The monotonic tokens are correct for the case they were written for (a newer request superseding an older one) but not for "the reason to want this response disappeared".
The first one silently persists the wrong document, so I'm marking this changes-requested — everything else here is in good shape, and both fixes are one-liners I verified.
Both fixes together keep 240 passed and tsc/build clean.
| } | ||
|
|
||
| /** Extract the frontmatter `name:` value from a rendered document, if any. */ | ||
| export function extractFrontmatterName(content: string): string | null { |
There was a problem hiding this comment.
[nit] extractFrontmatterName can match a name: line in the markdown body
[\s\S]*? isn't bounded to the end of the frontmatter block, so when the frontmatter has no name: the scan continues into the document body:
EXTRACT no-fm-name -> "decoy" // '---\ndescription: d\n---\nbody\nname: decoy\n'
EXTRACT normal -> "real"
EXTRACT quoted -> "real"
EXTRACT crlf -> "real"
Impact today is limited to pre-filling the profile-name box (:343, only when !nameTouched.current), and every shipped template carries a frontmatter name, so I don't think it's reachable — hence nit. Bounding the scan to the block would close it, e.g. match the frontmatter with /^---\r?\n([\s\S]*?)\r?\n---/ first and search name: within that captured group, which is what rewriteFrontmatterName already does correctly.
gutosantos82
left a comment
There was a problem hiding this comment.
PR Review: #692 — feat(web): Profiles tab — browse, search, create, edit, clone, delete profiles
Summary
Adds the Profiles tab to the CAO Web UI (browse/search/create/edit/clone/delete over the #575/#585 APIs) — the final piece of #510. The implementation and its 63 new tests are high quality: we independently reproduced the 240/240 suite, clean tsc/build, and every hardening claim in the PR body. However, maintainer @haofeif has an open CHANGES_REQUESTED at this exact head for a data-integrity race (stale template preview silently persisted), and our review found the root cause is systemic: previewSeq is bumped only when a request is issued, never when the preview is invalidated, so the flagged one-liner alone leaves two more paths to the same silent persistence. Recommendation: fix the token-invalidation class as a whole (all clear sites), not just the two flagged lines, and land regression tests with it.
Blocking (must fix before merge)
- [correctness][verification] web/src/components/ProfileCreateModal.tsx:286-306 (open-reset effect) and :311-314 (template-deselect branch) — 🆕 Both
setPreview(null)without bumpingpreviewSeq.current(the reset also leavespreviewTimerandpreviewLoadinguntouched). The modal stays mounted across close (openprop), so: preview in flight → close → reopen → the stale response still satisfiesseq === previewSeq.current, re-landssetPreview(staleContent)+setProfileName(extractFrontmatterName(stale)), andcanCreate(which checks onlypreview !== null && !previewLoading, nottemplate) enables Create — persisting a body from a template that isn't even selected, with the preview pane not rendered. This is the same silent-persistence class as the maintainer's P1 but a distinct trigger his line-333 fix does not cover. Fix: invalidate the token at every clear site — bumppreviewSeq.current, clearpreviewTimer, resetpreviewLoadingin the reset effect and the deselect branch (or bump the token on every preview-effect run/teardown).
Important (should fix)
- [consistency][correctness] ProfileCreateModal.tsx:309-327 — 🆕
getTemplateSchemais the one fetch in the modal with no staleness guard (no seq token, no cancelled flag, no abort) — unlike the search, preview, andProfileDetail.getProfileeffects beside it. Fast A→B template switch with out-of-order resolution leavestemplateSchema= A's schema andconfig= A's seeded defaults whiletemplate= B: the form renders A's fields under a B selection and the debounced preview renders B with A's config. Same fix norm as the blocking item: token orcancelledflag keyed on the selected template. - [tests] web/src/test/ — 🆕 The monotonic seq-guard machinery the PR headlines has zero test coverage: no test creates two overlapping in-flight requests with reordered resolution, so the
seq !== ...currentdiscard branch is never taken (all search/preview mocks resolve synchronously). The P1/P2 fixes should ship with gate-promise regression tests — (a) search: type, let the request go in flight, clear the box, then resolve → assert results stay null; (b) preview: template A render in flight, switch to B, resolve A → assert no A content in pane and Create does not POST A's body. Otherwise the one-liner fixes land undefended. - [tests] web/src/components/CustomSelect.tsx — 🆕 The shared-component rewrite (portal to
document.body, fixed positioning, flip-up, scroll/resize-to-close, portal-aware outside-click, newinvalidprop) is essentially untested — tests only pick options by testid, and theinvalidred-boundary path on a select is never asserted (the two red-boundary tests hit text/number inputs). This component backs Flows and Agents too; a regression in scroll-close/flip would be invisible to the suite. - [correctness] ProfileCreateModal.tsx (SchemaField object branch) — 🆕 Object/JSON textareas are uncontrolled (
defaultValue) and always receivevalue=undefined(object fields render only in Advanced, which passesundefinedfor object types). Collapse and reopen Advanced (or trigger the error-driven auto-expand) and the textarea remounts empty whilejsonDraftsstill holds — and persists — the typed JSON. Display/state desync: user sees an empty field, the value saves anyway; retyping clobbers the draft. Make the textarea controlled from the draft string. - [conventions] web/README.md — 🆕 The canonical frontend doc (linked from docs/web-ui.md and CODEBASE.md) enumerates every page, supporting component, and file in three places; this PR adds a page (ProfilesPanel) + three components (ProfileCreateModal, ProfileEditorModal, ValidationFindings) and updates none of them. Per the repo's documentation-maintenance rule, update all three enumerations in this PR.
- [conventions] CHANGELOG.md — 🆕 No
[Unreleased] → Addedentry for the new Profiles tab. The #510 arc's API work is already recorded in the changelog; this user-facing UI piece should be too.
Nits (optional)
- [correctness] ProfileCreateModal/ProfileEditorModal save paths — a transport error/timeout on the pre-save
validateProfile(a UX gate; the write route re-validates authoritatively) hard-blocks the save behind a generic "Validation failed". Consider letting transport failures fall through to the server-side validation while still blocking on real 400s. - [consistency] web/src/api.ts:174, 484-495 —
TemplateConfigValidation+validateTemplateConfig()are defined but never referenced anywhere in web/src (the UI usespreviewTemplateinstead). Wire it or drop it. - [consistency] web/src/api.ts:474-476 — the "category/name travels as two path segments, slash must NOT be encoded" comment sits above
getProfileSchema(fixed path, no template arg) but describesgetTemplateSchemabelow it. Move it. - [tests] App.tsx Alt+N renumbering — the flagged-as-risky shortcut shift has no keyboard test (only DOM order is asserted); one test that Alt+2 opens Profiles and Alt+3 opens Agents would pin it.
- [tests] ConfirmModal
typedreset-on-open and the delete/detail error paths (DELETE rejection → snackbar + row retained; detail-panegetProfilefailure) are untested. - [conventions] docs/web-ui.md — the Features paragraph doesn't mention profile management; one clause would do (component detail stays in web/README.md).
- [conventions] commit subject — 77 chars, over the ~72 norm; move the verb enumeration to the body.
- [conventions] ProfilesPanel.tsx — the
{/* Master list */}comment: "master-detail" is a standard pattern name, but the standalone "Master" could read as "List pane" per the project's inclusive-language convention.
Tests
63 new tests across 4 files, counts exact, and assertion quality is deliberately anti-tautological (ordering fixtures defeat both alphabetical and catalog-order coincidences; $&/$'/CRLF adversarial cases assert the actual old bugs; the byte round-trip pins ${VAR} through edit→PUT; the clone test asserts validated bytes equal the POST body; optimistic delete is proven independent of the refetch). The systematic gap is concurrency: the stale-response discard branch is never exercised anywhere, which is precisely where all the known and new race findings live — see the Important items for the two regression tests that should accompany the fixes. Secondary gaps: the CustomSelect rewrite, Alt+N mapping, ConfirmModal reset, and a few error paths.
Verification
Independent dynamic verification at this head (clean checkout):
- ✓ VERIFIED — web suite: 16 files, 240/240 passed (after working around the known npm optional-deps issue for rolldown's native binding, unrelated to the PR).
- ✓ VERIFIED —
npm run build(tsc + vite) exit 0; standalonetscexit 0; only the advisory 823 kB chunk-size notice. - ✓ VERIFIED — maintainer P1 defect chain end-to-end by code reading (early return at :333 leaves token valid → stale
setPreview→canCreateenables →createProfilepersists the stale body), plus the two additional un-flagged instances of the same root cause (open-reset effect, template-deselect branch) now in Blocking. - ✓ VERIFIED — maintainer P2 at ProfilesPanel.tsx:270-276 as described.
- ✓ VERIFIED — the
extractFrontmatterNamenit reproduced empirically (bodyname:returned when frontmatter lacks one). - ✓ Audited the remaining async paths in the diff: ProfileEditorModal's unguarded load is not a practical defect (conditional mount discards stale state); ProfilesPanel detail effect, MemoryGraphView, and useEventFollow use correct guard patterns.
Verdict
Request changes — maintainer @haofeif's CHANGES_REQUESTED stands at this exact head with the P1 data-integrity blocker unaddressed, and the fix should cover the whole token-invalidation class (the two additional preview-clear sites and the unguarded getTemplateSchema fetch), with regression tests, not just the two flagged lines. Everything else about the PR is in strong shape; once the race class is closed and the doc/CHANGELOG drift is patched, this is a clean approve.
Review findings on #692: the monotonic seq tokens guarding the debounced search and preview fetches were bumped only when a new request was issued, never when the reason for the in-flight request disappeared. Every clear path therefore left the old token valid, letting a late response re-land silently: - template switch (preview effect early return): the previous template's render re-armed Create with its body while the pane showed only 'Loading template schema...' (P1, silent wrong-document persistence) - modal close/reopen and template deselect: same class, distinct triggers - search-box clear: stale results restored under an empty box (P2) Also adds the staleness token getTemplateSchema was missing (fast A->B switch with out-of-order resolution left A's schema under B's selection). Regression tests use gated promises to genuinely reorder resolutions -- the seq-discard branch was previously never exercised by any test. All four new tests fail against the unfixed components (mutation-verified). Suite: 244/244, tsc clean, build clean.
Review items on #692: - Object/JSON textareas are controlled from the draft string: an uncontrolled defaultValue remounted EMPTY when Advanced collapsed and reopened while the draft silently persisted into the POST -- the user saw a blank field but the typed JSON still saved - extractFrontmatterName matches the frontmatter block first and scans name: within it, so a 'name:' line in the markdown body can no longer pre-fill the profile-name box (same bounding rewriteFrontmatterName already used) - CustomSelect gains its own suite (portal placement, portal-aware outside-click, flip-up/down positioning, scroll- and resize-to-close, invalid red boundary, disabled options). Writing it exposed a real defect: the resize path reused the scroll handler, whose contains() check throws on a resize event's window target, so the menu never closed on resize -- fixed with an instanceof Node guard - web/README.md (Profiles page section, supporting-components table, project tree), CHANGELOG.md Unreleased/Added, and docs/web-ui.md Features updated per the repo documentation-maintenance rule Suite: 255/255, tsc clean, build clean.
Remaining #692 review nits: - drop validateTemplateConfig + TemplateConfigValidation (defined but never referenced; the UI uses previewTemplate) and move the two-path-segment encoding comment to getTemplateSchema, the method it describes - pre-save validate transport failures fall through to the authoritative server-side validation instead of hard-blocking behind a phantom 'Validation failed'; real 4xx findings still block (both modals) - rename the 'Master list' comment to 'List pane' per the inclusive naming convention - tests: Alt+2/Alt+3 keyboard mapping pinned beyond DOM order, ConfirmModal typed-confirmation reset on reopen, DELETE failure (error snackbar + row retained), detail load failure (list survives, reselect works), and the validate transport fall-through Suite: 260/260, tsc clean, build clean.
PR #692 — reviewer reply draftsAll fixes are in three new commits (no force-push): 2806cfd (blockers), Reply 1 — haofeif's P1 thread (ProfileCreateModal.tsx:333, stale preview persisted)Fixed in 2806cfd, and you were right that the root cause was the token only Your probe scenario is now a regression test: template A's render released Reply 2 — haofeif's P2 thread (ProfilesPanel.tsx:272, stale search restored)Fixed in 2806cfd with exactly your snippet ( Reply 3 — haofeif's nit thread (extractFrontmatterName body match)Fixed in e070c3b using the bounding you suggested: match the frontmatter Reply 4 — top-level reply to haofeif's reviewThanks for the depth here — reproducing every claim in the PR body and then Follow-up commits e070c3b and 33d6477 address the rest of the review Reply 5 — top-level reply to gutosantos82's reviewThanks — the systemic framing was the right call. All three blocking sites On the important items (e070c3b): the JSON textareas are now controlled from Nits (33d6477): dead |
fanhongy
left a comment
There was a problem hiding this comment.
Summary
Reviewed PR #692 (feat/510-profiles-ui @ 33d6477) against main @ b6a0520: 18 files, +3422/−24, no Python or CI changes. Every backend route the UI calls exists at HEAD, and the UI's reading of the server contracts is accurate — /source (not the resolved GET /{name}) backs the editor, the ranked search order is never re-sorted, duplicated_in is surfaced, the {message, errors} write-rejection shape is rendered through the shared findings panel, and the _OMISSION_MESSAGE truncation contract (last-position, severity-of-omitted) is implemented correctly. Validation, staleness tokens, and the type-to-confirm delete gate are all real, not decorative. tsc --noEmit, npm test (260 tests), npm run build, and the four backend profile test modules (192 tests) are green locally.
Six findings, four of them demonstrated with runtime probes against the PR's own components: the detail pane never refreshes after an in-place edit, the header X re-opens the mid-save dismissal hole that Cancel and the backdrop were deliberately closed against, the scratch-mode document embeds an untrimmed name while the POST sends the trimmed one, and a failed profile-schema fetch leaves From-scratch mode on a permanent spinner. None is a security, data-loss, or build failure, so nothing here is P1.
Two design calls I looked at and am not flagging: the fall-through-to-write on a 5xx/transport pre-save validate failure is safe (the write route re-validates and returns a renderable 400) and is documented at both call sites; and the absence of an Authorization header is repo-wide and pre-existing, not introduced here — worth a separate issue now that write operations are on the ungated client.
Findings
P2 — The detail pane keeps showing pre-edit values after a successful save
web/src/components/ProfilesPanel.tsx:94-104 (the fetch effect), :403 (the render site), :221-229 (handleSaved)
ProfileDetail's fetch effect is keyed on [row.name]. After an in-place edit, handleSaved calls setSelected(name) (same name) and refreshCatalog(). The catalog refresh produces a new row object but an unchanged row.name, so the effect never re-runs and detail — the source of Provider, Model, Role, Tags and Capabilities — is never refetched.
Triggering scenario: select a local profile whose model is claude-sonnet-4, click Edit, change the frontmatter to model: claude-opus-5, save. The snackbar says Profile 'developer' saved, the PUT succeeded, and the detail pane still reads claude-sonnet-4 until the user selects a different profile and comes back. Verified by probe: detailFetches stays at 1 across the whole save, and the rendered pane is …Providerkiro_cliModelclaude-sonnet-4. description and source do update (they come from the refreshed catalog row), which makes the stale half more convincing, not less. Clone is unaffected — the new name changes the effect key.
Correction: give the detail fetch an explicit reload token, e.g. a reloadNonce counter bumped in handleSaved and passed as a prop into the effect's dependency array (or key={${selectedRow.name}:${reloadNonce}} on <ProfileDetail>). No existing test covers detail state after a save; profile-editor.test.tsx:84 asserts only the PUT body.
P2 — The header X is not disabled during a save, and closing mid-flight discards the write outcome silently
web/src/components/ProfileEditorModal.tsx:126, web/src/components/ProfileCreateModal.tsx:589
Both modals gate the backdrop on !saving (ProfileEditorModal.tsx:116, ProfileCreateModal.tsx:573) and disable Cancel while saving (:193, :755), with a comment at each site explaining why. The aria-label="Close" X calls onClose unconditionally, so it reopens exactly that hole — and for the editor it is worse than the comment anticipates, because ProfilesPanel.tsx:419 renders the editor as {editor && <ProfileEditorModal …>}, so onClose unmounts it.
Triggering scenario: click Save changes, then click X while the PUT is in flight. The PUT returns 400 {message: "Profile failed validation and was not written.", errors: […]}. setSaveError/setFindings land on an unmounted tree, and nothing else surfaces the failure: no snackbar, no alert. Verified by probe — with Cancel confirmed disabled === true at the same instant, clicking X unmounted the editor (Profile source textarea gone) and after the 400 resolved screen.queryAllByRole('alert') was []. The user is left believing an edit was saved that was rejected. The create modal has the same outcome by a different route: it stays mounted but renders null, so saveError is invisible and is then wiped by the reset effect on the next open.
Correction: onClick={saving ? undefined : onClose} (or disabled={saving}) on both X buttons, matching Cancel. profile-editor.test.tsx:370 already pins the backdrop path — parameterising it over backdrop / Cancel / X would pin all three.
P2 — Scratch-mode frontmatter carries the untrimmed name while the POST carries the trimmed one
web/src/components/ProfileCreateModal.tsx:496 vs :515 (and canCreate at :503-504)
handleCreate computes const name = profileName.trim() for the POST, and template mode rewrites the document with that trimmed value. buildScratchContent instead does { name: profileName, … } — untrimmed. canCreate only requires profileName.trim() !== '', so surrounding whitespace passes the client gate.
Triggering scenario: in From scratch, paste my-agent (trailing space — routine when copying a name out of a doc or terminal) and click Create. Verified by probe: the validated/POSTed document is ---\nname: "my-agent "\n---\n\n while the POST name is "my-agent". Confirmed against the real backend, validate_profile_text on that document returns exactly one finding: error | name | 'my-agent ' does not match '^[A-Za-z0-9_-]{1,64}$'. So the pre-save gate blocks the create and paints the Profile-name box red while quoting a value whose only defect is invisible — and the identical input succeeds in template mode. Were the pattern ever relaxed, the same skew would hit _validate_profile_for_write's name-identity check (api/main.py:2489-2495) instead.
Correction: use the trimmed name in buildScratchContent, e.g. { name: profileName.trim(), …scratchValues }, or hoist const name = profileName.trim() and pass it in.
P2 — A failed profile-schema fetch leaves From-scratch mode on a permanent spinner
web/src/components/ProfileCreateModal.tsx:336, rendered at :674-677
api.getProfileSchema().then(setProfileSchema).catch(() => setProfileSchema(null)) maps failure onto the same null that means "still loading", and the render is {!profileSchema ? <Loader2 …/> Loading profile schema… : …}. There is no error state and no retry. This is the opposite shape from the sibling template-schema fetch, which commit 2/4 specifically hardened to surface its error (:362-365).
Triggering scenario: GET /agents/profiles/schema returns 500 (or the request times out). Verified by probe: after switching to From scratch, profile-schema-loading is still present and screen.queryAllByRole('alert') is [] — the mode is permanently unusable with no indication of why. api.listProfileTemplates().catch(() => setTemplates([])) at :335 degrades more visibly (the select reads "No options available") but also swallows the cause. Neither path is tested.
Correction: add a schemaError state set in the catch and render it through the same amber/red banner the template path uses, keeping the spinner for the genuinely-in-flight case.
P3 — Object-typed primary fields would render blank while still saving their JSON (latent)
web/src/components/ProfileCreateModal.tsx:687 vs :726
handleScratchChange (:487-493) routes any field whose schema type === 'object' into jsonDrafts, never into scratchValues. The ADVANCED call site accounts for that (value={scratchProps[k].type === 'object' ? (jsonDrafts[k] ?? '') : scratchValues[k]}); the PRIMARY call site passes value={scratchValues[k]}. Commit e070c3b fixed exactly this bug — a blank textarea that still POSTs the typed JSON — but only at the advanced site.
Not reachable today: the six PRIMARY_FIELDS resolve against agent_profile.schema.json to name/description/provider/model (string) and tags/capabilities (array), none of them object. It returns silently the moment an object-typed field is added to PRIMARY_FIELDS or an existing primary field's schema type changes. Correction: use the advanced site's expression at both call sites.
P3 — The portaled menu declares role="listbox" but contains no options
web/src/components/CustomSelect.tsx:121 (container), :134 (items)
The portal wrapper gained role="listbox", but its children are <button> elements inside plain <div> group wrappers — no role="option", no aria-selected, and the trigger has aria-expanded without role="combobox", aria-haspopup, or aria-controls. Assistive technology announces a list box with zero items, which is worse than the previous role-less <div>. This affects every consumer of the shared component (Agents, Flows, Memory, workflow run comparison), not just the new modals.
Correction: either drop role="listbox" and keep the menu a plain container of buttons (matching the pre-PR behaviour), or complete the pattern — role="option" + aria-selected on each item, role="presentation" on the group wrappers, and role="combobox"/aria-controls on the trigger.
Validation / tests
All commands run from /tmp/cao-pr-review-awslabs-cli-agent-orchestrator-pr-692/checkout at 33d6477, darwin/arm64. The checkout was not modified (git status --porcelain empty before and after; the Vite output directory is gitignored).
| Command | Result |
|---|---|
npx tsc --noEmit (in web/) |
exit 0 |
npm test (vitest, the CI "Run tests" step) |
17 files / 260 tests passed |
npm run build |
exit 0 (pre-existing 823.71 kB chunk-size warning) |
uv run --frozen pytest test/api/test_api_profile_surface.py test/api/test_api_profiles.py test/services/test_profile_validator.py test/services/test_profile_store.py -q |
192 passed, 3 warnings |
Backend contract spot-checks (this PR changes no Python; these confirm the assumptions the UI is built on):
validate_profile_text('---\nname: "my-agent "\n---\n\n')→error | name | 'my-agent ' does not match '^[A-Za-z0-9_-]{1,64}$'(finding 3).render_template('aws/sqs-monitor', {…defaults only})→Config validation failed …\n - (root): 'profile' is a required property, which thetemplateErrorFieldsparser atProfileCreateModal.tsx:423-438matches exactly,(root)branch included.- All seven shipped
templates/aws/*/template.md.j2files carry a frontmattername:line, sorewriteFrontmatterName's no-name:no-op path is not reachable from the UI.
Findings 1–4 were each demonstrated by running the PR's own components under vitest with assertions written for the correct behaviour, so a failure means the defect is present. Probes live in a scratch copy of web/ at /tmp/probe692-web (rsync excluding node_modules, which is symlinked back) — the checkout itself was never written to. Probe outputs quoted inline above: detailFetches after save = 1; cancel disabled during save = true / editor still mounted after X = false / alerts visible anywhere = []; validated content = "---\nname: \"my-agent \"\n---\n\n" with POST name = "my-agent"; spinner still shown = true / alerts = [].
Open questions / residual risk
- CustomSelect in a real browser. The portal +
position: fixedrewrite is the widest-blast-radius change and jsdom cannot lay out, so the 9 new tests pin behaviour (portal placement, flip via mockedgetBoundingClientRect, portal-aware outside-click, scroll/resize close, Escape) but not geometry. Worth one manual pass through the Flows and Agents creation modals. Related nit, not filed as a finding: when the menu flips up,maxHeightstays atMENU_MAX_Hrather than being clamped to the space above, so on a very short viewport (< ~500 px) the top of the list would sit above the viewport edge and be unreachable. OMISSION_MESSAGEdrift.ValidationFindings.tsx:13is a hand-copiedprofile_validator._OMISSION_MESSAGE. If the Python string changes, the truncation notice silently degrades into an ordinary 100th finding row; no test crosses the language boundary.- Refresh. The catalog is fetched once on mount plus after this panel's own writes, with no manual refresh control — a
cao profile installwhile the tab is open is invisible until remount. Deliberate and commented; flagging because it compounds finding 1.
haofeif
left a comment
There was a problem hiding this comment.
Thanks for the thorough turnaround on this — the blockers were fixed as a class rather than patched at the one site I happened to reproduce, and every fix landed with a regression test that actually fails without it.
Reviewed at 33d6477eee97383dd3f93eb4399e91f8ebce15ec (merge-base b6a0520bd2cc3e1c2e7756197c84710afdc8bbb3). Three additive commits since my last review, no force-push, so the earlier history is intact.
Claims verified by execution
| Claim | Method | Result |
|---|---|---|
| 260/260, tsc + vite build clean | npm ci && npm test && npm run build |
✅ exactly 260 passed, 17 files; build exit 0 |
| P1 (stale template preview) fixed | replayed my original probe, unchanged | ✅ Create disabled, no POST (was: POSTed template A's body) |
| P2 (search clear) fixed | replayed my original probe | ✅ 6 catalog rows restored under an empty box (was: 1) |
nit (extractFrontmatterName) fixed |
my original decoy document | ✅ null (was: "decoy"); normal + CRLF still resolve real |
| "new tests fail against the unfixed components" | reverted each fix, re-ran its test | ✅ every mutant dies — see below |
| CustomSelect suite "caught a real bug" | reverted the instanceof guard |
✅ real: TypeError: Failed to execute 'contains' on 'Node': parameter 1 is not of type 'Node' |
validateTemplateConfig removal safe |
grep -rn across web/src/ |
✅ genuinely unreferenced |
Mutation results
Reverting each fix and re-running only its guard test:
M1 preview-effect early-return bump -> FAILS (guards)
M3 templateSeq staleness token -> FAILS (guards)
M4 searchSeq bump on clear -> FAILS (guards)
M5 bounded extractFrontmatterName -> FAILS (guards)
M6 JSON draft controlled value -> FAILS (guards)
resize `instanceof Node` guard -> FAILS (guards, with the TypeError above)
A correction to my own method, so the above is not over-read. My first pass mutated the open-reset invalidatePreview() alone and reported the reopen test as vacuous. That was my error: the three invalidation sites are mutually redundant by design, so removing one leaves the template-deselect branch covering the path. Removing all three together fails both your test and a stronger probe I wrote:
all 3 guards present -> create disabled: true, POSTed stale A body: no POST
remove all 3 sites -> create disabled: false, POSTed stale A body: true
So the reopen test does guard real behaviour, and the redundancy is a property of the fix rather than a gap in the test. Worth stating explicitly because a single-site mutation is a misleading way to grade this fix — as I demonstrated on myself.
On the two unrequested changes in these commits
Both were outside my original findings, so I reviewed them fresh rather than re-verifying:
- 5xx/transport fall-through on the pre-save validate (
ProfileCreateModal.tsx,ProfileEditorModal.tsx). Safe, and the justification in the comment holds:POST /agents/profilesandPUT /agents/profiles/{name}both call_validate_profile_for_writebeforewrite_profile(api/main.py:2571,:2605), whose docstring is explicit that "an invalid profile never reaches disk". A 4xx still blocks —ApiError.statusis set on every non-OK response infetchJSON— and a write rejection is surfaced through the same findings renderer viadetailMeta.errors, so nothing fails silently. Net effect is a flaky-validate call no longer hard-blocks behind a phantom "Validation failed". - JSON draft display desync. The
defaultValue→ controlledvaluechange is the right fix; an uncontrolled textarea remounted empty on Advanced collapse/reopen while the draft that actually saves persisted, which is the same silent-divergence shape as the original P1.
Verdict
Approving. All three findings from my previous review are fixed at the root, the fixes are pinned by tests that fail without them, the full suite is green on a pristine tree, and I found no new defects.
Note for the author: @gutosantos82's CHANGES_REQUESTED at the old head is a separate outstanding block and will need their re-review independently of this approval.
|
@gutosantos82 can you pls check |
gutosantos82
left a comment
There was a problem hiding this comment.
PR Review: #692 — feat(web): Profiles tab — browse, search, create, edit, clone, delete profiles
Summary
Adds the Profiles tab to the CAO Web UI (browse/search/create-from-template/create-from-scratch/edit/clone/delete over the #575/#585 APIs) — the final piece of #510. The implementation quality is high: no XSS sinks, well-hardened frontmatter helpers, genuinely discriminating race tests, and docs/CHANGELOG in sync. The earlier review blockers (stale-preview token class, search-clear restore) are fixed as a class at this head and were mutation-verified by @haofeif, who has approved. However, @fanhongy's four demonstrated P2 defects remain present and unanswered at the current head, and our review adds one new defect of the same family (cross-mode error-state bleed in the create modal). Recommendation: one more fix pass for the open P2s + the mode-switch bleed, then this is merge-ready.
Important (should fix)
- [correctness] web/src/components/ProfileCreateModal.tsx (mode-switch handlers) — 🆕 Switching between the From-template and From-scratch tabs clears none of the shared error state (
findings,saveError,previewError, and the derivederrorFields). A validation failure in template mode leaves the findings panel and red save-error box rendered on the scratch form, paints red boundaries on scratch controls whose field names collide (name,provider, …), and can auto-expand the scratch form's Advanced section for a finding that came from the template attempt. Reverse direction bleeds the same way; reset only happens on modal open. Fix: clear findings/saveError/previewError onmodechange — same one-effect shape as the open-reset. - [correctness] web/src/components/ProfilesPanel.tsx (
handleCreated, clonehandleSaved) — 🆕setSelected(newName)runs before the asyncrefreshCatalog()lands, so the just-created/cloned profile renders "Select a profile…" until the refetch resolves — and ifrefreshCatalog()fails, the new profile is selected but never appears (permanent blank detail with no path out except reselecting). Consider selecting after the refresh resolves, or tolerating a selected name not yet in the catalog. - [tests] web/src/test/ — 🆕 The create path lacks the end-to-end panel coverage the edit path has: every create test renders
ProfileCreateModalin isolation, so the New-profile button →handleCreated→ warning/success snackbar → post-create selection →refreshCatalogwiring is untested (the analogoushandleSavedpath IS covered through the panel). Adding it would also pin whichever behavior is chosen for the select-before-refresh item above.
Nits (optional)
- [correctness] both modals — after a blocked save,
findingsand the red field outlines persist while the user edits and only clear at the start of the next save attempt — misleading feedback; clearing (or greying) on edit would be kinder. - [correctness] ProfilesPanel.tsx (
ProfileDetail) — theduplicated_inshadow warning renders only for catalog rows;ProfileSearchResultcarries noduplicated_in, so the same shadowed profile shows the amber "Also defined in …" banner via the catalog but not when reached through search. - [correctness] ProfileCreateModal.tsx (open-reset effect) — the initial
listProfileTemplates/getProfileSchema/listProvidersfetches are plain.then(setX)with no staleness guard — the one async class in this PR without the seq-token discipline applied everywhere else. Low impact (idempotent endpoints), but a rapid close→reopen can repopulate from the earlier open's response. - [correctness] both modals — the Create/Save buttons gate on
!saving, but two synchronous clicks before re-render both observesaving === false, opening a double-submit window; a ref-based guard closes it. - [consistency] PR body — the Tests paragraph is stale after the three fix commits: counts are now 15/30/17/11 per file (not 12/25/15/11), the suite is 260 not 240, and
custom-select.test.tsxis an entire fifth new test file the body's "four files" never mentions. Worth refreshing before merge so the record matches what ships. - [conventions] docusaurus/docs/features/web-ui.md — the published-site features list did not get the Profiles bullet that
docs/web-ui.mdgot; per the documentation-maintenance rule, add it for parity. - [consistency] docs/web-ui.md, web/README.md — the detail pane is described as showing "source, provider, model, tags, capabilities" but also renders a Role field; trivial omission.
- [conventions] CHANGELOG.md — the new
### Addedblock is packed tight (no blank lines between bullets) unlike the rest of the section, and three adjacent provider entries (#624, #559, grok_cli) ride in via the merge of main; a glance post-squash that only the #510 entry lands as this PR's net change (it currently is — no duplication observed).
Tests
Strong, behavior-focused suite (~83 new tests; 260/260 claimed after the fix commits). The async/race tests are genuinely high quality: gated promises released after the reordering event, asserting the discard branch is actually taken; the search-order fixture defeats both alphabetical and catalog-order coincidences; debounce is asserted by call count; the truncation contract is tested with full 100-finding payloads including marker-severity and marker-mid-list cases; adversarial frontmatter regressions ($-patterns, CRLF, body decoy) are excellent. Two gaps mask the real defects noted above: nothing exercises the header X in either modal, and nothing inspects the detail pane after a save (a test that edits a parsed field and re-checks the pane would fail today). Minor: some tests couple to Tailwind class strings and layout selectors rather than testids/aria; detail-loading/search-spinner testids are never asserted; delete-success snackbar unasserted.
Verification
Dynamic verification did not return by synthesis time. Independent maintainer evidence on record: @haofeif ran npm ci && npm test && npm run build at 33d6477 (260 passed, build clean) and mutation-tested every race-fix guard; @fanhongy reproduced the suite, tsc, build, and the four backend profile test modules green at the same commit. The current head adds only a merge of main on top.
Verdict
Request changes — the code is close, but four demonstrated P2 defects from earlier review remain unaddressed at this head (stale detail pane after save, ungated header X in both modals, untrimmed scratch-mode name, permanent spinner on a failed schema fetch), our review adds a cross-mode error-state bleed of the same family, and an earlier CHANGES_REQUESTED review remains open pending its author's re-check. One focused fix pass (plus tests for the X and detail-pane paths) should make this merge-ready; the underlying feature and its test suite are in very good shape.
haofeif
left a comment
There was a problem hiding this comment.
Re-reviewed exact head cad050a947635f45cbdaa9503dd2bf2147fd063a. The feature and fixes approved at 33d6477eee97383dd3f93eb4399e91f8ebce15ec are unchanged; the only newer commit merges main at a5ccbe2624aabadfbdf64642c5f1e364db299ec3. git show --remerge-diff confirms the sole conflict was CHANGELOG.md, resolved additively by retaining both the Profiles entry and every upstream release entry. The web track remains at 260/260 tests with type-check/build successful, and all current checks are green. No findings.
Round-3 review items on #692 (fanhongy's probe-demonstrated P2s + gutosantos82's consolidation review): - detail pane refreshes after an in-place edit: the parsed-profile fetch was keyed on the profile NAME, which an edit does not change, so the pane kept pre-edit provider/model/tags until the user selected away and back. ProfileDetail is now remounted via a reload nonce bumped in handleSaved - the header X in both modals is gated on !saving, matching Cancel and the backdrop: closing mid-flight unmounted the editor (or left the create modal rendering null), so a late 400 rejection surfaced nowhere and the user believed a rejected save succeeded - scratch-mode frontmatter embeds the TRIMMED name, matching the POST: a trailing space previously failed the server name pattern with an error quoting a value whose only defect is invisible - a failed profile-schema fetch now renders an error banner instead of mapping onto the same null as 'still loading', which left From-scratch mode on a permanent spinner - switching between From-template and From-scratch clears the shared findings/saveError/previewError surfaces, which otherwise bled red boundaries onto the other mode's same-named fields and auto-expanded Advanced for a finding from the other mode - handleCreated/handleSaved select the new/cloned profile only after refreshCatalog resolves, so the just-created profile no longer renders 'Select a profile...' until the refetch lands Tests: +6 (X gating in both modals via gated POST/PUT, trimmed-name POST body, schema-error banner, mode-switch bleed, detail refetch after save, and the panel-level create flow that pins the New-profile -> snackbar -> refresh -> selection wiring). Five fail against the unfixed components (mutation-verified); the panel-level flow test is end-to-end wiring coverage pinning the chosen select-after-refresh behavior rather than a mutant-killer. Suite: 266/266, tsc clean, build clean.
The two P3s from fanhongy's #692 review: - the PRIMARY scratch-field call site now uses the same object-aware value expression as the ADVANCED site (object drafts live in jsonDrafts). Latent today -- no primary field resolves to type object -- but it returned the blank-textarea-that-still-saves bug the moment one did - the portaled CustomSelect menu is role-less again: role='listbox' over plain button children announced a list box with zero items to assistive technology, worse than the pre-portal container. The complete combobox/option pattern changes the trigger role for every consumer (Agents, Flows, Memory) and is deferred to a follow-up Suite: 266/266, tsc clean, build clean.
Remaining optional items from the #692 round-3 review: - editing after a blocked save clears the stale findings, red boundaries, and save error in both modals (previous feedback described the already-corrected document) - the duplicated_in shadow banner now renders when a profile is reached through SEARCH: search rows carry no duplicated_in, so the panel resolves it from the catalog by name for both row types - the open-reset fetches (templates/schema/providers) carry a staleness token -- the one async class without the seq discipline; a rapid close/reopen could repopulate from the earlier open's response - ref-based double-submit guard on Create/Save in both modals (two synchronous clicks both observed saving === false) - CHANGELOG: restored the [Unreleased] section -- the main merge had folded this PR's entry into the already-shipped 2.5.0 block, which does not contain the Profiles tab - docs parity: Profiles bullet in docusaurus/docs/features/web-ui.md, Role added to the README detail-pane enumeration Tests: +2 (search-reachable shadow banner, clear-on-edit after a blocked save). Suite: 268/268, tsc clean, build clean.
Adversarial probe on the round-3 mode-switch clear found a regression it introduced: previewError doubles as the template-schema LOAD error, and clearing it unconditionally on a template -> scratch -> template round-trip left template mode on a permanent loading spinner with no in-flight request -- the same defect class as the scratch-mode schema spinner fixed this round. The clear now preserves previewError while a selected template's schema is still missing; render-validation errors still clear. Probe test added (fails without the guard). Suite: 269/269, tsc clean, build clean.
|
All items addressed in four commits on top of the main merge. Suite is now 8788ab4 — the four open P2s plus your new items:
Six tests with it, including the panel-level create flow you asked for; the 8d6500e — the two P3s. For the listbox one I took fanhongy's option A 5e84a2d — the nits: findings clear as the user edits after a blocked 98f6b99 — probing the mode-switch clear before pushing found a PR body's Tests section refreshed to the current counts. Not done here: |
haofeif
left a comment
There was a problem hiding this comment.
Re-reviewed exact head 98f6b996. The round-three fixes close the previously reported synchronous state issues, and the focused 71-test web slice plus production build pass. Four async ordering gaps remain: one can persist an obsolete template render, while the others leave mutation/search state stale or reintroduce errors across create modes. These were independently exercised at this head; details are inline.
Round-4 review (haofeif, at 98f6b99): four async ordering gaps, all the same class as the original P1 -- a token advanced only when a request is ISSUED cannot invalidate responses that become stale when the underlying STATE changes: - (P1) the preview generation now advances the moment template/schema/ config changes, not when the debounce fires: a render in flight for config A landing during config B's 300ms debounce window matched the sequence, installed A's body, and re-armed Create while the form displayed B - catalog loads carry a monotonic token: a slow mount request resolving AFTER a post-write refresh overwrote the fresh catalog and deleted the just-created row - mutations reconcile the ACTIVE search: the visible rows are results ?? catalog, so refreshing only the catalog left the created/ cloned profile invisible and the detail pane on 'Select a profile...' under a success snackbar. The search is cleared and selection happens only against the refreshed row set; a failed refresh stays a failure - the create-mode tabs are disabled while a save is in flight, so a validation/POST from the old mode can no longer repopulate errors and red boundaries on the newly selected form Tests: +4, one per finding, each reproducing the reviewer's ordering with gated promises; all four fail against the unfixed components (mutation-verified). Suite: 273/273, tsc clean, build clean.
PR #692: round-4 reply to @haofeif (top-level PR comment)All four fixed in 42b7df7. You named the root cause precisely: the tokens Four tests with it, one per finding, each reproducing your ordering with |
haofeif
left a comment
There was a problem hiding this comment.
Re-reviewed exact head 42b7df72. The four previously reported async defects are addressed, but three adjacent generation/ownership races remain: one P1 can save a settled preview from the previous template, and two P2s can display results for the wrong query or overwrite navigation performed after a save. The focused frontend suite passes (55 tests) and the production build succeeds; the passing tests do not exercise these windows.
| // window still matched the sequence, installed A's body, and re-armed | ||
| // Create while the form displayed B (round-4 P1). | ||
| const seq = ++previewSeq.current | ||
| if (!template || !templateSchema) { |
There was a problem hiding this comment.
[P1] Clear or bind a settled preview when the template changes
The early generation bump invalidates an in-flight response, but this no-schema branch leaves an already-settled preview untouched. Let template A finish previewing, then select B while its schema is pending or fails: this branch sets loading false, canCreate remains true because A is still non-null, and handleCreate persists A while the UI identifies B. Clear the preview on every template/schema transition that cannot render, or associate it with the exact current template/config generation before enabling Create.
| } | ||
| setSearching(true) | ||
| debounceRef.current = setTimeout(() => { | ||
| const seq = ++searchSeq.current |
There was a problem hiding this comment.
[P2] Invalidate the prior search as soon as a non-empty query changes
searchSeq still advances only when the debounced request starts. If search A is in flight, the user types different non-empty query B, and A resolves during the 300 ms debounce, A still passes this guard and installs its rows under B. If B then hangs or fails, those mismatched rows remain actionable. Advance and capture the generation immediately on every query change, before scheduling the timer.
| // not in the catalog yet, and an active search's stale rows never carry | ||
| // it (round-4 review). | ||
| if (!(await refreshCatalog())) return | ||
| clearSearchAndSelect(name) |
There was a problem hiding this comment.
[P2] Do not let a late post-save refresh overwrite newer navigation
The create/edit modals call these async callbacks and close without awaiting them, so the panel is interactive while refreshCatalog() is pending. If the user starts a search or selects another profile in that window, this continuation unconditionally clears the new search and selects the saved profile when the older refresh resolves. Clear mutation-time state before starting the refresh and guard the eventual selection with an interaction/mutation generation so later navigation wins.
…ync transitions Round-5 review (#692): three adjacent generation/ownership races. - P1: the preview effect's cannot-render branch invalidated in-flight renders but left an already-SETTLED preview in state, so canCreate stayed armed and Create could persist template A's content while the UI identified template B. The branch now clears the preview (previewError is preserved -- it doubles as the current template's schema-load error). - P2: searchSeq advanced only when the debounced request started, so a response for prior query A resolving inside B's 300ms debounce window installed A's rows under B. The generation now advances on every query change, before the timer (same shape as the round-4 preview fix). - P2: the post-save continuation raced user navigation performed while its catalog refresh was pending, clearing the newer search and force-selecting the saved profile. Mutation-time state is now cleared BEFORE the refresh, and the eventual selection is guarded by a user-navigation generation bumped on every search keystroke and row selection -- later navigation wins. 3 gated-promise regression tests reproducing the reviewer's orderings; all 3 fail against the unfixed components (mutation-verified).
Behavior-preserving migration of the six hand-rolled generation counters (previewSeq, templateSeq, openSeq, searchSeq, catalogSeq, navSeq) to one shared hook owning the full invariant: begin() binds work to the current state at state-change time, invalidate() stales outstanding work when its reason disappears, and isCurrent() gates every application of a result. Five review rounds of #692 each found a hand-rolled site missing one piece of this invariant; the hook makes the next such miss structurally impossible rather than a matter of per-site discipline. Verified behavior-preserving by the existing gated-promise race suite (every round-2 through round-5 regression test passes unchanged), plus 4 unit tests on the hook itself.
Adversarial probe on the round-5 P1 fix: the failure mode of over-clearing is a permanently disarmed Create. Pins that a late schema resolution for the newly selected template re-arms the flow end-to-end -- preview renders the NEW template's content and Create re-enables.
|
Thanks @haofeif all three verified and fixed, plus a structural change so this class of finding can't recur site-by-site. 0d3ca10 — the three findings. P1: the cannot-render branch now clears a settled preview too, so 232a4af — aea697b additionally pins the recovery path of the P1 fix (probing for the over-clearing failure mode: a late schema resolution must re-arm Create with the NEW template's render, which it does). Suite is 281/281, tsc and build clean, main merged (7734319). |
Overview
Third and final PR for #510, building the web UI on top of the APIs from #575 (validation service,
/validate,/schema) and #585 (create/update/delete/source endpoints). Adds a Profiles tab where you can browse and search installed profiles, create new ones from a scaffold template or from scratch, edit and clone existing ones, and delete local-store profiles — with the backend validator wired in front of every write.AgentPanelis untouched; it remains the agent-launch picker. Closes #510 once merged.What's in it
Navigation. A Profiles tab between Home and Agents, and the Home dashboard's Profiles stat card now navigates there. This renumbers the Alt+N shortcuts for the tabs after it (one-time shift; the code comment explains the ordering rationale).
List, search, detail. Master-detail layout. The catalog is fetched once on mount — no polling. Search delegates ranking to
GET /agents/profiles/searchwith a 300 ms debounce; results render in server order (the client never re-sorts) and a monotonic token discards stale responses. The detail pane shows source, provider, model, tags, capabilities, and a warning whenduplicated_inreports the name shadowed across directories.Create. One modal, two entry points. From template: pick a scaffold template, fill a form generated from that template's own JSON-Schema, and watch a live preview rendered by
POST /templates/preview(same 300 ms debounce; Create is gated while a render is in flight, and the preview shows the exact document that will be persisted, including the frontmatter name rewrite). From scratch: the form is generated fromGET /agents/profiles/schema— primary fields visible, everything else behind an Advanced expander. Object-valued fields (mcpServers,codexConfig, …) use validated JSON editors rather than bespoke widgets. Frontmatter is emitted as JSON-valued YAML, so no YAML dependency is added.provideris a select fed by the live registry (uninstalled providers labelled but selectable, free-text fallback if the registry call fails);roleis a datalist with the built-in roles plus free entry forsettings.jsoncustom roles;modelstays free text deliberately — there is no registry of valid model IDs to validate against.Edit, clone, delete. Edit opens a raw document editor over
GET /agents/profiles/{name}/sourceand saves via PUT — deliberately not the schema form, because an edit must round-trip the exact stored bytes (env-var placeholders intact; one test pins a${VAR}surviving the full load→edit→PUT cycle). Only local-store profiles get Edit/Delete; built-in, provider, and custom profiles are read-only and offer "Clone to customise", matching the backend's write model. Delete sits behind a type-the-name-to-confirm gate (an optional, additiveconfirmationTextprop on the sharedConfirmModal; existing callers unchanged).Validation.
POST /agents/profiles/validateruns before every save, in both modals and both modes. Errors block client-side; warnings render but allow (and surface again via snackbar after the save). The findings panel renders the truncation contract from #585 precisely: at most 100 findings including one omission marker, exactly once, last, with its severity matching the omitted producer — an error-severity marker is explicitly flagged as hiding errors, and the marker text in any non-final position renders as an ordinary finding. Error findings also paint the specific form control they name (dotted path rooted at the frontmatter key,(root)required-property errors mapped by the quoted name), auto-expanding the Advanced section when the target is hidden. Clone validation runs on the exact rewritten document; one test asserts the validated bytes equal the POST body.Hardening
The branch was reviewed adversarially before opening, with empirical probes against the frontmatter helpers; everything found was fixed here rather than left for review:
rewriteFrontmatterNameuses replacement functions, not strings — a replacement string interprets$-patterns, which corrupted names containing$&/$'and mangled documents whose frontmatter legally contains such text.Tests
91 net-new UI tests across five files (suite: 268/268 on merge-base 177;
tscand production build clean):profiles-panel.test.tsx— navigation, list/detail, debounced server-rankedsearch, stale-response invalidation, the search-reachable
duplicated_inbanner, Alt+2/Alt+3 keyboard mapping
profile-create-modal.test.tsx— frontmatter helpers (adversarial$-pattern/CRLF/body-decoy regressions), template + from-scratch flows,stale-preview invalidation (gated promises, reordered resolution),
round-3 fixes (X gating, trimmed name, schema-error banner, cross-mode
error clearing)
profile-editor.test.tsx— source round-trip with placeholders intact,clone rewrite, confirm-gated delete, error paths, backdrop/Cancel/X gating
mid-save, detail-pane refetch after save, panel-level create flow
validation-findings.test.tsx— the bounded-findings truncation contract(marker exactly once and last, error-severity marker, mid-list marker text
as ordinary finding)
custom-select.test.tsx— the shared dropdown's portal placement, flippositioning, scroll/resize close, portal-aware outside-click, invalid
boundary
The race tests use gated promises released after the invalidating event, so
the seq-token discard branch is genuinely taken; the behavioral fixes in the
review commits are mutation-verified (each guard test fails with its fix
reverted).
Not in scope (per #510)
Launching agents from the Profiles tab, workflow/team composition, profile rename semantics, and full JSON-Schema modelling of every object-valued field (JSON editors suffice). One known follow-up: making the template live preview directly editable (with dirty-state semantics so form edits don't clobber manual edits) — deferred to keep this PR reviewable; happy to file an issue.