Skip to content

feat(web): Profiles tab -- browse, search, create, edit, clone, delet… - #692

Open
sujoydc wants to merge 15 commits into
mainfrom
feat/510-profiles-ui
Open

feat(web): Profiles tab -- browse, search, create, edit, clone, delet…#692
sujoydc wants to merge 15 commits into
mainfrom
feat/510-profiles-ui

Conversation

@sujoydc

@sujoydc sujoydc commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

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.

AgentPanel is 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/search with 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 when duplicated_in reports 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 from GET /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. provider is a select fed by the live registry (uninstalled providers labelled but selectable, free-text fallback if the registry call fails); role is a datalist with the built-in roles plus free entry for settings.json custom roles; model stays 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}/source and 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, additive confirmationText prop on the shared ConfirmModal; existing callers unchanged).

Validation. POST /agents/profiles/validate runs 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:

  • rewriteFrontmatterName uses replacement functions, not strings — a replacement string interprets $-patterns, which corrupted names containing $&/$' and mangled documents whose frontmatter legally contains such text.
  • The frontmatter regexes accept CRLF, so cloning a Windows-authored profile can't silently no-op the name rewrite and then fail the server's name-match check.
  • Backdrop clicks are inert while a save is in flight (Cancel already was).
  • The authoring calls (validate/preview/create/replace) use a 30 s timeout instead of the 10 s default, so a slow round-trip doesn't render as a phantom validation failure.
  • A failing template-schema fetch surfaces its error instead of setting it invisibly behind a schema-gated render.

Tests

91 net-new UI tests across five files (suite: 268/268 on merge-base 177;
tsc and production build clean):

  • profiles-panel.test.tsx — navigation, list/detail, debounced server-ranked
    search, stale-response invalidation, the search-reachable duplicated_in
    banner, 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, flip
    positioning, 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.

@haofeif haofeif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 CustomSelect menu breaking existing callers. z-[80] clears the z-[60] maximum elsewhere, and the two backdrop closers (AgentPanel:518, FlowsPanel:300) are element-level onClicks, not document-level handlers, so a click on the portaled menu never reaches them. The pre-existing memory-graph test opens the menu and clicks an option, so this is covered by tests that predate the PR.
  • Mixed line endings from a CRLF clone. rewriteFrontmatterName does 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}.
  • deleteProfile against 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.

Comment thread web/src/components/ProfileCreateModal.tsx Outdated
Comment thread web/src/components/ProfilesPanel.tsx
}

