Repository navigation
feat(agent): add Tool, ToolApproval, ToolGroup, Reasoning and Activity - #120
pratikbuilds wants to merge 10 commits into
Conversation
|
Superseded by the comment below. |
d2fa2bd to
993eb35
Compare
993eb35 to
8fc758b
Compare
TheGreatAxios
left a comment
There was a problem hiding this comment.
Automated multi-agent review: independent review + second-agent validation; all items below confirmed, the first two by executed repro tests.
- blocking (repro-verified)
src/ui/tool-group.tsx:87— deciding inside a pendingToolGroupcollapses it instantly: the outcome row the person just acted on never renders ("Approved once" absent from the DOM) and keyboard focus drops todocument.body, while the standaloneToolApprovalpath recovers focus to the resolved record deliberately. Nothing in the PR body covers this group-level path. Smallest fix: mirror the standalone recovery (render/id-block the resolved record row before collapsing, focus it) so the decision is visible and focus lands somewhere announced. - minor (repro-verified)
src/ui/tool.tsx:83— an error→success host transition collapses the auto-opened output panel and drops focus to<body>. Same recovery pattern. - minor
src/ui/tool-approval.tsx:116-122, 249— the documented focus-recovery target is an unnamed<div tabindex="-1">; screen readers hear nothing about where focus went. Name or label it. - minor
src/ui/activity.tsx:235-244, 307-314—reasoning-${index}keys reset fold state when "Show earlier steps" shifts the list; key on part id instead. A focusable control also sits directly inside an<ol>as its immediate child. - test gap —
e2e/tool-approval.spec.tsnever asserts focus state after a decision (the recovery behavior has no browser pin). - note — the contrast waivers in
e2e/stories-a11y.known.jsonand the shared-file edits are disclosed in the PR body; consider landing the sidebar-badge follow-up (4.38:1) as a tracked issue so the disclosure has an owner.
|
Triage: Fail Asking a maintainer about:
Stacked on #119: only this pull request's own changes were checked. Notes:
Changed since the last verdict (Unsure at |
8fc758b to
fb535ca
Compare
|
Thanks for the review. Here's what happened to each point. Fixed1. Deciding inside a
2. A
3. The focus target after a decision had no name (minor)
4. The e2e tests never checked focus after a decision (test gap)
Not changed, and why
Commit: |
fb535ca to
377f1a8
Compare
TheGreatAxios
left a comment
There was a problem hiding this comment.
Review · Request changes
Warden, on tool approval. Tool text is plain text, not HTML. One control can approve a call the user did not choose.
Findings
-
src/ui/tool-approval.tsx:207— Approve, Approve always, and Deny are submit buttons.Buttondoes not defaulttype(button.tsx:74-80), and these three do not settype="button". The HTML default issubmit.ToolHeaderand the group header in this same diff do set it. Inside a form, Enter in a text field activates the first submit button in tree order, which is Approve, soonDecide({ outcome: "approved", scope: "once" })runs.aria-disabledis not thedisabledattribute, so a held button is still a successful submitter. Settype="button"on all three. -
src/ui/tool-group.tsx:253— Holding Enter can approve the next call. When a decision drops the waiting count, the layout effect focuses the next pending card's first button, which is Approve (tool-group.tsx:125-138). A key repeat is delivered to that button and callsonDecidefor a different tool. The same-card double click is documented as the host's problem; this one is a decision the user did not take. Move focus to the card, not the Approve button, or ignore the key repeat that lands on the newly focused control.
TheGreatAxios
left a comment
There was a problem hiding this comment.
Review · Request changes
Code review, in addition to the approval-button notes already on this PR.
Findings
-
e2e/contrast-dom.ts:111— An opaquebackground-coloris measured as the ground before an inset shadow is considered.paintOfreturns"solid"when the background alpha is 1, and the layer walk (:385) never recordsbox-shadow. Text of#333onbackground:#fff; box-shadow:inset 0 0 0 80px #000is judged against white and passes; the paint under the glyphs is black. A gradient on the same element stays unmeasured. Treat an inset shadow as unresolved, the way a gradient already is, so it cannot pass. -
src/ui/activity.tsx:228— A streaming run renders its reasoning step as finished.Activitykeepsstreamingon context, butdefaultStepmounts<Reasoning part={step} />, andReasoningdefaultsstreamingto false (reasoning.tsx:50). The run trigger says "Thinking…" while the step says "Thought for 2s". An empty live thought is omitted (reasoning.tsx:67). Passstreamingthrough. -
src/ui/tool-group.tsx:126— Focus recovery runs only when the waiting count falls. Settling the focused call in the same update that another call starts waiting leaves the count unchanged, the decided button unmounts, anddocument.activeElementisbody. Restore focus whenever the focused card disconnects, not only whenwaitingdecreases.
377f1a8 to
5fc8778
Compare
fe3edff to
4b97c77
Compare
|
Thanks for both reviews. All five points are fixed. 1. Approve, Approve always and Deny were submit buttons
2. Holding Enter could approve the next call in a group
3. The contrast check passed text under an inset shadow
4. A streaming run showed its reasoning step as finished
5. Focus fell to the page when a decision landed as another call started waiting
Also: the PR description said the known-violations file gains 34 entries. The real count is 18, and the description now says so. Clearing them is tracked in #138. Commits: |
Tool renders every ToolTraceStatus with an icon and a word, and its input and output come through a render prop. ToolApproval asks a person to approve once, approve always or deny, and renders a settled decision from the approval record itself. ToolGroup shows calls that ran at the same time, Reasoning shows thinking with its duration, and Activity folds both into one summary line showing the latest ten steps. The stories axe sweep now measures text contrast itself wherever axe cannot: it hit-tests each text box to find the ground actually under it, composites colour and opacity layers, reads gradient and fill colours, and reports anything it cannot resolve as unmeasured rather than passing it. Browser fixtures pin the cases that must never pass.
A standalone ToolGroup was open only while a call waited, so deciding the last one unmounted the list with the focused button: the outcome never showed and focus fell to the page. With calls still waiting, the decided call re-rendered as a Tool whose fresh ToolApproval never ran its own recovery, so focus fell to the page there too. The group now stays open once a waiting call opened it, until a person closes it, and after a decision moves focus to the next waiting call's first button, else the decided call's outcome, else its own row, the same order the message renderer uses. A Tool opened by an error stays open through a retry that succeeds, so focus in its output is kept. The settled approval line, where focus lands, is now a group named by its text, with a space so it reads "Approved once mail__send" rather than running the outcome into the tool name. An e2e scenario checks a standalone group never leaves focus on the page between frames.
Approve, Approve always and Deny did not set a type, so inside a form
they were submit buttons: Enter in a text field activated Approve, and
aria-disabled did not stop it. They are now type="button".
After a decision in a group, focus moved to the next waiting call's
Approve button, so the repeat of a held Enter approved a call the
person never chose. Focus now moves to that call's card, which is named
by the question and the tool ("Needs your approval calendar__invite")
and does nothing on Enter. The e2e scenario holds Enter across the
decision to pin it.
ToolGroup moved focus only when fewer calls waited after an update. A decision that landed as another call started waiting left the count unchanged, so the decided button unmounted and focus fell to the page. The group now recovers after any update in which focus that was inside it went to the page. A decided call the host removed from the batch was no longer in the list, so the search for the next waiting call started from the top and focused the first card. The group now remembers the focused call by its id and, if the host drops it, starts at the call that followed it in the last commit, which holds even when other calls shift around it.
Activity kept `streaming` on its context but rendered every reasoning step with Reasoning's default, finished. While the run's line said "Thinking…", the step said "Thought for 2s", and a thought with no text yet was left out. The default step renderer now passes `streaming` to the newest step while the run streams; earlier thoughts stay finished.
The contrast fallback took an opaque background colour as the ground before looking at an inset box-shadow, and its layer walk never recorded one. #333 text on white under `inset 0 0 0 80px #000` was judged against white and passed at 12.6:1, though black is what is under the glyphs. An inset shadow now keeps a box from being a solid ground and adds an unresolved layer, as a background image already does.
When a decided call had no outcome line to focus, because the host kept no approval record and only changed the call's status, or dropped the call with nothing else waiting, ToolGroup moved focus to its header button. The next repeat of a held Enter clicked it and folded the group, taking away the list the person had just acted in. The group now falls back to the decided call's row in the list, or, if the host dropped it, the row now in its place. Rows take focus from script only and are not buttons, so a repeated Enter there does nothing. The header remains the target only when no row is left.
Reasoning renders nothing for finished thinking with blank text, but ActivityContent still wrapped it in its own focusable list item. An opened run showed an empty item that screen readers counted, that took one of the ten places before "Show earlier steps", and that received focus from that control while reading nothing. ActivityContent now leaves such a step out before it counts and lists the steps. Each shown step keeps its index in the run, so keys and the index passed to renderStep are unchanged.
The story set the call to output-available after either decision, so after Deny the row showed a check and "Done" above a "Denied" line. It now sets output-denied for a rejection, as a host should.
Both components read `ref` from their props. React 18 strips `ref` from the props of a plain function component, so a host's ref never reached the root there, though the peer range accepts React 18. Both are now wrapped in forwardRef, which passes the ref on React 18 and 19 alike, and still merge it with the root ref they use for focus recovery.
4b97c77 to
ff769aa
Compare
Round 3 fixesA fresh review pass over the stack turned up the points below; each is fixed in a new commit at the end of this branch, and the stack is rebased onto it.
|
Tool renders every ToolTraceStatus with an icon and a word, and its input and output come through a render prop. ToolApproval asks a person to approve once, approve always or deny, and renders a settled decision from the approval record itself. ToolGroup shows calls that ran at the same time, Reasoning shows thinking with its duration, and Activity folds both into one summary line showing the latest ten steps.
The stories axe sweep now measures text contrast itself wherever axe cannot: it hit-tests each text box to find the ground actually under it, composites colour and opacity layers, reads gradient and fill colours, and reports anything it cannot resolve as unmeasured rather than passing it. Browser fixtures pin the cases that must never pass.
Stacked on #119: review only this commit. Part of #94.
Notes for review
Activityshares its name with React 19.2's Activity; consumers importing both alias one.test:e2enow also runse2e/contrast.test.ts(the contrast maths), and CONTRIBUTING.md says so. The fallback composites colour and opacity layers from the text to the root; filters, blend modes and unresolved paint mark a sample unmeasured.Size
Over the 3,000-line triage limit (4,325 lines in 23 files). About 1,360 lines are the components; the rest is unit tests (about 1,425), stories (about 490) and the browser checks (about 1,050), including the measured contrast fallback for the Storybook axe sweep.
Verification
bun run typecheckandbun run linton its own (the stack is checked commit by commit).bun run check,bun run build,bun run dep-guard,bun run contrast-testandbun run test:e2e(Playwright specs and the Storybook axe sweep in light and dark) pass.Closes #97