feat(web): Agents Roster AI Assistant sidebar #1220
No reviewers
Labels
No labels
area:agents
area:dashboard
area:database
area:design
area:design-review
area:flows
area:infra
area:meta
area:security
area:sessions
area:webhook
area:workdir
security
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/agent-hooks!1220
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "dev/1213"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
/agents/rosterwith resizable width (~420px default), chat (textarea + Cmd/Ctrl+Enter send), loading and 4xx/5xx error UI with Retry (replays the last payload, no extra refinement).POST /api/roster/assistant/proposeviapostRosterAssistantProposeand@claude-hooks/sharedProposalEnvelope; showssoft_cap_exceededbanner and refinement counter from the envelope.localStorage; session id is cleared when the panel is closed (per issue).en.json/fr.jsonfor all UI copy; Vite dev proxy adds/api/roster.viimport that brokebiome checkon pre-push.Closes #1213
Test plan
bun x vitest runinapps/web(includes new sidebar tests + roster route tests)just i18n-string-check/just paraglide-checkReview — feat(web): Agents Roster AI Assistant sidebar
CI is green. The overall shape is solid: clean component decomposition, good localStorage persistence, proper session-ID lifecycle (cleared on close), i18n coverage in both locales, and a useful test suite. Two issues need fixing before merge; a handful of nits below.
🔴 Must fix
1. Uncontrolled
<select>with imperative DOM mutation (roster-assistant-sidebar.tsx)defaultValuemarks the element as uncontrolled. Manually resettinge.target.valuecircumvents React's reconciler and will misbehave in Concurrent Mode / Strict Mode (double-invoke renders can revert the DOM reset). Use a controlled state instead:2. Resize drag mixes pointer-capture with
mouse*window listeners (roster-assistant-sidebar.tsx)setPointerCaptureredirects pointer events (pointermove,pointerup) to the capturing element — butmousemove/mouseupare separate events and are unaffected by capture. On touch / stylus these mouse events may never fire, silently breaking the drag. Use pointer events throughout so capture does the work you intended:🟡 Should fix (nits)
3.
"Invalid proposal response"is not i18n'd (roster-assistant.ts, line 67)Every other error string in this file goes through
m.*()orlocalizedApiError. Add a Paraglide key (roster_assistant_invalid_responseor similar) to both locale files and use it here.4. Non-unique
data-testidin the message list (roster-assistant-sidebar.tsx)With multiple operator or assistant turns the testid is duplicated in the DOM. The tests happen to work because Vitest/Playwright grabs the first match, but this is fragile. Use
turn.idor at minimum a role+index combination:`roster-assistant-msg-${turn.role}-${turn.id}`.5.
lastProposalstate is derivable (roster-assistant-sidebar.tsx)lastProposalis always theproposalof the last assistant message inmessages. Keeping it as separate state risks the two drifting. Derive it:Drop the
setLastProposalcalls inrunProposeaccordingly.6. Panel landmark role for screen readers (
roster-assistant-sidebar.tsx)The open-panel
<div>hasaria-labelledby={headingId}via the inner<h3>, but without a landmark role the label has no structural effect for assistive technologies. Addrole="complementary"(orrole="region") directly on the panel container:ℹ️ Minor observations (no action required)
readStoredOpen()is called twice on mount: once for thepanelOpenlazy initializer and again inside thesessionIdlazy initializer. A single read suffices; capture it in a module-level or component-local variable.panelOpeneffect writes tolocalStorageon every mount (even when the stored value hasn't changed). This is harmless but auseRefdirty-flag would avoid the redundant write.Summary
Two blockers (uncontrolled select, mixed pointer/mouse events) and four quick nits. Happy to re-review once those are addressed — everything else here is well-considered.
17be5c410afec668e56cCode Review — feat(web): Agents Roster AI Assistant sidebar
CI ✅ · Mergeable ✅
Good implementation overall. The multi-turn history builder, localStorage persistence, retry-replay pattern, and i18n coverage are all solid. A few things to address (one is a real bug, the rest are polish):
🐛 Bug — in-flight fetch bleeds into the next session
roster-assistant-sidebar.tsx,useEffect([panelOpen, sessionId])When the panel is closed while a request is in-flight the close-effect resets
messages → []andsessionId → null, but does not callsetLoading(false). The in-flightrunProposeclosure continues and on resolution calls:…against the now-reset component. If the user re-opens the panel before the request resolves they'll see
loading = truewith a disabled textarea; when the request finally settles a stale assistant message from the closed session appears in what is meant to be a fresh conversation.Fix: tie an
AbortControllerto the session ID so the fetch is cancelled when the session is cleared, or at minimum compare the captured session ID against the current ref at resolution time and discard the result when they differ. Example:And in the close branch of the effect:
Please track this in a follow-up if you don't want to block the merge; the BE route isn't live yet so it's latent.
⚠️ Accessibility —
aria-controlspoints to a non-existent node when collapsedroster-assistant-sidebar.tsx— collapsed branch<Button aria-controls={headingId} />headingIdis theidof the<h3>that only exists in the expanded panel branch. When the panel is collapsed that element is not in the DOM. WAI-ARIA requiresaria-controlsto reference a currently-present element. Screen readers will silently ignore the broken reference.Options:
id, and pointaria-controlsthere.aria-controlsfrom the collapsed button —aria-expanded={false}already communicates the collapsed state.⚠️ Resize handle —
setPointerCapturedoesn't benefit themousemovelistenerroster-assistant-sidebar.tsx—onResizePointerDown+ resizeuseEffectonPointerDowncallse.currentTarget.setPointerCapture(e.pointerId)which routes subsequent pointer events to the button, but the drag-trackinguseEffectregisterswindow.addEventListener('mousemove')— a different event system. The capture has no effect on themousemovepath, so the line is dead weight, and the resize won't work on touch/stylus devices (nomousemovefires there).Either drop the
setPointerCapturecall and keep mouse events, or (better) switch theuseEffecttopointermove/pointerupto match the pointer capture model and gain touch support:Minor nits
roster-assistant.ts— unlocalized error stringm.roster_assistant_request_failed()already exists and fits here. This is the only raw error string in the file.roster-assistant-sidebar.tsx— template<select>mixes uncontrolleddefaultValuewithe.target.value = ""Direct DOM mutation (
e.target.value = "") works in practice but is fragile — React's reconciler can undo it on the next render. A controlled select (value={templateValue}+setTemplateValue("")inonChange) is safer and more idiomatic.roster-assistant-sidebar.test.tsx— silent Paraglide key expectationPasses because Paraglide returns the message key in the test environment, but there's no comment explaining that. Future readers will be confused. One line:
Summary
The in-flight session bleed is the only real correctness issue; the rest are accessibility, consistency, and polish. CI is green, the BE route is not live yet (404 until #1219 merges), and the test coverage is good. Happy to merge as-is with a follow-up issue for the abort/session guard if you'd like to keep the train moving.