/** Extract the frontmatter `name:` value from a rendered document, if any. */
export function extractFrontmatterName(content: string): string | null {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 gutosantos82 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 bumping previewSeq.current (the reset also leaves previewTimer and previewLoading untouched). The modal stays mounted across close (open prop), so: preview in flight → close → reopen → the stale response still satisfies seq === previewSeq.current, re-lands setPreview(staleContent) + setProfileName(extractFrontmatterName(stale)), and canCreate (which checks only preview !== null && !previewLoading, not template) 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 — bump previewSeq.current, clear previewTimer, reset previewLoading in 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 — 🆕 getTemplateSchema is the one fetch in the modal with no staleness guard (no seq token, no cancelled flag, no abort) — unlike the search, preview, and ProfileDetail.getProfile effects beside it. Fast A→B template switch with out-of-order resolution leaves templateSchema = A's schema and config = A's seeded defaults while template = 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 or cancelled flag 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 !== ...current discard 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, new invalid prop) is essentially untested — tests only pick options by testid, and the invalid red-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 receive value=undefined (object fields render only in Advanced, which passes undefined for object types). Collapse and reopen Advanced (or trigger the error-driven auto-expand) and the textarea remounts empty while jsonDrafts still 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] → Added entry 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-495TemplateConfigValidation + validateTemplateConfig() are defined but never referenced anywhere in web/src (the UI uses previewTemplate instead). 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 describes getTemplateSchema below 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 typed reset-on-open and the delete/detail error paths (DELETE rejection → snackbar + row retained; detail-pane getProfile failure) 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; standalone tsc exit 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 setPreviewcanCreate enables → createProfile persists 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 extractFrontmatterName nit reproduced empirically (body name: 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.
@sujoydc

sujoydc commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

PR #692 — reviewer reply drafts

All fixes are in three new commits (no force-push): 2806cfd (blockers),
e070c3b (important items), 33d6477 (nits). Suite is now 260/260, tsc and
build clean.


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
being bumped when a new request is issued. Rather than patching just this
early return, I closed the class: every site that clears the preview now
invalidates the token (the early return you flagged, plus the open-reset
effect and the template-deselect branch, which had the same latent path via
the modal staying mounted across close/reopen). getTemplateSchema also
gained a staleness token — it was the one fetch in the modal with no guard,
so a fast A→B switch with out-of-order resolution could leave A's schema
under B's selection.

Your probe scenario is now a regression test: template A's render released
only after switching to B (B's schema held in-flight), asserting no A content
surfaces, Create stays disabled, and no POST can carry A's body. All four new
race tests use gated promises to genuinely reorder resolutions, and all four
fail against the unfixed components.


Reply 2 — haofeif's P2 thread (ProfilesPanel.tsx:272, stale search restored)

Fixed in 2806cfd with exactly your snippet (searchSeq.current++ in the
clear branch). Regression test holds the search response in flight, clears
the box, then releases it — the full catalog must stay, with the box empty
and no error left behind.


Reply 3 — haofeif's nit thread (extractFrontmatterName body match)

Fixed in e070c3b using the bounding you suggested: match the frontmatter
block first, then scan name: within the captured group — same shape
rewriteFrontmatterName already used. Your decoy document is now a test
case (body name: decoy with no frontmatter name → null), plus a guard that
the normal case still resolves.


Reply 4 — top-level reply to haofeif's review

Thanks for the depth here — reproducing every claim in the PR body and then
finding the one class the self-review missed is exactly the review this
needed. Both defects are fixed in 2806cfd, and since the root cause was
systemic (token bumped only on request issue, never on invalidation), the fix
covers every clear site plus the unguarded template-schema fetch, not just
the two flagged lines. Details and regression tests in the inline replies.

Follow-up commits e070c3b and 33d6477 address the rest of the review
feedback (JSON-draft display desync, bounded name scan, CustomSelect test
suite, docs/CHANGELOG sync, dead API surface, error-path tests). One thing I
can't fix without rewriting pushed history: the 77-char subject on the
original commit — happy to have the squash-merge message shortened at merge
time.


Reply 5 — top-level reply to gutosantos82's review

Thanks — the systemic framing was the right call. All three blocking sites
are fixed as a class in 2806cfd: invalidatePreview() (token bump + timer
clear + loading reset) runs at the open-reset effect, the deselect branch,
and the preview effect's early return, and getTemplateSchema now carries
its own staleness token. The two regression tests you specified are in
(gated promises, reordered resolution, discard branch actually taken), plus
a close/reopen test and an out-of-order schema test — all four fail against
the unfixed components.

On the important items (e070c3b): the JSON textareas are now controlled from
the draft string (your display/state desync repro is a test), and CustomSelect
has its own 9-test suite — which promptly caught a real bug: the resize path
reused the scroll handler, whose contains() throws on a resize event's
window target, so the menu never actually closed on resize. Fixed with an
instanceof Node guard. README (all three enumerations), CHANGELOG, and
docs/web-ui.md are synced.

Nits (33d6477): dead validateTemplateConfig dropped, encoding comment
moved to the method it describes, "List pane" rename, validate transport
failures now fall through to the authoritative server-side validation (real
4xx still blocks), and tests for Alt+2/Alt+3, ConfirmModal reset-on-open,
DELETE failure, and detail load failure. The commit-subject nit I'll leave
to the squash-merge message, since the original commit is already pushed.

@fanhongy fanhongy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 the templateErrorFields parser at ProfileCreateModal.tsx:423-438 matches exactly, (root) branch included.
  • All seven shipped templates/aws/*/template.md.j2 files carry a frontmatter name: line, so rewriteFrontmatterName'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

  1. CustomSelect in a real browser. The portal + position: fixed rewrite is the widest-blast-radius change and jsdom cannot lay out, so the 9 new tests pin behaviour (portal placement, flip via mocked getBoundingClientRect, 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, maxHeight stays at MENU_MAX_H rather 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.
  2. OMISSION_MESSAGE drift. ValidationFindings.tsx:13 is a hand-copied profile_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.
  3. Refresh. The catalog is fetched once on mount plus after this panel's own writes, with no manual refresh control — a cao profile install while the tab is open is invisible until remount. Deliberate and commented; flagging because it compounds finding 1.

@haofeif haofeif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/profiles and PUT /agents/profiles/{name} both call _validate_profile_for_write before write_profile (api/main.py:2571, :2605), whose docstring is explicit that "an invalid profile never reaches disk". A 4xx still blocks — ApiError.status is set on every non-OK response in fetchJSON — and a write rejection is surfaced through the same findings renderer via detailMeta.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 → controlled value change 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.

@haofeif

haofeif commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

@gutosantos82 can you pls check

@gutosantos82 gutosantos82 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 derived errorFields). 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 on mode change — same one-effect shape as the open-reset.
  • [correctness] web/src/components/ProfilesPanel.tsx (handleCreated, clone handleSaved) — 🆕 setSelected(newName) runs before the async refreshCatalog() lands, so the just-created/cloned profile renders "Select a profile…" until the refetch resolves — and if refreshCatalog() 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 ProfileCreateModal in isolation, so the New-profile button → handleCreated → warning/success snackbar → post-create selection → refreshCatalog wiring is untested (the analogous handleSaved path 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, findings and 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) — the duplicated_in shadow warning renders only for catalog rows; ProfileSearchResult carries no duplicated_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/listProviders fetches 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 observe saving === 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.tsx is 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.md got; 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 ### Added block 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 haofeif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@sujoydc

sujoydc commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

All items addressed in four commits on top of the main merge. Suite is now
269/269, tsc and build clean, and the four backend profile modules pass
(192) at this head.

8788ab4 — the four open P2s plus your new items:

  • stale detail pane: ProfileDetail remounts via a reload nonce bumped in
    handleSaved (the key={name:nonce} shape from fanhongy's finding)
  • header X gated on !saving in both modals, matching Cancel/backdrop
  • scratch frontmatter embeds the trimmed name, matching the POST
  • failed profile-schema fetch renders an error banner, not the spinner
  • mode switch clears findings/saveError/red boundaries (your bleed finding)
  • handleCreated/handleSaved select only after refreshCatalog resolves

Six tests with it, including the panel-level create flow you asked for; the
five behavioral fixes fail their tests with the fix reverted.

8d6500e — the two P3s. For the listbox one I took fanhongy's option A
(drop the role, restoring the pre-portal container): the full combobox/option
pattern changes the trigger role for every consumer and deserves its own PR.

5e84a2d — the nits: findings clear as the user edits after a blocked
save, the duplicated_in banner now resolves from the catalog so it renders
via search too, the open-reset fetches carry a staleness token, ref-based
double-submit guards, docusaurus/README doc parity, and the CHANGELOG entry
moved back under [Unreleased] (the main merge had folded it into the
already-shipped 2.5.0 block, which doesn't contain this feature).

98f6b99 — probing the mode-switch clear before pushing found a
regression it introduced: previewError doubles as the template-schema load
error, so a template→scratch→template round-trip after a failed schema fetch
stranded template mode on a permanent spinner. The clear now preserves the
load error; probe kept as a regression test.

PR body's Tests section refreshed to the current counts. Not done here:
the combobox a11y pattern (follow-up above) and a real-browser pass over
CustomSelect, which I've covered manually on the dev server.

@haofeif haofeif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread web/src/components/ProfileCreateModal.tsx Outdated
Comment thread web/src/components/ProfilesPanel.tsx
Comment thread web/src/components/ProfilesPanel.tsx Outdated
Comment thread web/src/components/ProfileCreateModal.tsx Outdated
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.
@sujoydc

sujoydc commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

PR #692: round-4 reply to @haofeif (top-level PR comment)

All four fixed in 42b7df7. You named the root cause precisely: the tokens
advanced when a request was issued, not when the state that justified it
changed. The P1 fix moves the generation bump to the top of the preview
effect, so any response for the prior template/schema/config is stale the
moment the form changes — including during the debounce window your probe
exploited. Catalog loads now carry the same monotonic token as search and
preview, mutations clear the active search and select only against the
refreshed row set (with a failed refresh staying a failure), and the mode
tabs are disabled while a save is in flight.

Four tests with it, one per finding, each reproducing your ordering with
gated promises; all four fail with the fixes reverted. Suite is 273/273,
tsc and build clean.

@haofeif haofeif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread web/src/components/ProfilesPanel.tsx Outdated
}
setSearching(true)
debounceRef.current = setTimeout(() => {
const seq = ++searchSeq.current

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread web/src/components/ProfilesPanel.tsx Outdated
// not in the catalog yet, and an active search's stale rows never carry
// it (round-4 review).
if (!(await refreshCatalog())) return
clearSearchAndSelect(name)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.
@sujoydc

sujoydc commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

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 canCreate can never stay armed by the previous template's content (previewError is left alone — it doubles as the current template's schema-load error). Search P2: the generation now advances on every query change, before the timer — the same shape as the round-4 preview fix, which we should have applied here at the time. Navigation P2: mutation-time state is cleared before the refresh, and the continuation captures a user-navigation generation (bumped by every search keystroke and row selection) and yields if it moved — later navigation wins. Three gated-promise regression tests reproduce your exact orderings; all three fail with the fixes reverted.

232a4afuseGeneration. Five rounds of this review have each found a hand-rolled counter missing one piece of the same invariant (issue-time vs state-change-time bumps, clear paths that issue no request, continuations racing navigation). Rather than keep patching sites, this extracts one shared hook — begin() at state-change time, invalidate() when the reason for outstanding work disappears, isCurrent() gating every application — and migrates all six counters across both files to it. Behavior-preserving: every round-2 through round-5 race test passes unchanged against the migrated code, plus 4 unit tests on the hook itself.

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).

@sujoydc
sujoydc requested a review from haofeif September 3, 2026 18:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] Web UI: profile management surface — search, create, edit, delete, validate

4 participants