Skip to content

feat(agent): add Tool, ToolApproval, ToolGroup, Reasoning and Activity - #120

Open
pratikbuilds wants to merge 10 commits into
conversation/13-panel-tabsfrom
conversation/03-agent-steps
Open

pratikbuilds wants to merge 10 commits into
conversation/13-panel-tabsfrom
conversation/03-agent-steps

Conversation

@pratikbuilds

@pratikbuilds pratikbuilds commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

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

  • Displayed status comes from one internal helper (src/lib/tool-display.ts) so the header word, failed counts and open-on-error agree with the approval record.
  • The axe sweep gains a measured contrast fallback for translucent backgrounds (pure maths in e2e/contrast.ts). Its failures use their own keys (color-contrast-measured / -unmeasured); large text uses a 3:1 floor; unmeasurable nodes are reported, not skipped.
  • e2e/stories-a11y.known.json gains 18 entries (9 stories in light and dark), all for pre-existing stories the new check now measures (sidebar badge 4.38:1 on stacked washes; unmeasurable text in prompt-input and avatar stories). Clearing them is tracked in Clear the 18 known contrast entries added by the measured axe check #138.
  • The pending-approval story passes contrast on --attention-wash in both themes.
  • A decision that folds the call into an earlier run unmounts ToolApproval; the message renderer should move focus to that run's ActivityTrigger. Pass onDecide to ToolGroup for batches.
  • Activity shares its name with React 19.2's Activity; consumers importing both alias one.
  • test:e2e now also runs e2e/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.
  • ToolGroup's header counts calls awaiting approval as waiting ("2 at once · 1 done · 1 waiting for approval"); Reasoning's trigger is named by its summary only.

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

  • This commit passes bun run typecheck and bun run lint on its own (the stack is checked commit by commit).
  • At the top of the stack: bun run check, bun run build, bun run dep-guard, bun run contrast-test and bun run test:e2e (Playwright specs and the Storybook axe sweep in light and dark) pass.

Closes #97

@corbits-triage

corbits-triage Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Superseded by the comment below.

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

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 pending ToolGroup collapses it instantly: the outcome row the person just acted on never renders ("Approved once" absent from the DOM) and keyboard focus drops to document.body, while the standalone ToolApproval path 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.ts never asserts focus state after a decision (the recovery behavior has no browser pin).
  • note — the contrast waivers in e2e/stories-a11y.known.json and 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.

@corbits-triage

corbits-triage Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Triage: Fail

Asking a maintainer about:

  • pull request is over the lines limit: 5066 lines changed (max 3000)

Stacked on #119: only this pull request's own changes were checked.

Notes:

  • added code matches a code pattern: e2e/contrast-dom.ts:358 Invented fallback `?? 0`; handle the missing value instead., e2e/contrast-dom.ts:257 Invented fallback `?? []`; handle the missing value instead., e2e/contrast-dom.ts:368 Invented fallback `?? []`; handle the missing value instead., e2e/contrast-dom.ts:204 Immediately invoked function; name the function., e2e/contrast.ts:238 Invented fallback `?? []`; handle the missing value instead., e2e/fixtures.tsx:974 Nested ternary; use if statements or a lookup., e2e/fixtures.tsx:986 Nested ternary; use if statements or a lookup., src/ui/activity.tsx:141 Nested ternary; use if statements or a lookup., src/ui/tool-approval.tsx:171 Nested ternary; use if statements or a lookup., src/ui/tool-group.tsx:231 Nested ternary; use if statements or a lookup., stories/chat/activity.stories.tsx:39 Nested ternary; use if statements or a lookup., stories/chat/activity.stories.tsx:167 Nested ternary; use if statements or a lookup., stories/chat/activity.stories.tsx:179 Nested ternary; use if statements or a lookup., stories/chat/tool.stories.tsx:63 Nested ternary; use if statements or a lookup., stories/chat/tool.stories.tsx:271 Nested ternary; use if statements or a lookup.
  • added code matches a code pattern: e2e/contrast-dom.ts:101 Invented fallback (an empty string, || [] or || 0); handle the missing value instead.
  • Risk: medium (5066 lines)

Changed since the last verdict (Unsure at 377f1a8): 56 new commits.

@pratikbuilds

pratikbuilds commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Here's what happened to each point.

Fixed

1. Deciding inside a ToolGroup collapsed it, hid the outcome and dropped focus (blocking)

  • The fix: once a waiting call has opened the group, it stays open until a person closes it, so the outcome you just chose stays visible. After a decision, focus moves to the next waiting call's first button. If none is left, it moves to the decided call's outcome line, and after that to the group header.
  • Tests: six unit tests, and a Playwright case checking focus is never on <body> between frames.

2. A Tool that failed and then succeeded on retry collapsed and dropped focus (minor)

  • The fix: a Tool that opened because of an error stays open through the successful retry, so focus inside it is kept.

3. The focus target after a decision had no name (minor)

  • The fix: the settled line is now a group named by its own text, with a space added, so it reads "Approved once mail__send" instead of "Approved oncemail__send".

