Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
- Lucide, Lucide contributors, ISC: https://github.com/lucide-icons/lucide
- React Markdown and remark-gfm, unified contributors, MIT:
https://github.com/remarkjs/react-markdown and https://github.com/remarkjs/remark-gfm
- Highlighters (`@highlighters/core`), Jace Attard, MIT:
https://github.com/JaceThings/highlighters. The pencil-case dock in
`ui/src/review` is our own; its interaction language is modelled on
highlighte.rs.
- DM Sans and IBM Plex Mono font packages, SIL Open Font License 1.1:
https://fontsource.org/fonts/dm-sans and https://fontsource.org/fonts/ibm-plex-mono

Expand Down
71 changes: 71 additions & 0 deletions docs/design/review-tools.md
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.
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"@eslint/js": "^10.0.0",
"@fontsource-variable/dm-sans": "5.3.0",
"@fontsource/ibm-plex-mono": "5.3.0",
"@highlighters/core": "^2.0.0",
"@pierre/diffs": "1.4.1",
"@pierre/trees": "1.0.0-beta.6",
"@playwright/test": "1.63.0",
Expand Down
1 change: 1 addition & 0 deletions scripts/ui-notices.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const roots = [
"remark-gfm",
"@fontsource-variable/dm-sans",
"@fontsource/ibm-plex-mono",
"@highlighters/core",
];
const packages = new Map();
async function visit(name, parent = process.cwd()) {
Expand Down
157 changes: 154 additions & 3 deletions ui/src/Contribution.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));

Expand Down Expand Up @@ -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",
)
Comment on lines +191 to +194

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude every interactive control from stamp clicks

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.tsx use <summary>, so expanding one also triggers dropStamp; 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 👍 / 👎.

)
return;
const box = event.currentTarget.getBoundingClientRect();
setLanded((all) => [
...all,
{
id: crypto.randomUUID(),
stamp: review.stamp,
Comment on lines +198 to +202

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Remove stamps when their comment fails to post

The visual stamp is added before command.send runs, and the returned success value is ignored. If the request times out, the discussion has reached its 256-comment limit, or a changes-tab checkpoint has advanced, the server rejects the comment but the page continues showing the verdict as though it landed successfully. Add the decoration only after success or remove it on a false result.

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(
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Deactivate tools when the pencil case disappears

If a contribution becomes closed while a stamp or highlighter is selected, this condition unmounts the toolbar without clearing review.tool. The panel handlers remain active, and disabled does not include the closed status, so subsequent clicks can keep posting stamps while no selected tool is visible; comments on closed exchanges are accepted by the host. Clear the active tool when the tray becomes unavailable or guard the handlers with the same visibility conditions.

Useful? React with 👍 / 👎.

<button className="back-button" onClick={back}>
<ArrowLeft size={15} /> All contributions
</button>
Expand Down Expand Up @@ -199,7 +285,36 @@ export function ContributionPage({
? "Changes"
: "Discussion"
}
onPointerUp={(event) => paintSelection(event.currentTarget)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Process keyboard-completed prose selections

The highlighter is invoked only from pointerup, so selecting prose with Shift+Arrow and completing the selection from the keyboard never calls paintSelection; no mark or quote action appears despite the toolbar itself being keyboard operable. Add an appropriate keyboard or selection-change path so the feature is usable without a pointing device.

Useful? React with 👍 / 👎.

onClick={dropStamp}
>
<StampLayer
stamps={landed.filter((stamp) => stamp.where === tab)}
/>
Comment on lines +291 to +293

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Scope landed stamps to the current checkpoint

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 Landed or clear changes-tab stamps when the checkpoint changes.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound generated quotes to the comment limit

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 maxLength only limits user editing and does not truncate this programmatic value, while the server rejects comment bodies over 8,000 characters, so the generated quote cannot be posted without manual trimming.

Useful? React with 👍 / 👎.

setQuote(null);
setTab("discussion");
}}
>
<MessageCircle size={13} /> Quote in discussion
</button>
)}
{tab === "conversation" && (
<>
<Transcript
Expand Down Expand Up @@ -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>
) : (
Expand All @@ -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>
Expand Down Expand Up @@ -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>
))}
Expand Down
Loading
Loading