feat(ui-rewrite): add chip-style TagInput with /tags autocomplete - #5777
Conversation
|
Folded in the suggestions from the review note on #5640:
Pushed as a follow-up commit. |
9282964 to
5b0ff1c
Compare
4e655a0 to
4025978
Compare
0a994c6 to
10d27fa
Compare
4025978 to
911b942
Compare
marekdano
left a comment
There was a problem hiding this comment.
@Altamimi-Dev - thanks for the contribution!
Nice work on this! The string → string[] migration is clean and consistent across all four form hooks, building it from repo primitives with no new dependency was the right call, and the combobox/listbox ARIA implementation is genuinely well done (activedescendant, arrow-key nav, backspace-to-remove, focus restoration after removal, role="status" for the max message). Tests ship with the code too. Just one thing worth addressing before merge, plus a few optional notes.
Worth fixing
Resource form dropped per-tag sanitization. The PR notes say per-tag sanitize/length limits are preserved, but that holds for tool/prompt/server (their zod schemas keep sanitizeString(...) in the transform) and not for resources:
// useResourceForm.ts
tags: z.array(z.string()).optional(), // no transform
// getFormData:
tags: tags.length > 0 ? tags : undefined, // raw, unsanitizedTagInput only trims - it doesn't strip control chars/CRLF or enforce the 200-char cap that sanitizeString gives. The backend validators are still a backstop, so this is a defense-in-depth gap rather than an open hole, but it's inconsistent with the other three forms. Could we restore the transform on the resource schema (or in getFormData) to match? e.g. z.array(z.string().transform((t) => sanitizeString(t, 200))).
Suggestions
- i18n:
TagInputhardcodes a few user-visible / screen-reader strings —Create "<value>",Maximum N tags reached., and theRemove <tag>aria-label. Every consuming form usesintl.formatMessage(...), so it'd be nice to route these through react-intl for consistency. - Test coverage: the suite is solid, but a couple of core keyboard paths aren't covered - arrow-key + Enter to select a suggestion (only mouse-click selection is tested, and this is the main keyboard-a11y path), Tab/blur commit, and paste that exceeds
maxTags.
Minor
- The
// ponytail:comment inuseTagSuggestions.tslooks like a stray artifact — worth rewording. - A failed
/tagsfetch leaves the module cachenull, so it refetches on every subsequent form mount (e.g. a 403 per form open for a user withouttags.read). A "fetched" sentinel would avoid that if you think it's worth it. - Placeholders like
"tag1, tag2, tag3"read slightly oddly now that it's a chip input (comma still works, so low impact).
All tests should be green.
Signed-off-by: Ahmad Al Tamimi <altamimi.dev@gmail.com>
…helper Signed-off-by: Ahmad Al Tamimi <altamimi.dev@gmail.com>
Review follow-ups on the chip-style TagInput: - getTagLabels accepts the generated ToolReadTagsItem shape (an index-signature record whose label is not statically declared) and drops blank labels, fixing the tsc break that came in with the epic rebase. - Resource tags run through sanitizeString(t, 200) again, matching useToolForm; the transform was lost when the field moved to TagInput. - The three user-visible TagInput strings and the resource tags placeholder are localized under common.tagInput.* in en-US, es-ES and pt-BR. - useTagSuggestions guards on a fetch sentinel rather than cache truthiness, so a 403 or network failure no longer refetches /tags on every form mount. - New tests: ArrowDown+Enter suggestion select, Tab commit, blur commit, and a paste capped at the remaining maxTags capacity. Signed-off-by: Ahmad Al Tamimi <altamimi.dev@gmail.com>
911b942 to
fdfa729
Compare
|
Thanks for the review — the sanitization regression was a genuine miss, and the i18n and test gaps were fair calls. All taken. Pushed in CI (the two Review items:
Full client suite green (136 files / 2557 tests), |
TagInput only split on commas inside the paste handler, so a value set programmatically (Playwright fill, browser autofill) reached the blur commit as a single tag: 'security, qa' became one chip instead of two. This broke the virtual-servers create E2E, which fills the tags field rather than typing it. Split inside addTags so every entry path -- Enter, comma, Tab, blur, paste -- goes through the same delimiter handling. Signed-off-by: Ahmad Al Tamimi <altamimi.dev@gmail.com>
|
Correction to my note above — the Playwright failure was not downstream of the type break. I ran the suite locally and it was a real regression in this PR.
Fixed in Local runs on the pushed head: Playwright 171/171 passed, vitest 2558 passed, |
ToolAdvancedSettings still hardcoded 'Add optional tags separated with commas' -- an unlocalized string, and one that describes comma entry rather than the chip input it now labels. Routed through common.tagInput.placeholder like the resource form. Signed-off-by: Ahmad Al Tamimi <altamimi.dev@gmail.com>
|
One more from your placeholder note — I'd only half-done it. Fixed in
Deliberately left alone, for the record:
Verification on the pushed head: Playwright 171/171, vitest 2558 passed, |
marekdano
left a comment
There was a problem hiding this comment.
@Altamimi-Dev — thanks for the thorough follow-ups on this round. The i18n, keyboard-test, and fetch-sentinel items are all cleanly addressed, and the programmatic-fill regression catch in 03c0c1033 was a good one. The combobox/listbox a11y work remains the standout here.
One thing surfaced on a closer read that I'd like to sort before merge, plus a couple of optional notes.
Worth fixing
Tool and Resource forms send tags unsanitized — the schema transform never runs on the payload.
The "restore resource tag sanitization" commit added the transform to the schema, which reads correctly, but for these two hooks getFormData builds the payload by hand and never calls schema.parse — the transform only runs in validateForm/isValid, whose transformed output is discarded. So it's inert for what actually gets submitted.
Before this PR, both hooks sanitized each tag inline in getFormData:
// old getFormData (tool + resource)
tags: tags ? tags.split(",").map((t) => sanitizeString(t.trim(), 200)).filter(Boolean) : undefined,Now:
// new getFormData (tool + resource)
tags: tags.length > 0 ? tags : undefined, // raw — no sanitizeStringNet effect: tool and resource submissions now carry tags with no control-char/CRLF stripping and no 200-char cap (TagInput only trims). usePromptForm and useCreateServerForm are unaffected — their getFormData calls schema.parse(...) and returns the parsed tags, so the transform genuinely applies there.
This is defense-in-depth rather than an open hole — the backend validators are still a backstop — but the PR notes say "Per-tag sanitize lengths and max-count limits are preserved," and that doesn't hold for tool/resource. Simplest fix is to sanitize where the payload is built, matching the other fields already handled there:
tags: tags.length > 0 ? tags.map((t) => sanitizeString(t, 200)) : undefined,in both useToolForm.getFormData and useResourceForm.getFormData. A payload-level assertion in the two hook test suites would have caught this and would guard against it regressing again.
Minor notes (optional)
- Focus after removing a chip at
maxTags:removeAtcallsinputRef.current?.focus()while the input is stilldisabledfor that render (atMaxis still true until the state update commits), so focus can be briefly dropped before it re-enables. Small edge case.
Everything else from the previous round looks good to me. Once the tag sanitization is restored on the tool/resource payloads, I'm happy to approve.
…ter chip removal at maxTags Signed-off-by: Ahmad Al Tamimi <altamimi.dev@gmail.com>
|
Thanks @marekdano, both spot-on — you're right that the schema transform was inert for these two hooks. Fixed in Tag sanitization on tool/resource payloads
const cleanTags = tags.map((t) => sanitizeString(t, 200)).filter(Boolean);
// ...
tags: cleanTags.length > 0 ? cleanTags : undefined,One deviation from your suggested snippet: I kept the
Payload-level testsAdded the assertions you asked for, in both result.current.setTags([" clean ", "evil\r\ninject", "x".repeat(250), "\x00\x07"]);
// ...
expect(payload.tool.tags).toEqual(["clean", "evilinject", "x".repeat(200)]);That covers CRLF stripping, the 200-char cap, and the all-control-chars drop. I verified both fail against the previous code. Also renamed Focus after removing a chip at
|
marekdano
left a comment
There was a problem hiding this comment.
@Altamimi-Dev — all three items from the last round are cleanly resolved, and CI is green.
LGTM 🚀
Pull Request
🔗 Related Issue
Closes #5640
📝 Summary
Replaces the comma-separated
<Input>used for tag entry in the tool, resource, prompt, and virtual-server forms with a chip-styleTagInput, and adds autocomplete against the existing/tagsendpoint. Built from repo primitives — no new dependency.client/src/components/ui/tag-input.tsx: chips (viaCardTag) + a text field. Commit onEnter/,/Tab/ blur;Backspaceon empty removes the last tag; each chip has aRemove <tag>button; paste splits on,\n\t; case-insensitive trimmed dedupe;maxTags,disabled,aria-invalid,aria-describedbyprops. Suggestions render in arole="listbox"with arrow-key navigation and aCreate "<value>"entry for unmatched input; the input is arole="combobox"witharia-activedescendant.client/src/hooks/useTagSuggestions.ts: fetches/tagsonce per session (module-cached) and returns the names for autocomplete. Best-effort — a failed fetch just yields no suggestions and never blocks tag entry.useToolForm,useResourceForm,usePromptForm,useCreateServerForm) fromtags: stringtotags: string[], dropping the inline.split(",")/.join(", ")and theparseTagshelper. Per-tag sanitize lengths and max-count limits are preserved; the zod schemas now validatez.array(z.string()).TagInputintoToolAdvancedSettings,ResourceForm,PromptForm, andCreateServerForm.📏 Reviewability
triage🏷️ Type of Change
🧪 Verification
Run from
client/:npx eslint <changed .ts/.tsx>npx prettier --check <changed files>npx vitest runNew
tag-input.test.tsx(11 tests) covers chip rendering, commit keys, backspace/×-button removal, paste-splitting, case-insensitive dedupe, suggestion filtering + selection, theCreateoption,maxTags, anddisabled. The four hook/form test suites were updated to thestring[]shape and to drive the chip input.📓 Notes
CardTag) and tag-management CRUD are out of scope, per the issue.tags: [](previouslynull) — both mean "no tags", and edit mode pre-fills existing tags, so a save that doesn't touch tags preserves them./tagsendpoint had no prior client consumer;useTagSuggestionsis the first, typed against theTagInfoname field.