Skip to content

fix(buddy): edit the flag-to-PM question before confirming (#235) - #272

Open
daniilperkin wants to merge 6 commits into
feature/311-buddy-onboarding-tutorfrom
feature/235-buddy-escalation-preview-edit
Open

daniilperkin wants to merge 6 commits into
feature/311-buddy-onboarding-tutorfrom
feature/235-buddy-escalation-preview-edit

Conversation

@daniilperkin

@daniilperkin daniilperkin commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

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_pm gains 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.

  • BuddyActionProposals becomes a thin list; each action is its own BuddyProposalCard, so an offer has an identity of its own across every status.
  • handleConfirm is the single send path — the card's confirm button, and the same button again after a refusal — sending { ...action, question: editedQuestion.trim() }.
  • The confirm is disabled while the field is empty or whitespace-only; the field is frozen while a confirm is in flight.
  • The hire's wording lives in the session (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 legacy AutoResizeTextarea: the shared control already carries the label binding, the hint's aria-describedby and the disabled treatment.
  • No change to the confirm payload in useBuddyConversation: it already sends action.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:

  • the hire can see and correct what would go out before retrying (before, the field was unmounted, so the wording existed only in memory);
  • pressing the button again no longer swaps the whole card in and out (the layout jump);
  • the separate "Try again" button is gone: the card's own confirm is the retry, and it is disabled rather than silently inert when the field is empty;
  • the reason stays on screen while the retry is in flight, so pressing the button does not blank the sentence being acted on.

Acceptance criteria (#235)

  • A flag_to_pm proposal displays the complete proposed question.
  • The user can edit the question before sending it — on the first attempt and on a refusal's retry.
  • Confirming the action submits exactly the displayed text.
  • Cancelling the action does not create a knowledge request (a hire offer dismisses locally — useBuddyConversation.ts, unchanged).
  • Works in both surfaces: the full page and the dock render the same 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.tsx pins the session draft (kept on a refusal, dropped once sent); tests/unit/a11y/BuddyActionProposals.a11y.test.tsx covers the editable field and the refused card.
  • Red-checked: with the offer-keeping behaviour reverted, four of the new tests fail (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).
  • Gates green, new/updated unit tests red-checked
  • Manually verified end-to-end in the running app (not done in this PR)

Merge note

Retarget to dev after #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

  • actionDrafts is cleared wherever the conversation is reset. startFreshVisit and 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.
  • The emptied field says why it cannot be sent. It passes Field's error ("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.
  • The field is handed back what it sent. handleConfirm writes 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.
  • One continuous draft-lifecycle test (refused → kept in the session → retried → sent, in one session), plus a test per reset path (fresh visit, switch) and an a11y case for the emptied field.

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.

  • A retry that failed on the wire no longer tells two stories. The outcome line stands down for 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.
  • The refusal stopped wearing a checkmark. AlertCircle in the warning tone instead, so the mark and the sentence agree — the shape carries the meaning, per the palette's colour-blind rule.
  • The field is required — asterisk plus aria-required on the textarea, now that Field propagates it (dev's rework); no native validation attribute is set, so nothing blocks typing.
  • The claim about when the error appears is corrected above. It is true for every proposal the backend produces — it refuses to compose a flag with no question; one that somehow arrived without a question would show the error from the first render, which is right: a disabled confirm still has to say why.
  • 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.
  • Left alone on purpose: the keystroke re-render observation (Finding 5). EMPTY_ACTION_DRAFTS is what keeps the memoised rows stable while a flag is typed into; a BuddyDraftProvider-style context is the follow-up if flag editing ever grows.

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.
@daniilperkin
daniilperkin added this pull request to stack #273 September 27, 2026 21:02
@daniilperkin daniilperkin linked an issue Sep 28, 2026 that may be closed by this pull request
5 tasks
…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.
@daniilperkin
daniilperkin marked this pull request as ready for review September 28, 2026 09:25
@daniilperkin

Copy link
Copy Markdown
Collaborator Author

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.

feature/311-buddy-onboarding-tutor (#267) is ~99 commits behind dev, and both sides changed the same file, src/features/buddy/components/BuddyComposer.tsx (dev: the caret hand-off/return around submit; #267: the typedRef focus for drafts arriving from outside). So the bottom layer can't merge into dev, and that blocks everything above it. #267 reports CONFLICTING/DIRTY. This PR itself is clean against its own base: one commit, no conflicts, checks green.

What we're doing about it

Waiting for David to resolve #267 on his own branch. The conflict is his code against dev's, so the call is his — we're not merging into or force-pushing someone else's branch.

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

For whoever does it, the cheap path — dry-ran locally end to end, verified:

  1. Merge origin/dev into feature/311-buddy-onboarding-tutor: one content conflict, BuddyComposer.tsx, both sides additive — keep dev's caret hand-off/refocus and the typedRef external-draft focus, one below the other.
  2. tests/unit/features/buddy/BuddyComposer.test.tsx: the handleSubmit stub must return true now that dev's contract returns a boolean. Git merges that file cleanly and only tsc sees the mismatch.
  3. That push is a plain fast-forward from the branch tip — no force, no history rewrite — and Make the buddy the tutor along the onboarding path #267's approval survives it (dismiss_stale_reviews_on_push: false on the ruleset); only CI re-runs.

Dry-run result: tsc -b and prettier clean, 23 buddy test files / 157 tests green. Ask me and I'll hand the resolved diff over.

After #267 merges

  • GitHub retargets this PR's base to dev automatically. Since Make the buddy the tutor along the onboarding path #267 will land as a merge commit (the repo's convention — every first-parent commit on dev), 311's tip stays an ancestor of dev, so the diff here stays our one commit.
  • If GitHub still reports a conflict at that point, we back-merge dev into this branch — the pattern the repo already uses (466a1b20 Merge remote-tracking branch 'origin/dev' into feature/KB-updates-final) — then merge.

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 kiranfin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. actionDrafts is 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 reset messages, openerAction, openError, draft and more, but not actionDrafts. Keys are messageId:actionId from 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, clearing actionDrafts alongside them would be the consistent thing to do.

  2. An empty field disables the confirm silently, with no visible reason and no aria-describedby — src/features/buddy/components/BuddyActionProposals.tsx:309-323
    ui/Field has an error prop built exactly for this ("its presence is the error state: it colors the border, sets aria-invalid, and is announced," per its own doc comment), but the card renders Field here 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 like error="Write a question before sending." when editedQuestion.trim() is empty would close that gap and match how the rest of the app treats invalid fields.

🟢 Nits / cleanup

  • handleConfirm sends editedQuestion.trim() but never writes the trimmed value back into actionDrafts (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 separate confirmOnce calls, 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Show the exact message before a Buddy escalation is confirmed

2 participants