diff --git a/apps/board/package.json b/apps/board/package.json index 41e3dfdabc..c8c7a78508 100644 --- a/apps/board/package.json +++ b/apps/board/package.json @@ -27,6 +27,7 @@ "@mattstack/tui-kit": "workspace:*", "@pierre/diffs": "catalog:", "invadrs": "^1.3.1", + "motion": "14.2.0", "pngjs": "^7.0.0", "react": "catalog:", "react-dom": "catalog:", 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..ace80a57ce --- /dev/null +++ b/apps/board/src/client/board/ClickToEditField.stories.tsx @@ -0,0 +1,323 @@ +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, CodeGlyph, CommentGlyph, FileGlyph } 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); + +interface StoryFinding { + id: string; + title: string; + body: string; + comment: string; + file?: { path: string; line: number }; +} + +const FINDINGS: StoryFinding[] = [ + { + id: 'd1', + title: 'retryPolicy retries a job that already failed for good', + body: '`retryPolicy` retries every rejection, including `PermanentJobError`, so a job that can never succeed burns all five attempts.', + comment: + "**issue:** this retries `PermanentJobError` too, so a job that can't succeed eats all five attempts before it fails. could we skip retries for that one?", + file: { path: 'src/jobs/retry-policy.ts', line: 34 }, + }, + { + id: 'd2', + title: 'The delay never backs off', + body: 'The delay is fixed at `200ms`; the spec asks for exponential backoff capped at 5s.', + comment: + '**suggestion:** the spec wants exponential backoff capped at 5s, and this is a flat `200ms`. something like `Math.min(200 * 2 ** attempt, 5000)` would cover it.', + file: { path: 'src/jobs/retry-policy.ts', line: 41 }, + }, + { + 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 => !f.file && 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 && ( + <> + {f.file && ( +
+
+ + + The Code + +
+
+
+ + + {f.file.path} + + :{f.file.line} + + +
+
+
+ )} +
+
+ + + Agent Finding + +
+
+ + {f.body} + +
+
+
+
+ + + Comment + + + {f.file + ? 'drafted by the agent' + : 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..6680655e47 --- /dev/null +++ b/apps/board/src/client/board/ClickToEditField.tsx @@ -0,0 +1,351 @@ +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type KeyboardEvent, + type MouseEvent, + type ReactNode, +} from 'react'; +import { + AnimatePresence, + domAnimation, + LazyMotion, + m, + MotionConfig, +} from 'motion/react'; + +import { sourceOffset } from './caret-map.ts'; +import { CommentMarkdown } from './CommentMarkdown.tsx'; +import { CheckGlyph, PencilLineIcon } from './icons.tsx'; + +const FLASH_MS = 1600; +const FADE_MS = 150; + +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; +} + +const SWAP = { duration: 0.15, ease: [0.4, 0, 0.2, 1] as const }; +const STILL = { duration: 0 }; + +/** Each face fades over the other; the leaving one is popped out of flow + and stops taking clicks, so the box sizes to the face arriving. */ +const FADE = { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0, pointerEvents: 'none' as const }, + transition: SWAP, +}; + +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, + heading, + actions, + badge, +}: { + label: string; + text: string; + variant?: 'boxed' | 'bare' | 'filled' | 'plain'; + stripe?: 'always' | 'focus'; + hint?: boolean; + readOnly?: boolean; + invalid?: boolean; + describedBy?: string; + onChange: (text: string) => void; + onEditing?: (editing: boolean) => void; + /** Names the mode while writing, e.g. "Editing comment". */ + heading?: string; + /** Controls on the header's right while writing, such as a reset. */ + actions?: ReactNode; + /** At rest, a pill on the field's top-left border, such as "Edited". */ + badge?: ReactNode; +}) { + 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 faces = useRef(null); + const [height, setHeight] = useState('auto'); + // The box eases across a swap and at rest (a reset changing the text); + // a line typed in Write grows it at once, so the caret's line is never + // clipped mid-ease. + const [swapping, setSwapping] = useState(false); + // Closing on a changed text flashes "Comment updated" in the pill, then + // fades it; the label holds through the fade so it never swaps mid-ease. + const openedWith = useRef(text); + const [flash, setFlash] = useState<'shown' | 'fading' | null>(null); + useEffect(() => { + if (flash === null) return; + const id = window.setTimeout( + () => setFlash(flash === 'shown' ? 'fading' : null), + flash === 'shown' ? FLASH_MS : FADE_MS + ); + return () => window.clearTimeout(id); + }, [flash]); + + useLayoutEffect(() => { + const el = faces.current; + if (!el || typeof ResizeObserver === 'undefined') return; + setHeight(el.offsetHeight); + const observer = new ResizeObserver(() => setHeight(el.offsetHeight)); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const open = (at: number) => { + openedWith.current = text; + setFlash(null); + caret.current = at; + setSwapping(true); + setEditing(true); + onEditing?.(true); + }; + const close = (keepFocus: boolean) => { + refocus.current = keepFocus; + if (text !== openedWith.current) setFlash('shown'); + setSwapping(true); + 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)}`); + }; + + const showHeading = editing && !!heading; + return ( + + +
+ + {editing && (showHeading || actions) && ( + +
+ + {showHeading && ( + <> + + {heading} + + )} + + {actions} +
+
+ )} +
+
+ {!editing && badge && ( + {badge} + )} + setSwapping(false)} + > +
+ + {editing ? ( + +