[Fix] Disabled ticket actions explain themselves to assistive technology, Fixes #409 - #451
Conversation
…on, Fixes #409 The ticket card's actions (link, switch, unlink, delete work, the move onto trunk and the dirty-trunk answers) share one gate that used to disable them silently, and the move onto trunk carried its reason as `title`, which Chromium hides on a disabled control and assistive technology skips. Every branch of the gate now has a sentence in `src/renderer/ticket-actions.cjs`, rendered through `ReasonedButton`: Tooltip, `accessibleWhenDisabled` and `description`, the shape the discard link already used, which now builds on it. The dirty-trunk question moves into the same module. When the ticket already has parked work the switch restores it, so its answers say "continue on #N" instead of "start clean". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJJSFQMq1ahmubgNNxJwqz
… the parked-work question Self-review findings. ReasonedButton returned a bare Button without a reason and a Tooltip-wrapped one with it, so React saw a different element type at the same position and remounted the button every time the gate flipped, dropping focus and restarting the busy animation. The Tooltip is now always rendered; with no text it renders its anchor and no popover. rebaseDisabledReason put `discarding` after the shared gate, so an install would be reported over a discard that is already rewriting the tree. It leads now, as it does in `discardDisabledReason`, which is what the button's old inline `title` produced. Only the new-ticket refusal carries the ticket number, so the parked-work answers always read "continue on the ticket". The renderer reads it back off the ref the switch was asked for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJJSFQMq1ahmubgNNxJwqz
|
Warning Review limit reachedNext included review available in 55 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (3)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai review |
|
Why
The ticket card disables its actions while a link, switch, delete, trunk update, install or build runs, and used to do so silently: one shared gate, no sentence. The one button that did explain itself, Update this ticket to the current trunk, carried its reason as
title, which Chromium does not show on a disabled control and assistive technology skips because the control has the realdisabledattribute. A contributor saw a dead button with no way to learn why. #409 has the full case.The dirty-trunk question had a second, smaller wrong: Discard them and start clean and Save them as a patch, then start clean… are false when the ticket already has parked work, because the switch restores that work and nothing starts clean.
What changes
src/renderer/ticket-actions.cjsholds the reason for every branch of the shared gate (ticketActionDisabledReason), the two extra guards the move onto trunk adds because it rewrites the checked-out tree (rebaseDisabledReason, the dev server and a discard in flight), and the dirty-trunk question with both wordings (dirtyTrunkQuestion). Order followsdiscardDisabledReason: the action already underway first, then the process the contributor can wait for or stop.ReasonedButtoninindex.jsxis the house shape lifted out ofDiscardChangesLink:Tooltip+accessibleWhenDisabled+description={reason}when there is a reason, the plainButtonotherwise.DiscardChangesLinknow builds on it. Every ticket action goes through it: Unlink, the branch rows (switch, delete work), the four dirty-trunk answers, Link ticket in both views, and the move onto trunk.disabledfor the empty field. That is not a blocked action, so it gets no sentence.Deliberately not in this PR: the button staying enabled after a rebase conflict (the third item in #409, needs state that outlives the refusal), the
TextControlfor the ticket number (a disabled text field has no tooltip shape in the component library), andsrc/main.js(untouched, another change owns it).How to test this
Platform: either, macOS or Windows. Build: the Buildkite artifact for this branch at head
1fb75e1, or a localnpm startfrom the repository root.Starting state: a site with a linked ticket whose base predates trunk, so the card shows "Trunk has moved since this ticket started." A site cloned before trunk last moved, then Update to latest trunk on a second site, gets there; the journey below stages the same thing.
What must not have happened: a button that is greyed with no tooltip and no description; a disabled button that still fires its action on click or Enter; the dirty-trunk panel losing an answer it offered before; the branch rows or the Unlink action disabled while the app is idle.
The layer-4 journey
tests/e2e/journeys/ticket-rebase.spec.jsclicks the move button by its accessible name on both platforms; run from the repository root withnpx playwright test --project=journeys tests/e2e/journeys/ticket-rebase.spec.js. It covers the happy path, not the disabled state, so steps 1 to 3 are the manual pass. Unit coverage istests/unit/ticket-actions.test.cjs(from the repository root:node --test tests/unit/ticket-actions.test.cjs). On the old code there is nothing to test: the gate was an inline boolean inindex.jsxand the wording was inline JSX, which is the bug the test now pins.Risks and limitations
1fb75e1, none deferred. Everything below this line is what is deliberately not fixed here. See the review outcome for the full record.accessibleWhenDisabledkeeps the button in the tab order and usesaria-disabled, so a Playwrightclick()on it waits for enabled as before, but a test that asserted thedisabledattribute directly would needtoBeDisabled(). No test in the repository does.src/main.jscarriesticketandfiles. That file is owned by another change, so the count stays absent on that path and the sentence reads "You have uncommitted changes on this site" rather than naming a number. Carrying both through the other refusal is the tidier fix and belongs whereversrc/main.jsnext opens.TextControl) is still silently disabled by the gate; the button beside it explains.---/+++header heuristic inrewritePatchPathsandsplitPatchSections(src/patch-plan.cjs) is line-local, so a hunk that removes a line starting--right before one adding a line starting++reads as a file header and the patch is refused with a nonsense reason. Never a bad write, Git decides. Tracking hunk state (the@@counts) closes it.Related
Fixes #409. Follow-up to #408 (which moved the ticket panel's feedback under the controls).
Design decisions and alternatives considered
ReasonedButton) rather than a second copy of the Tooltip block per button.DiscardChangesLinkkeeps its signature and callers; it is now three lines over the wrapper.discardDisabledReason, because that function's first three branches (patch loading, load failed, no changes) do not apply to a tree rewrite that is not a discard, and its sentences end in "before discarding changes".ticketActionsBlockedstays asBoolean(ticketActionsReason)so the twoTextControls keep their gate without a second expression that could drift from the sentence.canCarryfrom main's answer, as before; the module only words it. The "continue on #N" form is what the switch actually does, per Disabled ticket-card buttons explain themselves through title, which Chromium hides and assistive tech skips #409.Review outcome (required — see AGENTS.md)
2 [fix here] · 3 [follow-up] — both
[fix here]fixed in1fb75e1.Fixed:
ReasonedButtonreturned<Button>without a reason and<Tooltip><Button></Tooltip>with one, so React saw a different element type at the same position and remounted the<button>on every flip of the gate. That discards whataccessibleWhenDisabledbuys: the keyboard user who just activated the control loses the focused element and focus falls back to the document, and the busy animation restarts on the buttons that pairisBusywith the same flag. The Tooltip is now always rendered; with no text it renders its anchor and no popover (verified in@wordpress/components28.13.0,build/tooltip/index.js).rebaseDisabledReasoncheckeddiscardingafter the shared gate, so an install or build would be reported over a discard already rewriting the tree.discardDisabledReasonputs it first, and so did this button's old inlinetitle. It leads now, with the ordering pinned by the test.Deferred, with reasons:
canCarry: falsequestion was asserted with a ticket number and a file count that the app could not produce, because only the new-ticket refusal insrc/main.jsattachesticketandfiles. Rather than mark the wording unreachable, the renderer now derives the ticket from the ref it asked to switch to, so the#Nform ships. The count still does not; carrying it through the other refusal needssrc/main.js, which another change owns. Noted in Risks.TextControlbeside "Link ticket" is stilldisabledwith no reason.TextControlhas noaccessibleWhenDisabled, so it needs a different shape (a visible sentence in the row, orreadOnlywitharia-describedby) rather than this PR's wrapper. Noted in Risks.index.jsx, which §1 of the review standard puts out of the suite's reach by construction. Taken as the repo's chosen trade-off; the module header now namesReasonedButtonas the only sanctioned way to disable a ticket action, so a future review has something to point at.Checked and clear in the review:
isBusywithaccessibleWhenDisabled(the no-op click handlers are spread after the caller's props, so a gated click cannot reachsaveTicket);Tooltipinside the text span and the flex row (portalled popover,VisuallyHiddendescription, no layout or focus effect); the dirty-trunk question sentence byte-identical for bothcanCarryvalues;ButtonforwardingdescriptionandaccessibleWhenDisabledon the installed 28.13.0; and theticket-rebasejourney's locator and auto-wait, sincedescriptiondoes not enter the accessible name and Playwright's enabled check honoursaria-disabled. Security, performance and cross-platform: nothing to report, a renderer string module with no IPC, spawn, path or persistence surface.passbut its message reads "Review rate limited", and an@coderabbitai reviewafter the window it named was refused the same way. No CodeRabbit finding exists for this PR, at any severity..github/instructions/code-review.instructions.md; reviewed head909258d/ basef31e5df;npm run lintclean andnpm test1289 pass / 0 fail at that revision.909258d→1fb75e1, the two[fix here]findings plus the ticket-number fallback; lint clean,npm test1289 pass / 0 fail,npm run build:onceclean. Not re-reviewed in a fresh context.Screenshots or recording
No screenshot. The visible change is a tooltip and a screen-reader description on buttons that were already greyed; the state that shows it needs a running install or dev server, which the manual pass above stages. The dirty-trunk panel's new labels are quoted in step 4.
🤖 Generated with Claude Code
https://claude.ai/code/session_01KJJSFQMq1ahmubgNNxJwqz