From 07361cc18ccc4b1571358944679e17929dad7fc4 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 08:32:11 -0500 Subject: [PATCH 01/88] board: map a click in rendered markdown to its source offset Co-Authored-By: Claude Opus 5.5 --- .../client/board/__tests__/caret-map.test.ts | 68 +++++++++++++++++++ apps/board/src/client/board/caret-map.ts | 33 +++++++++ 2 files changed, 101 insertions(+) create mode 100644 apps/board/src/client/board/__tests__/caret-map.test.ts create mode 100644 apps/board/src/client/board/caret-map.ts diff --git a/apps/board/src/client/board/__tests__/caret-map.test.ts b/apps/board/src/client/board/__tests__/caret-map.test.ts new file mode 100644 index 0000000000..17b4084e10 --- /dev/null +++ b/apps/board/src/client/board/__tests__/caret-map.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test'; + +import { sourceOffset } from '../caret-map.ts'; + +/** The caret in `source` for a click just after `before` in the rendered text. */ +function caretAfter(source: string, rendered: string, before: string) { + const at = rendered.indexOf(before); + if (at < 0) throw new Error(`"${before}" is not in the rendered text`); + return sourceOffset(source, rendered, at + before.length); +} + +describe('sourceOffset', () => { + test('plain text maps one to one', () => { + expect(sourceOffset('hello world', 'hello world', 5)).toBe(5); + }); + + test('skips bold markers before the click', () => { + const source = '**suggestion:** thanks for the run'; + const rendered = 'suggestion: thanks for the run'; + expect(source.slice(0, caretAfter(source, rendered, 'thanks'))).toBe( + '**suggestion:** thanks' + ); + }); + + test('a click inside bold text lands inside the markers', () => { + const source = '**suggestion:** thanks'; + const rendered = 'suggestion: thanks'; + expect(source.slice(0, caretAfter(source, rendered, 'sugg'))).toBe( + '**sugg' + ); + }); + + test('skips backticks around inline code', () => { + const source = 'the `--changed` flag and `src/jobs/**` too'; + const rendered = 'the --changed flag and src/jobs/** too'; + expect(source.slice(0, caretAfter(source, rendered, 'flag and src/'))).toBe( + 'the `--changed` flag and `src/' + ); + }); + + test('a link maps to its text, not its url', () => { + const source = 'see [the docs](https://example.test/a.b) for more'; + const rendered = 'see the docs for more'; + expect(source.slice(0, caretAfter(source, rendered, 'for'))).toBe( + 'see [the docs](https://example.test/a.b) for' + ); + }); + + test('paragraph breaks collapse without drifting', () => { + const source = 'first line\n\nsecond line'; + const rendered = 'first line\nsecond line'; + expect(source.slice(0, caretAfter(source, rendered, 'second'))).toBe( + 'first line\n\nsecond' + ); + }); + + test('a click at the start stays at the start', () => { + expect(sourceOffset('**bold**', 'bold', 0)).toBe(0); + }); + + test('a click past the end lands at the end of the source', () => { + expect(sourceOffset('**bold**', 'bold', 99)).toBe(8); + }); + + test('rendered text the source cannot match lands at the end', () => { + expect(sourceOffset('abc', 'xyz', 2)).toBe(3); + }); +}); diff --git a/apps/board/src/client/board/caret-map.ts b/apps/board/src/client/board/caret-map.ts new file mode 100644 index 0000000000..4dc194165d --- /dev/null +++ b/apps/board/src/client/board/caret-map.ts @@ -0,0 +1,33 @@ +/** A link's destination never renders as text, so a rendered character + must not match inside one. */ +function linkDestinations(source: string): boolean[] { + const hidden = new Array(source.length).fill(false); + for (const m of source.matchAll(/\]\(([^)]*)\)/g)) + for (let i = m.index; i < m.index + m[0].length; i++) hidden[i] = true; + return hidden; +} + +/** Where a caret at `offset` in `rendered` (the Markdown as it reads) sits + in `source`: the rendered characters before it, whitespace aside, are + matched in order through the source, so markers the render dropped + (`**`, backticks, link syntax) fall behind the caret. A click past the + rendered text, or text the source cannot match, lands at the end. */ +export function sourceOffset( + source: string, + rendered: string, + offset: number +): number { + if (offset >= rendered.length) return source.length; + const hidden = linkDestinations(source); + const before = rendered.slice(0, Math.max(0, offset)); + let at = 0; + for (const ch of before) { + if (/\s/.test(ch)) continue; + while (at < source.length && (hidden[at] || source[at] !== ch)) at++; + if (at >= source.length) return source.length; + at++; + } + const trailing = before.length - before.trimEnd().length; + for (let n = 0; n < trailing && /\s/.test(source[at] ?? ''); n++) at++; + return at; +} From ce53416db2de03b07c3022c83109354bb1b3bae6 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Wed, 28 Oct 2026 08:35:23 -0500 Subject: [PATCH 02/88] board: click-to-edit comment field, first pass in Storybook Reads rendered, opens raw Markdown with the caret at the click, no Write/Preview toggle. Also drops the summary card's double top line. Co-Authored-By: Claude Opus 5.5 --- .../client/board/ClickToEditField.stories.tsx | 272 ++++++++++++++++++ .../src/client/board/ClickToEditField.tsx | 206 +++++++++++++ apps/board/src/style.css | 64 +++++ 3 files changed, 542 insertions(+) create mode 100644 apps/board/src/client/board/ClickToEditField.stories.tsx create mode 100644 apps/board/src/client/board/ClickToEditField.tsx diff --git a/apps/board/src/client/board/ClickToEditField.stories.tsx b/apps/board/src/client/board/ClickToEditField.stories.tsx new file mode 100644 index 0000000000..bc06a9c33b --- /dev/null +++ b/apps/board/src/client/board/ClickToEditField.stories.tsx @@ -0,0 +1,272 @@ +import { useState, type ReactNode } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Chip, Markdown } from '@mattstack/tui-kit'; +import { registerTheme, SoribashiProvider } from '@mattstack/tui-kit/provider'; +import { tuiTheme } from '@mattstack/tui-kit/theme'; + +import '@mattstack/tui-kit/theme.css'; +import '@mattstack/tui-kit/canvas.css'; +import '../../style.css'; + +import { ClickToEditField } from './ClickToEditField.tsx'; +import { AgentGlyph, CommentGlyph } from './icons.tsx'; + +/** + * First build pass of the click-to-edit comment (board.pen B26 and B27): + * every comment reads rendered, a click on its text opens the raw Markdown + * with the caret at the click, and there is no Write/Preview toggle. The + * summary card stacks bare fields so it reads as the one comment the forge + * shows; a comment from a finding edits in the summary or in its own card, + * both on the same text. Invented content only (the repo is public). + */ +function Stage({ + scheme, + children, +}: { + scheme: 'light' | 'dark'; + children: ReactNode; +}) { + return ( +
+ +
{children}
+
+
+ ); +} + +const meta = { + title: 'Gates/Board/Click to edit', + decorators: [ + (Story, context) => ( + + + + ), + ], + parameters: { layout: 'fullscreen' }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +registerTheme(tuiTheme); + +const FINDINGS = [ + { + id: 'f1', + title: 'No run for the --changed flag', + body: 'The AC for `--changed` on files under `src/jobs/**` has no run in the evidence.', + comment: + '**suggestion:** thanks for the extra evidence, that covers it nicely. the one AC without a run is `--changed` picking up a file under `src/jobs/**`... would you mind grabbing one quick run of that?', + }, + { + id: 'f2', + title: 'Stale comments and a stale stat name', + body: 'Comments in `scan-jobs.ts` still describe the old single folder, and `stats.reachableInJobs` now counts both.', + comment: + "**nitpick:** a few comments in `scan-jobs.ts` still describe the old single folder: `JobRow.path` (:88) says it's relative to `src/jobs/`. `stats.reachableInJobs` (:140) counts both folders now too, so maybe rename it?", + }, + { + id: 'f3', + title: 'README is out of step with the script', + body: 'The README still names the old folder, and nothing mentions the path change.', + comment: + '**suggestion:** the `README.md` still says the scanner reads `src/jobs` (:42). anyone with a local overlay keyed on the old paths will get `merged 0/N` with no hint why. could we add a line about both? see [the overlay docs](https://example.test/docs/overlay).', + }, +]; + +function SummaryAndFindings() { + const [summary, setSummary] = useState( + 'Left a few comments, the `retryPolicy` one is the main one.' + ); + const [texts, setTexts] = useState>( + Object.fromEntries(FINDINGS.map(f => [f.id, f.comment])) + ); + const [ticked, setTicked] = useState(new Set(FINDINGS.map(f => f.id))); + const [editing, setEditing] = useState(null); + const setText = (id: string) => (t: string) => + setTexts(prev => ({ ...prev, [id]: t })); + const track = (key: string) => (on: boolean) => + setEditing(prev => (on ? key : prev === key ? null : prev)); + const folded = FINDINGS.filter(f => ticked.has(f.id)); + return ( + <> +
+ Summary comment + + + for comment + + posts on !1182 + +
+
+
+
+ + {folded.map(f => ( + + ))} +
+ {editing?.startsWith('summary') && ( + Markdown supported + )} +
+
+ +
+ Findings + + {ticked.size} of {FINDINGS.length} selected + +
+
+ {FINDINGS.map(f => { + const on = ticked.has(f.id); + return ( +
+ + {on && ( + <> +
+
+ + + Agent Finding + +
+
+ + {f.body} + +
+
+
+
+ + + Comment + + + {editing === `summary:${f.id}` + ? 'editing in summary' + : 'posts in summary'} + +
+ +
+ + )} +
+ ); + })} +
+ + ); +} + +export const SummaryWithFindings: Story = { + name: 'Summary and finding cards', + render: () => , +}; + +function OneField() { + const [text, setText] = useState( + [ + '**question:** does the retry here back off, or hammer the queue?', + '', + '- `retryPolicy.max` is 5', + '- the delay looks fixed at 200ms', + '', + '```ts', + 'await retry(job, { max: 5, delayMs: 200 });', + '```', + '', + 'see [the retry notes](https://example.test/retry) for the old numbers.', + ].join('\n') + ); + return ; +} + +export const SingleField: Story = { + name: 'One boxed field (lists, code, a link)', + render: () => , +}; + +function EmptyField() { + const [text, setText] = useState(''); + return ( + + ); +} + +export const Empty: Story = { + name: 'An empty comment', + render: () => , +}; diff --git a/apps/board/src/client/board/ClickToEditField.tsx b/apps/board/src/client/board/ClickToEditField.tsx new file mode 100644 index 0000000000..63deaa3fca --- /dev/null +++ b/apps/board/src/client/board/ClickToEditField.tsx @@ -0,0 +1,206 @@ +import { + useLayoutEffect, + useRef, + useState, + type KeyboardEvent, + type MouseEvent, +} from 'react'; + +import { sourceOffset } from './caret-map.ts'; +import { CommentMarkdown } from './CommentMarkdown.tsx'; + +type CaretDocument = Document & { + caretPositionFromPoint?: ( + x: number, + y: number + ) => { offsetNode: Node; offset: number } | null; + caretRangeFromPoint?: (x: number, y: number) => Range | null; +}; + +function caretAtPoint(x: number, y: number) { + const doc = document as CaretDocument; + if (doc.caretPositionFromPoint) { + const p = doc.caretPositionFromPoint(x, y); + return p && { node: p.offsetNode, offset: p.offset }; + } + const r = doc.caretRangeFromPoint?.(x, y); + return r ? { node: r.startContainer, offset: r.startOffset } : null; +} + +/** The click's offset in the rendered text of `root`, or its end when the + click missed the text. */ +function renderedOffset(root: HTMLElement, x: number, y: number): number { + const all = root.textContent ?? ''; + const hit = caretAtPoint(x, y); + if (!hit || hit.node.nodeType !== Node.TEXT_NODE || !root.contains(hit.node)) + return all.length; + const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let total = 0; + for (let n = walk.nextNode(); n; n = walk.nextNode()) { + if (n === hit.node) return total + hit.offset; + total += n.textContent?.length ?? 0; + } + return all.length; +} + +function grow(el: HTMLTextAreaElement) { + el.style.height = 'auto'; + el.style.height = `${el.scrollHeight + el.offsetHeight - el.clientHeight}px`; +} + +/** A comment that reads rendered and edits raw. A click on the text opens + the Markdown with the caret where you clicked; Tab onto it opens it with + the caret at the end. Leaving the field renders it again; Escape does + too and keeps focus on it, so a second Escape reaches the sheet. A click + on a link follows the link, and a drag that selects text stays put. */ +export function ClickToEditField({ + label, + text, + variant = 'boxed', + stripe, + hint = true, + readOnly = false, + invalid = false, + describedBy, + onChange, + onEditing, +}: { + label: string; + text: string; + variant?: 'boxed' | 'bare'; + stripe?: 'always' | 'focus'; + hint?: boolean; + readOnly?: boolean; + invalid?: boolean; + describedBy?: string; + onChange: (text: string) => void; + onEditing?: (editing: boolean) => void; +}) { + const [editing, setEditing] = useState(false); + const area = useRef(null); + const view = useRef(null); + const caret = useRef(null); + const pressed = useRef(false); + const refocus = useRef(false); + + const open = (at: number) => { + caret.current = at; + setEditing(true); + onEditing?.(true); + }; + const close = (keepFocus: boolean) => { + refocus.current = keepFocus; + setEditing(false); + onEditing?.(false); + }; + + useLayoutEffect(() => { + if (editing) { + const el = area.current; + if (!el) return; + grow(el); + if (caret.current !== null) { + el.focus({ preventScroll: true }); + el.setSelectionRange(caret.current, caret.current); + caret.current = null; + } + } else if (refocus.current) { + view.current?.focus({ preventScroll: true }); + } + }, [editing, text]); + + const onViewClick = (e: MouseEvent) => { + pressed.current = false; + if (readOnly) return; + if ((e.target as HTMLElement).closest('a')) return; + const picked = window.getSelection(); + if ( + picked && + !picked.isCollapsed && + e.currentTarget.contains(picked.anchorNode) + ) + return; + const at = renderedOffset(e.currentTarget, e.clientX, e.clientY); + open(sourceOffset(text, e.currentTarget.textContent ?? '', at)); + }; + + const onViewFocus = () => { + if (refocus.current) { + refocus.current = false; + return; + } + if (pressed.current || readOnly) return; + open(text.length); + }; + + const onAreaKeyDown = (e: KeyboardEvent) => { + const el = e.currentTarget; + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + close(true); + return; + } + if (e.key !== 'Tab' || e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) + return; + e.preventDefault(); + e.stopPropagation(); + if (document.execCommand?.('insertText', false, ' ')) return; + const { selectionStart: start, selectionEnd: end, value } = el; + caret.current = start + 2; + onChange(`${value.slice(0, start)} ${value.slice(end)}`); + }; + + return ( + <> +
+ {editing ? ( +