4. The e2e tests never checked focus after a decision (test gap)

  • The fix: covered by the Playwright case in point 1.

Not changed, and why

  • Reasoning steps losing their open/closed state when "Show earlier steps" is clicked: this doesn't happen, because the key already uses the step's absolute position. Revealing earlier steps doesn't change the keys of the steps already shown, and a test opens the last step, reveals earlier ones, and it stays open. The "Show earlier" button is also already inside an <li>.
  • The sidebar badge contrast (4.38:1): I'll open a tracking issue so it has an owner.

Commit: fix(agent): keep focus and the outcome when a group's call is decided

@pratikbuilds
pratikbuilds force-pushed the conversation/03-agent-steps branch from fb535ca to 377f1a8 Compare October 27, 2026 13:54

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

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. Button does not default type (button.tsx:74-80), and these three do not set type="button". The HTML default is submit. ToolHeader and 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, so onDecide({ outcome: "approved", scope: "once" }) runs. aria-disabled is not the disabled attribute, so a held button is still a successful submitter. Set type="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 calls onDecide for 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 TheGreatAxios 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.

Review · Request changes

Code review, in addition to the approval-button notes already on this PR.

Findings

  • e2e/contrast-dom.ts:111 — An opaque background-color is measured as the ground before an inset shadow is considered. paintOf returns "solid" when the background alpha is 1, and the layer walk (:385) never records box-shadow. Text of #333 on background:#fff; box-shadow:inset 0 0 0 80px #000 is 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. Activity keeps streaming on context, but defaultStep mounts <Reasoning part={step} />, and Reasoning defaults streaming to 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). Pass streaming through.

  • 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, and document.activeElement is body. Restore focus whenever the focused card disconnects, not only when waiting decreases.

@pratikbuilds

Copy link
Copy Markdown
Collaborator Author

Thanks for both reviews. All five points are fixed.

1. Approve, Approve always and Deny were submit buttons

  • The fix: all three are now type="button". Inside a form, Enter in a text field no longer approves a call.
  • Test: "inside a form, the buttons decide without submitting it".

2. Holding Enter could approve the next call in a group

  • The fix: after a decision, focus moves to the next waiting call's card, not its Approve button. The card is named by its question and tool (for example "Needs your approval calendar__invite") and does nothing on Enter, so a key repeat can't decide a call nobody chose.
  • Tests: "with another call waiting, focus moves to that call's card, not a button", plus a Playwright scenario that holds Enter across the decision.

3. The contrast check passed text under an inset shadow

  • The fix: an inset box-shadow now stops a box from counting as a solid ground and adds an unresolved layer, the same way a background image already does. Your example, #333 on white under inset 0 0 0 80px #000, used to pass at 12.6:1 and is now reported as unmeasured.
  • Test: "the contrast fallback does not pass text on an opaque ground under an inset shadow".

4. A streaming run showed its reasoning step as finished

  • The fix: while the run streams, Activity passes streaming to its newest reasoning step, so it reads "Thinking…" like the run, and a thought with no text yet is shown. Earlier thoughts stay finished.
  • Test: "while streaming, only the newest thought is still thinking".

5. Focus fell to the page when a decision landed as another call started waiting

  • The fix: the group now recovers focus after any update in which focus inside it went to the page, not only when fewer calls wait. It also remembers the focused call by its id. If the host removes that call, focus goes to the call that came after it, even when other calls shift around it.
  • Tests: "a decision that lands as another call starts waiting keeps focus", "a focused call dropped from the middle hands focus to the one after it" and "a dropped call is placed by the call after it, though calls shift".

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: fix(agent): keep a stray Enter from deciding an approval, fix(agent): recover a group's focus whenever the focused call goes away, fix(agent): show a live run's newest thought as still thinking, fix(e2e): leave text under an inset shadow unmeasured

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.
@pratikbuilds
pratikbuilds force-pushed the conversation/03-agent-steps branch from 4b97c77 to ff769aa Compare October 28, 2026 14:47
@pratikbuilds

Copy link
Copy Markdown
Collaborator Author

Round 3 fixes

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

  • f227ed8: When a decided call left no outcome line (a host that only changes status, or drops the call), focus fell back to the group's header button, so a held Enter folded the group. Rows are now focus targets, and focus goes to a row, never a button, unless no row is left.
  • 837957b: A finished thought with blank text left an empty, focusable step that counted toward the ten shown. It is now left out; keys and renderStep indexes are unchanged.
  • fb3f3e6: The BatchWaiting story showed a denied call as "Done"; it now uses output-denied.
  • ff769aa: React 18 refs: components that read ref from props only receive it on React 19, while the peer range allows ^18.2.0. They are now wrapped in forwardRef, so a host ref reaches the element on both; the peer range is unchanged. Covers ToolApproval and ToolGroup.

bun run check and dep-guard pass on every branch of the stack, and the full e2e suite passes at the top of the stack (163/163, including the stories axe sweep). Each behaviour fix comes with a test that fails without it; the forwardRef tests can only guard, since the suite runs on React 19.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants