feat(panel): add a Markdown toolbar and suggestions to the comment field

Merged
DA
3d ago
+919-25

C1 of the "Better comment box" stack: a Markdown toolbar and suggestions in the Pull request tab's comment field. C2 (select to comment, several drafts at once, thread hover) will stack on this one.

Summary

  • Toolbar (markdown-toolbar.tsx). The shared MarkdownField now has a compact row of buttons beside Write / Preview, with stage-cli's set: heading, bold, italic | quote, code, link | bulleted, numbered and task list. All three users get it: the Review panel, the Conversation's comment box and the Files changed line comment. The row only shows while Write is selected.
    • Each button wraps the selection, or writes a selected placeholder at the cursor (**bold text**). It goes in through execCommand('insertText'), so the textarea's own undo takes it back: ⌘Z undoes it in one step and ⌘⇧Z redoes it (checked in Chromium). Where execCommand isn't available (jsdom), it falls back to setRangeText plus an input event.
    • Pressing bold, italic or code again over what it wrapped removes the marks. Pressing a line format over lines that all have it removes it, as GitHub's toolbar does. Code over several lines becomes a fenced block. Numbered lists count 1, 2, 3. Link uses the selection as the link text and selects url; if the selection is a URL, it becomes the target instead.
    • ⌘B, ⌘I, ⌘K in the text press bold, italic and link, as GitHub does. While the text field has focus these keys belong to it: the window's keymap (Toggle sidebar, Command palette) doesn't receive them.
    • The row is a single role="toolbar" tab stop that you move along with the arrow keys, Home and End. A mouse press doesn't take focus away from the text field. Tooltips show ⌘B / ⌘I / ⌘K.
  • Suggestion. In the line-comment box, and only when both ends of the range are on the new side (RIGHT), Add a suggestion comes first. It inserts a ```suggestion block holding the selected lines' current text, read from the patch (suggestedLines in changes-model.ts). The block goes on its own lines after the line the cursor is on, with the code lines selected so you can edit them. Its fence is always longer than any run of backticks in the code. Old-side and mixed ranges don't offer it.
  • Preview. A suggestion fenced block gets the header "Suggested change" instead of a language name, and its lines sit on the added-lines tint (--luke-color-complete-tint).
  • The edits themselves are pure functions in markdown-edits.ts, with no DOM.

Desktop and panel only. Nothing changes in the service or on the wire: the comment is still sent through memberPullRequestWrite as before.

Screenshots

Rendered from the real LineComment with the built styles.css, in headless Chromium driven by Playwright. The harness has no app fonts.

LightDark
Add a suggestion pressed after "Say it once:"
Preview of the suggestion
⌘B and ⌘K pressed, Bold's hint

Evidence

pnpm check:affected ran out of memory in this sandbox (desktop tsc got exit 137, discarded-effect ran out of heap), so I ran the parts separately:

  • repository-checks.sh: passed (the contract checks inside check:affected)
  • tsc for @luke/panel and @luke/desktop: passed
  • ./scripts/oxlint.sh, biome check packages/panel, pnpm knip, node scripts/discarded-effect.mjs: passed
  • vitest for @luke/panel: 195 files, 2,079 tests passed. Desktop's app-menu.test.ts can't load here because Electron's binary isn't installed in this sandbox. That's unrelated to this change.
  • cd apps/desktop && node scripts/build.mjs: passed, within the bundle budget, not re-recorded.

Tests (+11)

  • markdown-edits.test.ts (8): code holding backticks gets a longer span delimiter, a ] in a link's text is escaped, wrapping and its placeholder, removing marks on a second press, multi-line code as a fenced block, link both ways, line prefixes with numbering and removal, placeholder on an empty line, and the suggestion block with a longer fence.
  • changes-model.test.ts (1): suggestedLines returns the new side's text for a range, and nothing for an old-side, mixed, or missing line.
  • agent-pull-request-tab.test.ts (2): Add a suggestion writes the lines and selects them, Preview shows "Suggested change", and the body sent is exactly the block; an old-side comment offers no suggestion. A second test checks that ⌘B bolds the selection without the window's keydown listener seeing it, and that Italic wraps inside the bold.

Invariants

  • ./scripts/check.sh green. (CI)
  • JSON Schema goldens unchanged.
  • Every behavior change has, in this PR, the test that fails without it; no change-detector test added.
  • Gateway envelope goldens unchanged.
  • No new as type assertion.
  • No Effect.runPromise/runSync/runFork outside the runtime edges.
  • No new setTimeout/setInterval/new Promise/AbortController/fs.watch.
  • No Effect, Stream, or Layer written as a statement and dropped.
  • Test count: +11, listed above.
  • Nothing hand-rolled replaced.
  • No AGENTS.md sentence names a changed part.
  • PRIVACY.md unchanged. The toolbar's tooltips and the "Suggested change" header are ordinary text, which session replay masks like every other word.
  • Package deps unchanged (lucide-react and @luke/settings were already dependencies of @luke/panel).
  • Barrel/door rule untouched.

Notes

  • Out of scope, owned by other agents: the file header, the tree, the shortcuts sheet and the diff settings. The ⌘B / ⌘I / ⌘K chords belong to the field, not the app, so they aren't listed in the shortcuts sheet.

Made with Luke


Devin Review