Inline unstaged diff comment composer cannot be dismissed: Cancel renders as invisible text, Escape does nothing #566

Closed
opened 2026-09-15 13:06:37 +00:00 by crueber · 1 comment
Owner

Unstaged inline diff comment composer cannot be dismissed — the Cancel affordance renders as invisible text, and Escape does nothing

What's requested

A working, visible Cancel for the unstaged inline diff comment composers on the PR conversation page (DiffFile keyed drafts) and the PR Files tab (PullDiffFile staged selection): a Cancel that is visibly an interactive control, dismisses via button click AND Escape, removes only that composer instance, and never stages anything.

Evidence (static analysis of the current tree; no live repro)

The dismiss control exists and its handler is correctly wired — the failure is that it is invisible:

  • web/src/pages/Pull.jsx:499 (conversation page, inline draft composer): <button type="button" class="link ml-2" onClick={() => closeDraft(draftKey(hi(), ri()))}>cancel</button>, rendered inside the muted caption <p class="mb-1 text-xs text-zinc-500 dark:text-zinc-400">commenting on <span class="font-mono">…</span>cancel</p>.
  • web/src/pages/PullFiles.jsx:84 (Files tab staged-selection composer): same pattern — <button type="button" class="link ml-2" onClick={() => setStaged(null)}>cancel</button> inside the same muted caption idiom.

