-
Notifications
You must be signed in to change notification settings - Fork 0
Add the pencil case: physical review tools for contributions #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| # Review tools: the pencil case | ||
|
|
||
| ## What we learned from highlighte.rs | ||
|
|
||
| [highlighte.rs](https://highlighte.rs) is a library for realistic highlighter | ||
| marks, but the thing worth taking is how its site feels to use. The tools are | ||
| physical: pens stand in a tray with their tips showing, rise when you hover, | ||
| lift right out when you pick one, and a soft outline travels between them on | ||
| a spring. Ink colour crossfades. Marks are drawn on, not switched on. There is | ||
| a squeak when you pick a marker. Everything shares one quint curve | ||
| (`cubic-bezier(0.2, 0, 0, 1)`), text sits on cream paper in warm brown ink, | ||
| and nothing is decorated that does not also do something. | ||
|
|
||
| Their answer to "what do you build with this?" is a highlighter. Ours is code | ||
| review, because that is what maintainers do all day in AXP. | ||
|
|
||
| ## What was built | ||
|
|
||
| A pencil case: a capsule tray that floats at the bottom of a contribution | ||
| page for anyone who can post (`ui/src/review`). Three tools, four inks, four | ||
| stamps, an eraser, and an opt-in sound toggle. | ||
|
|
||
| - **Highlighter.** Drag over any prose (an agent turn, a maintainer prompt, a | ||
| comment) and a real highlighter mark is laid down with `@highlighters/core` | ||
| (MIT): chisel tip, word snapping, a short draw-on animation. A chip appears | ||
| under the mark: _Quote in discussion_. It quotes the passage into the | ||
| composer with an attribution and switches to the discussion. Quoted comments | ||
| render with the same mark over the quote, so the trail is visible. | ||
| - **Highlighter and Note on the diff.** With either held, Pierre's line | ||
| selection is enabled; dragging down the gutter selects lines (tinted with | ||
| the current ink), and a sticky note appears under the last line with the | ||
| reference (`src/parser.ts:L2–L4`). Pin it and it posts as a comment anchored | ||
| to the checkpoint and file, with the reference in code. | ||
| - **Stamp.** Pick LGTM, Needs work, Question or Nice, click anywhere, and the | ||
| stamp lands with a spring and a rubber-stamp ink texture. The verdict is | ||
| posted to the discussion as `**LGTM** — on bbbbbbb`, which the discussion | ||
| renders as a stamp again. Landed stamps are local decoration; the comment | ||
| is the record. | ||
| - **Paper.** The discussion sits on ruled paper (24px rhythm, 6% ink). | ||
|
|
||
| No protocol changes. Every action is an ordinary `_axp/comment`, which is | ||
| why the tools work for contributors and verifiers as well as maintainers, and | ||
| why nothing needs a migration. | ||
|
|
||
| ## The interaction language, as rules | ||
|
|
||
| These are the rules the pencil case follows, written down so the rest of the | ||
| app can follow them too: | ||
|
|
||
| 1. Tools are objects. They have a resting place, they rise when you reach for | ||
| them, and they lift when you pick them up. Selected ≠ highlighted. | ||
| 2. One curve. `cubic-bezier(0.2, 0, 0, 1)` for state changes; a `linear()` | ||
| spring only for things that arrive (the tray, a stamp, a note). | ||
| 3. Ink is a colour property, so changing it crossfades rather than re-renders. | ||
| 4. Marks are drawn, over the text, never in it: selection, copy and find | ||
| keep working. | ||
| 5. Sound is opt-in, synthesised, and remembered per browser. | ||
| 6. Keyboard first: the tray is a toolbar, tools are toggle buttons, inks and | ||
| stamps are radio groups, Escape puts the tool down. | ||
| 7. Decoration never replaces the record. A stamp is a comment. | ||
|
|
||
| ## Not done yet | ||
|
|
||
| - A vendored handwriting face for notes and stamps; the note uses a cursive | ||
| system stack for now (see `typography.md` for candidates). | ||
| - Marks and landed stamps do not persist across reloads. If they should, the | ||
| comment already carries enough to redraw them (file, lines, quote). | ||
| - Stamps could drive review flow: LGTM opening the approval dialog for a | ||
| maintainer who can sign is the obvious next step. | ||
| - Underline and strike-through pens (highlighters supports both) for | ||
| suggesting deletions in prose. |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,6 +38,16 @@ import { | |
| import { TabGroup } from "./vendor/huabu/TabGroup.js"; | ||
| import { useCommand, useDraft } from "./api.js"; | ||
| import { Transcript } from "./Transcript.js"; | ||
| import { | ||
| PencilCase, | ||
| StampLayer, | ||
| stampBody, | ||
| stampOf, | ||
| useReviewTools, | ||
| } from "./review/PencilCase.js"; | ||
| import type { Landed } from "./review/PencilCase.js"; | ||
| import { thunk } from "./review/sound.js"; | ||
| import { QuotedProse } from "./review/QuotedProse.js"; | ||
|
|
||
| const DiffPanel = lazy(() => import("./DiffPanel.js")); | ||
|
|
||
|
|
@@ -137,6 +147,77 @@ export function ContributionPage({ | |
| const writable = workspace.principal.role !== "observer"; | ||
| const disabled = offline || command.busy; | ||
| const checkpoint = exchange.checkpoint; | ||
| const review = useReviewTools(); | ||
| const [quote, setQuote] = useState<{ | ||
| text: string; | ||
| author: string; | ||
| x: number; | ||
| y: number; | ||
| } | null>(null); | ||
| const [landed, setLanded] = useState<Landed[]>([]); | ||
| useEffect(() => setQuote(null), [tab, review.tool]); | ||
| /** Highlighter: a drag over prose paints a mark and offers to quote it. */ | ||
| const paintSelection = (panel: HTMLElement) => { | ||
| if (review.tool !== "highlighter") return; | ||
| const selection = document.getSelection(); | ||
| if (!selection || selection.isCollapsed || !selection.rangeCount) return; | ||
| const range = selection.getRangeAt(0); | ||
| const node = range.commonAncestorContainer; | ||
| const element = node instanceof Element ? node : node.parentElement; | ||
| const prose = element?.closest(".prose"); | ||
| if (!prose || !panel.contains(prose)) return; | ||
| const text = selection.toString().trim(); | ||
| if (!text) return; | ||
| const author = prose.closest(".turn-response") | ||
| ? "the agent" | ||
| : prose.closest(".turn-prompt") | ||
| ? "the maintainer" | ||
| : (prose.closest(".comment")?.querySelector("strong")?.textContent ?? | ||
| "the discussion"); | ||
| const rect = range.getBoundingClientRect(); | ||
| const box = panel.getBoundingClientRect(); | ||
| review.mark(range); | ||
| selection.removeAllRanges(); | ||
| setQuote({ | ||
| text, | ||
| author, | ||
| x: rect.left - box.left + rect.width / 2, | ||
| y: rect.bottom - box.top + 8, | ||
| }); | ||
| }; | ||
| /** Stamp: lands where you click and posts the verdict. */ | ||
| const dropStamp = (event: React.MouseEvent<HTMLDivElement>) => { | ||
| if (review.tool !== "stamp" || disabled) return; | ||
| if ( | ||
| (event.target as HTMLElement).closest( | ||
| "button, a, input, textarea, select", | ||
| ) | ||
| ) | ||
| return; | ||
| const box = event.currentTarget.getBoundingClientRect(); | ||
| setLanded((all) => [ | ||
| ...all, | ||
| { | ||
| id: crypto.randomUUID(), | ||
| stamp: review.stamp, | ||
|
Comment on lines
+198
to
+202
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The visual stamp is added before Useful? React with 👍 / 👎. |
||
| where: tab, | ||
| x: event.clientX - box.left, | ||
| y: event.clientY - box.top, | ||
| rot: -12 + Math.random() * 10, | ||
| }, | ||
| ]); | ||
| thunk(); | ||
| const onChanges = tab === "changes" && checkpoint; | ||
| void command.send(contribution.id, { | ||
| kind: "comment", | ||
| body: stampBody( | ||
| review.stamp, | ||
| onChanges ? `on ${checkpoint.headCommit.slice(0, 7)}` : undefined, | ||
| ), | ||
| checkpoint: onChanges ? checkpoint.headCommit : null, | ||
| path: null, | ||
| }); | ||
| }; | ||
| const reviewKey = `${checkpoint?.headCommit ?? ""}:${exchange.review?.contributor.signature ?? ""}`; | ||
| const manifest = loadedReview?.key === reviewKey ? loadedReview.digest : null; | ||
| const ready = useCallback( | ||
|
|
@@ -149,7 +230,12 @@ export function ContributionPage({ | |
| setTab("discussion"); | ||
| }; | ||
| return ( | ||
| <section className="contribution-page"> | ||
| <section | ||
| className={`contribution-page ${review.tool ? `tool-${review.tool}` : ""}`} | ||
| > | ||
| {writable && !offline && exchange.status !== "closed" && ( | ||
| <PencilCase review={review} /> | ||
| )} | ||
|
Comment on lines
+236
to
+238
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If a contribution becomes closed while a stamp or highlighter is selected, this condition unmounts the toolbar without clearing Useful? React with 👍 / 👎. |
||
| <button className="back-button" onClick={back}> | ||
| <ArrowLeft size={15} /> All contributions | ||
| </button> | ||
|
|
@@ -199,7 +285,36 @@ export function ContributionPage({ | |
| ? "Changes" | ||
| : "Discussion" | ||
| } | ||
| onPointerUp={(event) => paintSelection(event.currentTarget)} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The highlighter is invoked only from Useful? React with 👍 / 👎. |
||
| onClick={dropStamp} | ||
| > | ||
| <StampLayer | ||
| stamps={landed.filter((stamp) => stamp.where === tab)} | ||
| /> | ||
|
Comment on lines
+291
to
+293
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Landed stamps are filtered only by tab, so if the agent publishes a new checkpoint while the reviewer remains on the contribution, stamps placed on the old diff are immediately drawn at the same coordinates over the new diff. The corresponding durable comment remains anchored to the old commit, but the visible overlay can now look like an LGTM or Needs work verdict on unrelated new content. Include the checkpoint in Useful? React with 👍 / 👎. |
||
| {quote && ( | ||
| <button | ||
| type="button" | ||
| className="pc-quote-chip" | ||
| style={{ | ||
| left: quote.x, | ||
| top: quote.y, | ||
| transform: "translateX(-50%)", | ||
| }} | ||
| onClick={() => { | ||
| const quoted = quote.text | ||
| .split("\n") | ||
| .map((line) => `> ${line}`) | ||
| .join("\n"); | ||
| setComment( | ||
| `${comment.trim() ? `${comment.trim()}\n\n` : ""}${quoted}\n\n— quoting ${quote.author}`, | ||
| ); | ||
|
Comment on lines
+308
to
+310
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Selecting more than roughly 8,000 characters from an agent response sets an oversized controlled value directly into the discussion composer, and the attribution can push an otherwise valid selection over the limit as well. The textarea's Useful? React with 👍 / 👎. |
||
| setQuote(null); | ||
| setTab("discussion"); | ||
| }} | ||
| > | ||
| <MessageCircle size={13} /> Quote in discussion | ||
| </button> | ||
| )} | ||
| {tab === "conversation" && ( | ||
| <> | ||
| <Transcript | ||
|
|
@@ -326,6 +441,18 @@ export function ContributionPage({ | |
| checkpoint={checkpoint.headCommit} | ||
| discuss={discuss} | ||
| ready={ready} | ||
| review={{ | ||
| tool: review.tool, | ||
| ink: review.ink, | ||
| onNote: (text, path, reference) => { | ||
| void command.send(contribution.id, { | ||
| kind: "comment", | ||
| body: `${text}\n\n\`${reference}\``, | ||
| checkpoint: checkpoint.headCommit, | ||
| path, | ||
| }); | ||
| }, | ||
| }} | ||
| /> | ||
| </Suspense> | ||
| ) : ( | ||
|
|
@@ -335,7 +462,7 @@ export function ContributionPage({ | |
| </Empty> | ||
| ))} | ||
| {tab === "discussion" && ( | ||
| <div className="discussion"> | ||
| <div className="discussion is-paper"> | ||
| <div className="discussion-intro"> | ||
| <MessageCircle size={19} /> | ||
| <div> | ||
|
|
@@ -369,7 +496,31 @@ export function ContributionPage({ | |
| )} | ||
| </div> | ||
| )} | ||
| <Prose text={item.body} /> | ||
| {(() => { | ||
| const stamp = stampOf(item.body); | ||
| if (stamp) { | ||
| const rest = item.body | ||
| .trim() | ||
| .slice(stamp.label.length + 4) | ||
| .replace(/^\s*—\s*/, ""); | ||
| return ( | ||
| <> | ||
| <span | ||
| className="pc-stamp-inline" | ||
| style={{ ["--stamp-ink" as string]: stamp.ink }} | ||
| > | ||
| {stamp.label} | ||
| </span> | ||
| {rest && <p className="muted">{rest}</p>} | ||
| </> | ||
| ); | ||
| } | ||
| return item.body.trimStart().startsWith("> ") ? ( | ||
| <QuotedProse text={item.body} /> | ||
| ) : ( | ||
| <Prose text={item.body} /> | ||
| ); | ||
| })()} | ||
| </div> | ||
| </article> | ||
| ))} | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the stamp selected, clicking an interactive element not in this selector posts a permanent verdict comment. For example, the tool-call disclosures in
Transcript.tsxuse<summary>, so expanding one also triggersdropStamp; interactions inside the file-tree shadow DOM can likewise be retargeted to its host and bypass this check. Detect interactive elements through the composed event path or explicitly stop stamping from these controls.Useful? React with 👍 / 👎.