fix(buddy): edit the flag-to-PM question before confirming (#235) - #272
daniilperkin wants to merge 6 commits into
Conversation
A flag to the PM leaves the product in the hire's name, and the buddy composes the question — but the confirm showed only the button's label plus a read-only line under it, so the words that actually land in somebody's inbox could not be checked, let alone corrected, before they went out. The card now shows the composed question in an editable field above the confirm, and sends exactly what that field holds: handleConfirm is the single send path (the offer's button and a refusal's retry both come through it), it trims the text, and it refuses to send nothing — a blank flag is not a question, and the backend's blank-question refusal is a card with no field left to fix it in. Every action is now its own BuddyProposalCard rather than an inline branch of the map, because the draft is per-action state and it has to survive the switch from the offer to its refusal: the retry is precisely where the hire's own wording matters again. The field is frozen while the confirm is in flight, so what was sent is what is still on screen when the outcome lands. Field and control are ui/Field + ui/Textarea, not the legacy AutoResizeTextarea: the primitives already carry the label binding, the hint's aria-describedby and the disabled treatment, and ui/Textarea is what the standards ask for at every text field (the buddy composer is the documented composite exception; this is not one). The skip request's reason stays a static line under the buttons — it is a sentence to acknowledge, not one to compose — and the question field's doc in types.ts now says where it is shown. Tests: the static-preview assertion became a nine-test suite over the flag card (editable prefill, edited text sent, untouched text sent, trimming, empty and whitespace guards, freeze while confirming, decline, retry carrying the edited text). Red-checked: with the component reverted to the previous commit, seven of them fail.
…e session (#235) A refused flag used to collapse to an outcome line with a "Try again" underneath: the field was unmounted, so the hire could not read — let alone correct — the text that would go out a second time, and re-pressing swapped the whole card back in. The refusal now keeps the offer: reason above, field and buttons exactly as they were, so the only thing a retry can send is the wording on screen. The card's own button is that retry. The separate "Try again" went with the branch that needed it — the hook already let a refused hire offer be confirmed again — and it cannot be pressed into a no-op: with the field empty the confirm is disabled, which is what the old button had no way of saying. The reason stays put while the retry is in flight; the line above the card is replaced only by the next outcome. The wording moves out of the card into the session (`actionDrafts`, keyed by message and action). A card's own state dies with it, and the card is unmounted by things that have nothing to do with the text: the dock closes, the conversation is handed over to /buddy, a route changes. The composer's draft already lives in the session for exactly that reason (useBuddyConversation), so the field follows the same rule now. The session lets go of a draft once the flag actually went out, or once the hire declined it — a card re-rendered later must not offer wording that already left the product. A refusal keeps it: that card is about to be handed the hire's own text back. Corrected from the review: the unmount, the layout swap on retry, the draft lost across surfaces, the docstring that promised an offer coming back, the a11y test whose title promised an edit it never made, and the retry button with no disabled state. Overstated there: the "inert Try again" was unreachable (an empty field could not be confirmed, so no refusal could carry an empty draft), and the PR body's ticked "Manually verified" box was unchecked when the PR was created — the claim is right about the body as it stood when the review ran, and it is unchecked again. Their fix for the fourth finding (patch the question into the action) was not taken: `ActionPatch` is documented as the round-trip, never the offer itself, and the session draft answers the same need inside that invariant. Tests: the flag card keeps its field, its text and a usable confirm under a refusal; the reason survives a retry in flight; a refusal whose field was emptied cannot be confirmed; the session carries the wording under `message:action`; the hook keeps a refusal's draft and drops a sent one. Red-checked: reverting the two behaviours fails four of them.
Why this PR shows "This stack has conflicts that must be resolved"That banner is about the layer below, not about this branch — #272 only shows it because a stack can't merge while any layer can't.
What we're doing about itWaiting for David to resolve #267 on his own branch. The conflict is his code against Please don't press Rebase stack on this stack in the meantime: it rewrites and force-pushes every branch in the stack (this one included) to replay #267's 23 commits onto For whoever does it, the cheap path — dry-ran locally end to end, verified:
Dry-run result: After #267 merges
Do not squash-merge #267: squashing rewrites the SHAs the layers above are based on, which breaks the stack and would make this PR show #267's changes as its own. |
kiranfin
left a comment
There was a problem hiding this comment.
Review
I did not find anything that should block landing this on top of #267; the two points below are worth a look, not blockers.
🟡 Worth a look
-
actionDraftsis never cleared when the conversation is reset —src/features/buddy/hooks/useBuddyConversation.ts:437-459(startFreshVisit) and:863-895(the team-mode switch effect)
Both places resetmessages,openerAction,openError,draftand more, but notactionDrafts. Keys aremessageId:actionIdfrom freshly generated UUIDs, so a leftover entry can never collide with a later card — this isn't a correctness bug — but every flag the hire ever edits across a long-lived tab stays in memory forever, since the buddy session never unmounts. Given how deliberately this file resets every other piece of session state on these two transitions, clearingactionDraftsalongside them would be the consistent thing to do. -
An empty field disables the confirm silently, with no visible reason and no
aria-describedby—src/features/buddy/components/BuddyActionProposals.tsx:309-323
ui/Fieldhas anerrorprop built exactly for this ("its presence is the error state: it colors the border, setsaria-invalid, and is announced," per its own doc comment), but the card rendersFieldhere without one. A keyboard or screen-reader user who clears the field and tabs to the disabled "Flag this to your PM" button gets nothing telling them why it won't activate. Passing something likeerror="Write a question before sending."wheneditedQuestion.trim()is empty would close that gap and match how the rest of the app treats invalid fields.
🟢 Nits / cleanup
handleConfirmsendseditedQuestion.trim()but never writes the trimmed value back intoactionDrafts(BuddyActionProposals.tsx:120-132), so a refused retry's field keeps whatever leading/trailing whitespace the hire originally typed. Harmless — invisible in a textarea — but slightly at odds with "what is confirmed is what the field held."- The new test "keeps the hire's wording for a refused offer and lets go of it once it went through" (
tests/unit/features/buddy/useBuddy.test.tsx:480-493) actually renders two independent hook instances via two separateconfirmOncecalls, rather than following one offer from refusal through to acceptance in one session. It proves both halves of the claim, just not as the one continuous story the name implies.
Overall this is a tight, well-tested PR. Nothing here needs to hold up the stack.
…ts wording (#235) Review round on #272: two "worth a look" points and two nits, all four confirmed against the code before anything moved. - `actionDrafts` is now cleared wherever the conversation is reset: `startFreshVisit` and the team-mode switch effect reset messages, opener, error and composer draft but kept every wording the hire had ever typed into a flag. Not a correctness bug -- the keys are per-message UUIDs, so nothing could collide -- but a long-lived tab accumulated them forever. The file resets every other piece of session state on both transitions; the drafts belonged to the offers going away, so they go with them (they are not composer drafts). - The empty field is an announced error instead of a silent dead button: it passes `Field`'s `error`, which colours the border, sets `aria-invalid` and is read out. A hire who clears the field and tabs to the disabled confirm now gets the reason, from a message that appears in response to their own clearing. - What was sent is what the field holds: `handleConfirm` wrote the trimmed question back, so a retry after a refusal hands back exactly the text the PM would have read, not the padded typing. - The draft-lifecycle test is one continuous story now (refused, kept, retried, sent in one session) instead of two independent hook instances; a second test covers the fresh visit, a third the switch, and the a11y suite covers the emptied field.
…y-escalation-preview-edit (#235) David's branch now carries `origin/dev` (126 commits: the easter-egg overhaul, the knowledge-base work, and two buddy refactors this PR has to meet — the memoised thread and rows (#236) and the dismiss-by-action API), so this backmerge is where #235's wiring meets them. Conflicts (8 files), and how each was settled: - `BuddyActionProposals.tsx` — their side's only change was the new `onDismiss(messageId, action)` contract; the card itself is ours, so the file is ours plus that contract (the prop type and both call sites). - `BuddyThread.tsx`, `BuddyConversation.tsx`, `BuddyPage.tsx` — taken from theirs, our wiring re-applied: the drafts props now thread through `BuddyThreadProps` *and* the new memoised `BuddyThreadRowProps`, and a row receives the session's drafts only when it actually carries a proposal (`EMPTY_ACTION_DRAFTS`), so a keystroke in a flag's field still cannot re-render every memoised row — #236's whole point. - `useBuddyConversation.ts` — same route, and both reset behaviours are kept: their `draftResetToken` for the composer's box and our `setActionDrafts({})` for the flag wording, on the fresh visit and on the team switch. Our clearing on send/decline rides their new `dismissAction(messageId, action)` signature. - `tests/unit/features/buddy/useHandedOffDraft.test.tsx` — deleted upstream (the handed-off draft moved into `BuddyDraftProvider`); the deletion is accepted and our stub additions to it are gone with it. - The remaining test files that build props by hand picked up the two new required props (`buddyDraftAcrossRoutes`, `BuddyThreadFooter`, `BuddyThreadStreaming`, plus our own suites), and the decline assertion now expects the action object their API passes. Gate on the merged tree — `npm run try` green on Node 22: format:check, build (`tsc -b` included), lint; unit 341 files / 3273 tests; a11y 56 files / 76 tests. Buddy + a11y alone: 83 files / 270 tests, with #235's own suites (proposals, draft lifecycle, both reset paths, the emptied-field a11y case) passing against the merged wiring.
…y-escalation-preview-edit (#235) David's second backmerge. `311` now carries dev's app-wide keyboard-shortcut work (#276, #268, and the focus/shortcut fixes that followed) plus the sidebar/modal touches that came with them — a **clean merge, no conflicts**: none of those commits touch the buddy files #235 reworks, so our wiring, the card's own dismissal call sites and the reset-site clears all stay as they were after e0d1d31. Contents: 26 files, +1,867/−83, dominated by the new `features/shortcuts/` registry, its modal and their tests. Nothing of ours was rewritten for this one — the resolution work of the previous backmerge (their files + our wiring, the memo-safe row props, both reset behaviours) already meets this base. Gate on the merged tree — `npm run try` green on Node 22: format:check, build (`tsc -b` included), lint; unit 344 files / 3321 tests; a11y 57 files / 78 tests. Buddy + a11y + the incoming shortcuts suite alone: 87 files / 307 tests, so #235's own suites still pass against their memoised rows and the dismissal API.
…quired (#235) Follow-up on the deep review of #272 — its findings 1, 2, 3, 4 and 6, each re-checked against the code before anything moved: - Finding 2 (real, and ours): a retry that failed on the wire kept the old refusal above the card *and* added "Couldn't reach the server — try again." below it — two stories about one press. The outcome line now stands down for `status: "error"`, and only there: it still stays while a retry is in flight, which is the reason it is kept above the card at all. - Finding 1 (real mixed signal, inherited from #311): the refusal wore a grey checkmark. It now draws `AlertCircle` in the warning tone — the shape says "this did not go through", not the colour. - Finding 4 (`required`): the flag's question *is* mandatory — an empty field cannot be sent — so `Field` now gets `required`: the asterisk plus `aria-required` on the textarea (dev's reworked `Field` propagates it through context; no native validation attribute is set). - Finding 3 (a claim, not behaviour): the claim that the error appears "in response to the hire's own clearing, not on a field they have not touched" is true on every path the backend produces — it refuses to compose a flag with no question — but overstated for a proposal that arrived without one. The wording is corrected; the behaviour stays, because a disabled confirm still has to say why. - Finding 6 (test hygiene, inherited): `BuddyDock.test.tsx`'s `assistant()` hardcoded `id: "a1"`, so the one test rendering two assistants emitted duplicate React keys. Both helpers take an optional id now and that call site passes its own. Deliberately not touched: Finding 5 — drafts living in the session mean a keystroke in a flag's field re-renders the session's subscribers; that is the known trade-off already mitigated with `EMPTY_ACTION_DRAFTS` in the thread (memoised rows keep their identity), and a provider of their own — the `BuddyDraftProvider` pattern — is how it would be lifted entirely if flag editing ever grows. Finding 7 is a merge-time step, not a change. Tests: three assertions were red-checked against the unfixed source and fail there (the stale refusal, the refusal's mark, `aria-required`); one new test covers the transport-error path.
Show — and edit — the exact message before a Buddy escalation is confirmed
Closes #235 · Stacked on #267 (
feature/311-buddy-onboarding-tutor) — this diff is the delta on top of that branch.What this changes
flag_to_pmgains the one thing the issue asked for: the composed question is shown in an editable field above the confirm, and the confirm sends exactly what that field holds.BuddyActionProposalsbecomes a thin list; each action is its ownBuddyProposalCard, so an offer has an identity of its own across every status.handleConfirmis the single send path — the card's confirm button, and the same button again after a refusal — sending{ ...action, question: editedQuestion.trim() }.actionDrafts,src/features/buddy/actionDrafts.ts), not in the card: closing the dock, handing the conversation over to/buddy, or any re-render keeps it. It is let go of once the flag actually went out — or once the hire declined it — because a card re-rendered later must not offer wording that already left the product.ui/Field+ui/Textarea, not the legacyAutoResizeTextarea: the shared control already carries the label binding, the hint'saria-describedbyand the disabled treatment.useBuddyConversation: it already sendsaction.question, so an edited action flows through untouched.After review round 1 (2026-09-28)
A refused hire offer now keeps its card — reason above it, field and buttons intact — instead of collapsing to a compact outcome line with a separate "Try again" button underneath:
Acceptance criteria (#235)
flag_to_pmproposal displays the complete proposed question.useBuddyConversation.ts, unchanged).BuddyThread→BuddyActionProposals, and because the wording is held by the session, switching between them (or closing the dock) keeps it.Verification
npm run try(install → format:check → build → lint → unit → a11y) on Node 22: green — unit 302 files / 2929 tests passing, a11y 52 files / 65 tests passing, build + lint + format clean.tests/unit/features/buddy/BuddyActionProposals.test.tsx: 42 tests, 9 of them the flag card;tests/unit/features/buddy/useBuddy.test.tsxpins the session draft (kept on a refusal, dropped once sent);tests/unit/a11y/BuddyActionProposals.a11y.test.tsxcovers the editable field and the refused card.retries a refusal with the text the hire last edited,cannot retry a refusal with an empty field,keeps the reason and offers it again under it,keeps the hire's wording for a refused offer and lets go of it once it went through).Merge note
Retarget to
devafter #267 merges — GitHub does not retarget an open PR while the base branch still exists; it is one command (gh pr edit <N> --base dev).Second review round — both "worth a look" points and both nits, each confirmed against the code and fixed
actionDraftsis cleared wherever the conversation is reset.startFreshVisitand the team-mode switch effect both reset messages, opener, error and composer draft; the flag wording now goes with them. It was never a correctness bug (the keys are per-message UUIDs, so a leftover could not collide), but a long-lived tab kept every wording the hire ever typed, on exactly the two transitions that reset everything else.Field'serror("Write a question before sending.") — announced,aria-invalid, and described by it — instead of leaving a disabled button with no reason. On the paths the backend produces it appears in response to the hire's own clearing — the buddy refuses to compose a flag with no question; a proposal that somehow arrived without one would show the error from the first render, which is right, because a disabled confirm still has to say why.handleConfirmwrites the trimmed question into the session draft, so a retry after a refusal shows the copy the PM would have read, not the padded typing.Red-checked: with
src/reverted and the tests kept, exactly these four tests fail.Third round — the deep review's findings, fact-checked before they were fixed
Its mechanisms held up. Two of the findings turned out to be inherited from #311 rather than brought in here, and one was a claim about the code rather than a defect in it.
status: "error"(it still holds while the retry is in flight, which is why it lives above the card), so only "Couldn't reach the server — try again." remains. New test, red-checked.AlertCirclein the warning tone instead, so the mark and the sentence agree — the shape carries the meaning, per the palette's colour-blind rule.required— asterisk plusaria-requiredon the textarea, now thatFieldpropagates it (dev's rework); no native validation attribute is set, so nothing blocks typing.BuddyDock.test.tsx's duplicate React keys are gone (inherited): its message helpers take an optional id, and the one test rendering two assistants passes its own.EMPTY_ACTION_DRAFTSis what keeps the memoised rows stable while a flag is typed into; aBuddyDraftProvider-style context is the follow-up if flag editing ever grows.