The whole bug: the .link class has zero CSS rules in the shipped stylesheet. web/src/ui.css is the only bundled sheet (index.jsx imports only ./ui.css; the repo's own header comment at ui.css:39 notes web/css/* is dead/unbundled). ui.css defines .btn, .pill, .card, .err-line, etc. in its @layer components, but grepping the entire web/ tree for a .link { rule returns nothing. Under Tailwind v4 preflight, a classless button is reset to plain text: no background, no border, inherited color, cursor: default. So the Cancel renders as the lowercase word "cancel" in the same size and muted zinc-500 color as the caption it sits in — visually part of the sentence "commenting on a/path @12 cancel". Users cannot tell it is clickable, so the composer reads as impossible to dismiss. (The click handler itself is sound; this is an affordance defect, not a logic defect.)

Adjacent: there is no Escape dismissal anywhere on these composers — no onKeyDown on the composer panel, no document-level listener. The repo's established popover/dismissal family (SplitCloseMenu in CommentComposer.jsx:38-43, RefPicker/ReactionMenu patterns) closes on Escape and refocuses the trigger; the inline composers are the outliers.

Architecture notes

  • Conversation page drafts are a keyed Map (${hunkIdx}:${rowIdx}, getDrafts/closeDraft at Pull.jsx:355-367); dismissal must delete only that key — sibling open composers stay untouched with their text preserved. That behavior already exists; the fix is affordance + keyboard, not state rework.
  • Files tab has a single staged selection signal (getStaged/setStaged(null), PullFiles.jsx:36, 84); dismissal clears it without posting and without touching getCreated.
  • Naming note: this "Cancel" is unrelated to props.onClose (issue Close/Reopen) in CommentComposer.jsx — do not conflate. The composer component itself needs no change for the conversation page (the caption lives in the consumer); if the control is centralized into CommentComposer, keep the dismissal callsite-provided.
  • Systemic adjacent finding (do NOT bundle the sweep into this ticket's scope silently): <button class="link"> / <A class="link"> is used across ~10 pages (Pull.jsx:181, 280, 585, 589, 732; Checks.jsx:151, 205; Team.jsx:57; Settings.jsx:1021, 1310-1314; PullFiles.jsx:84, 105). <A class="link"> accidentally looks acceptable because base a { color: var(--accent) } styling applies, but every button-shaped .link renders as bare text. Suggest a follow-up ticket for a shared .link component rule + page sweep once this lands.

Fix prescription

  1. Visible Cancel affordance. Either (a) add a .link component rule to ui.css @layer components (accent color, cursor-pointer, hover:underline) — which fixes the systemic class incidentally — or (b) give the composer Cancel a small secondary .btn treatment consistent with the row's caption. Pick one; (b) is stronger affordance, (a) is the smaller diff. Either way the control must be distinguishable from its caption in both themes.
  2. Escape dismissal. onKeyDown Escape on the composer panel (and/or textarea) dismisses: closeDraft(key) on the conversation page, setStaged(null) on Files tab. Follow the SplitCloseMenu convention (CommentComposer.jsx:38-43): Escape closes and refocuses the trigger (the gutter "+" that staged the draft), listener cleanup in onCleanup if a document listener is used. Mind the toggle-fight trap: dismissal must not re-trigger staging.
  3. Semantics stay as wired: dismissal removes the composer instance and its draft text, calls nothing (no onStage, no thread create), and leaves other open drafts and the staged selection's diff row intact.

Acceptance criteria

  • Cancel on the PR conversation inline composer is visibly interactive (hover + cursor) in light and dark themes.
  • Cancel on the Files tab staged-selection composer likewise.
  • Escape with the composer focused dismisses it on both surfaces and refocuses the staging trigger (gutter "+" / row).
  • Dismissal removes only that draft: sibling open composers keep their text; nothing is staged; no thread is created (verify no network POST fires on dismiss).
  • After dismissal, re-staging the same line opens a fresh empty composer.
  • Follow-up ticket filed (separately) for the unstyled .link class sweep across other pages.
# Unstaged inline diff comment composer cannot be dismissed — the Cancel affordance renders as invisible text, and Escape does nothing ## What's requested A working, visible Cancel for the unstaged inline diff comment composers on the PR conversation page (`DiffFile` keyed drafts) and the PR Files tab (`PullDiffFile` staged selection): a Cancel that is visibly an interactive control, dismisses via button click AND Escape, removes only that composer instance, and never stages anything. ## Evidence (static analysis of the current tree; no live repro) The dismiss control exists and its handler is correctly wired — the failure is that it is invisible: - `web/src/pages/Pull.jsx:499` (conversation page, inline draft composer): `<button type="button" class="link ml-2" onClick={() => closeDraft(draftKey(hi(), ri()))}>cancel</button>`, rendered inside the muted caption `<p class="mb-1 text-xs text-zinc-500 dark:text-zinc-400">commenting on <span class="font-mono">…</span>cancel</p>`. - `web/src/pages/PullFiles.jsx:84` (Files tab staged-selection composer): same pattern — `<button type="button" class="link ml-2" onClick={() => setStaged(null)}>cancel</button>` inside the same muted caption idiom. The whole bug: **the `.link` class has zero CSS rules in the shipped stylesheet.** `web/src/ui.css` is the only bundled sheet (index.jsx imports only `./ui.css`; the repo's own header comment at ui.css:39 notes `web/css/*` is dead/unbundled). ui.css defines `.btn`, `.pill`, `.card`, `.err-line`, etc. in its `@layer components`, but grepping the entire `web/` tree for a `.link {` rule returns nothing. Under Tailwind v4 preflight, a classless button is reset to plain text: no background, no border, inherited color, `cursor: default`. So the Cancel renders as the lowercase word "cancel" in the same size and muted zinc-500 color as the caption it sits in — visually part of the sentence "commenting on a/path @12 cancel". Users cannot tell it is clickable, so the composer reads as impossible to dismiss. (The click handler itself is sound; this is an affordance defect, not a logic defect.) Adjacent: there is no Escape dismissal anywhere on these composers — no `onKeyDown` on the composer panel, no document-level listener. The repo's established popover/dismissal family (SplitCloseMenu in `CommentComposer.jsx:38-43`, RefPicker/ReactionMenu patterns) closes on Escape and refocuses the trigger; the inline composers are the outliers. ## Architecture notes - Conversation page drafts are a keyed Map (`${hunkIdx}:${rowIdx}`, `getDrafts`/`closeDraft` at Pull.jsx:355-367); dismissal must delete only that key — sibling open composers stay untouched with their text preserved. That behavior already exists; the fix is affordance + keyboard, not state rework. - Files tab has a single staged selection signal (`getStaged`/`setStaged(null)`, PullFiles.jsx:36, 84); dismissal clears it without posting and without touching `getCreated`. - Naming note: this "Cancel" is unrelated to `props.onClose` (issue Close/Reopen) in `CommentComposer.jsx` — do not conflate. The composer component itself needs no change for the conversation page (the caption lives in the consumer); if the control is centralized into CommentComposer, keep the dismissal callsite-provided. - Systemic adjacent finding (do NOT bundle the sweep into this ticket's scope silently): `<button class="link">` / `<A class="link">` is used across ~10 pages (Pull.jsx:181, 280, 585, 589, 732; Checks.jsx:151, 205; Team.jsx:57; Settings.jsx:1021, 1310-1314; PullFiles.jsx:84, 105). `<A class="link">` accidentally looks acceptable because base `a { color: var(--accent) }` styling applies, but every button-shaped `.link` renders as bare text. Suggest a follow-up ticket for a shared `.link` component rule + page sweep once this lands. ## Fix prescription 1. **Visible Cancel affordance.** Either (a) add a `.link` component rule to ui.css `@layer components` (accent color, `cursor-pointer`, `hover:underline`) — which fixes the systemic class incidentally — or (b) give the composer Cancel a small secondary `.btn` treatment consistent with the row's caption. Pick one; (b) is stronger affordance, (a) is the smaller diff. Either way the control must be distinguishable from its caption in both themes. 2. **Escape dismissal.** `onKeyDown` Escape on the composer panel (and/or textarea) dismisses: `closeDraft(key)` on the conversation page, `setStaged(null)` on Files tab. Follow the SplitCloseMenu convention (CommentComposer.jsx:38-43): Escape closes and refocuses the trigger (the gutter "+" that staged the draft), listener cleanup in `onCleanup` if a document listener is used. Mind the toggle-fight trap: dismissal must not re-trigger staging. 3. **Semantics stay as wired:** dismissal removes the composer instance and its draft text, calls nothing (no `onStage`, no thread create), and leaves other open drafts and the staged selection's diff row intact. ## Acceptance criteria - [ ] Cancel on the PR conversation inline composer is visibly interactive (hover + cursor) in light and dark themes. - [ ] Cancel on the Files tab staged-selection composer likewise. - [ ] Escape with the composer focused dismisses it on both surfaces and refocuses the staging trigger (gutter "+" / row). - [ ] Dismissal removes only that draft: sibling open composers keep their text; nothing is staged; no thread is created (verify no network POST fires on dismiss). - [ ] After dismissal, re-staging the same line opens a fresh empty composer. - [ ] Follow-up ticket filed (separately) for the unstyled `.link` class sweep across other pages.
crueber added this to the v1 milestone 2026-09-15 13:09:02 +00:00
Author
Owner

Fixed by #569 (merged): Cancel takes the secondary .btn treatment on both composers (visible both themes, no new CSS); Escape dismisses + refocuses the staging trigger on both surfaces. Dismissal drops only that draft, stages nothing. Follow-up .link sweep tracked separately as #568. Verified: 1352 unit tests green (smoke excluded, pre-existing), vite/esbuild green, independent review APPROVE.

Fixed by #569 (merged): Cancel takes the secondary .btn treatment on both composers (visible both themes, no new CSS); Escape dismisses + refocuses the staging trigger on both surfaces. Dismissal drops only that draft, stages nothing. Follow-up .link sweep tracked separately as #568. Verified: 1352 unit tests green (smoke excluded, pre-existing), vite/esbuild green, independent review APPROVE.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
crueber/walhub#566
No description provided.