Merge graph card
NewAn agent’s pull request card that draws its commit graph as it works, runs into conflicts, resolves them and merges.
merge-graph-card/merge-graph-card.tsx- middleware.ts+214−21
- auth.ts+161−12
- session.test.ts+98−0
- session.ts+9−4
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- An agent that opens a pull request for the person and has to show where it stands: open, conflicting, green, merged.
- Branch work where the shape of the history matters: what the branch adds, what moved on the base, where they meet.
- A turn that needs the person to settle one thing (resolve the conflicts, then merge) with a single button on the card.
- Chat or side-panel agents in a code tool, where the card sits in the thread above the composer.
When not to use
- A list of many pull requests to triage. Use a table with status columns, one row each.
- A full review: diffs line by line and comments. Link to the code host or a diff viewer instead.
- Histories with more than three lanes or dozens of commits: the row-by-row graph is drawn for a short branch.
- Telling the person a merge already happened somewhere else. A one-line message with a link says it.
You can add this component with the shadcn CLI or by hand:
Into components/merge-graph-card of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/merge-graph-card.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { MergeGraphCard } from './merge-graph-card';export function ReviewTurn() { return ( <MergeGraphCard title="Refresh sessions in the auth middleware" number={128} branch="feat/session-refresh" defaultStage="conflicts" autoplay={false} onMerge={() => console.log('merged')} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A chat turn answering “Create PR”. The card is a commit graph of the branch: lanes of dots joined by curves, a row per commit with its message, a tinted branch pill, its short hash and who made it when. Rows decode into place as the agent commits, pushes, opens the pull request and fetches main, where a colleague’s new commits conflict. Resolve them and the base merges in conflict by conflict, the tests run and the card turns green; merge it and the branch turns purple.
- The whole turn is one model, and the agent’s work is a script of small patches to it, each after a pause: commit, push, open, fetch, check. Resolve and Merge play two more scripts. The rows and edges of the graph are worked out from the model, so any stage draws the same whether it is reached step by step or at once (defaultStage).
- The graph is drawn row by row: every edge is one path in the coordinates of the whole list, and each row has an SVG whose viewBox is its own 30 px band of it. A row that arrives later opens up between two others with the lanes running through it; nothing is laid over the list.
- Edges change lanes over at most two rows, next to the newer commit (a merge’s second parent) or the older one (where a branch leaves the base), with vertical tangents so they land on the dots. The link from the pending merge to the branch is dashed in the colour of its state, and draws itself in solid as it merges.
- New rows decode: the line grows from random letters to its length while the real words take over from the left, written straight to the text from a frame loop. Work in progress shimmers (a moving gradient clipped to the words), the conflict node pulses, and the answer streams in word by word with a short blur.
- Once merged, the branch’s dots, lines and pill turn purple row after row, with a tint sweeping down its commits; the checks fold away (a grid row from 1fr to 0fr).
- The script’s clock only runs while the card is on screen and the tab is visible. While untouched, a demo pointer plays the reader’s part, hovers the conflicts and the checks, resolves, merges, and starts over.
- Every colour is a light-dark() token on the root, so the card follows the page’s color-scheme, or the colorScheme prop.
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | 'Refresh sessions in the auth middleware' | The pull request’s title, on the card. |
number | number | 128 | Its number. |
branch | string | 'feat/session-refresh' | The branch the agent works on. |
base | string | 'main' | The branch it merges into. |
sideBranch | string | 'feat/checkout-analytics' | A colleague’s branch off the base, drawn in its own lane. |
repository | string | 'harbor-web' | The folder chip in the composer. |
defaultStage | 'idle' | 'conflicts' | 'ready' | 'merged' | 'idle' | Where the turn starts, already drawn: before Create PR, at the conflicts, green, or merged. |
openLabel | string | 'Open PR' | The button of a merged card. |
prHref | string | — | Where that button leads; without it, it is a plain button. |
onMerge | () => void | — | Called once the pull request is merged. |
autoplay | boolean | true | While on screen and untouched, a demo pointer plays the reader’s part: creates, resolves, reads the checks, merges, and starts over. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { Fragment, useCallback, useEffect, useEffectEvent, useId, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent,} from 'react';import { DemoPointer, runDemo, type Point } from './demo-pointer';import { pathsOf, Slice } from './graph';import { Bars, Icon, Spinner } from './icons';import { apply, CREATE, graphOf, INITIAL,/* Merge graph card (merge-graph-card.tsx): a chat turn on a near-white stage, or at night a near-black one. A 640 px column holds, bottom up, the composer, the status chips, and the thread: the request, the disclosure, the card and the answer. The thread grows upwards from the composer, as a chat does. Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). Motion. Rows that arrive later open up (their height, 30 px, once); everything else moves by opacity and transforms: entrances, the spinners, the halo of a node, the shimmer of the words in progress (a moving background clipped to the text). */.root { /* The stage, and the surfaces on it. */ --surface: #fdfdfd; --raised: #fff; --fill: #f3f3f3; --fill-2: #ececec; --edge: #ebebeb; --edge-2: #e3e3e3; /* Words: the strongest, the meta, the quiet ones (others' commits), the faint (hashes). */ --ink: #1a1a1a; --ink-2: #6b6b6b;// Copyright (c) 2026 Bruno Delorence. MIT License./* * What the card shows, as plain data. The whole turn is one Model; the agent's work is a script of * small patches to it, each after a pause (CREATE, RESOLVE, MERGE). The rows of the commit graph, * and the edges between them, are worked out from the Model by `graphOf`: nothing about the graph * is stored, so every state draws the same way whether it is reached step by step or at once. */export type Stage = 'idle' | 'sent' | 'working' | 'conflicts' | 'resolving' | 'ready' | 'merging' | 'merged';/** The stages a card may start at, already drawn. */export type StartStage = 'idle' | 'conflicts' | 'ready' | 'merged';/** The status chip over the composer. */export type Chip = 'create' | 'creating' | 'conflicts' | 'resolving' | 'ready' | 'merging' | 'merged';/** The card's last line: what the agent is doing, or what is left to do, and its button. */export type Footer = { kind: 'busy' | 'conflicts' | 'ready' | 'merged'; text: string; action?: 'resolve' | 'resolving' | 'merge' | 'merging' | 'open';// Copyright (c) 2026 Bruno Delorence. MIT License.import type { Edge, Lane, Node, Row } from './model';import styles from './merge-graph-card.module.css';/* * The commit graph, drawn row by row. Every edge is one path in the coordinates of the whole list * (row k's middle is at k × PITCH + PITCH / 2), and each row has an SVG of its own whose viewBox is * its band of that list: it shows the pieces of every edge that cross it, and its own node. So a * row that arrives later opens up between two others with the lanes running through it, and the * rows never need one drawing laid over them all. *//** A row's height, in px. */export const PITCH = 30;/** The graph's width, and where its three lanes run. */export const GRAPH = 61;const LANES = [9.5, 26.5, 43.5];const x = (lane: Lane) => LANES[lane];const y = (index: number) => index * PITCH + PITCH / 2;/** One edge, as a path in the list's coordinates. */function pathOf(edge: Edge, rows: Row[], index: Map<string, number>): string | null {// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useLayoutEffect, useRef } from 'react';import styles from './merge-graph-card.module.css';/* * Words that decode into place, the way the agent's new rows arrive: the line grows from a few * random letters to its full length while the real text takes over from the left. It writes the * span's text straight from a frame loop (no React render per frame). Screen readers get the real * words from a hidden copy; the moving one is aria-hidden. Under reduced motion it never runs. */const LETTERS = 'abcdefghijklmnopqrstuvwxyz0123456789';/** How often the random letters change, in ms. */const FLICKER = 45;const noise = (length: number, from: string) => Array.from({ length }, (_, i) => from[i] === ' ' ? ' ' : LETTERS[Math.floor(Math.random() * LETTERS.length)], ).join('');export function Scramble({// Copyright (c) 2026 Bruno Delorence. MIT License.import type { ReactNode } from 'react';import styles from './merge-graph-card.module.css';/* * The card's icons, drawn on a 16-unit grid with round strokes in currentColor. They are * decorative: every control that shows one is named in words. The stylesheet sizes them. */const props = { viewBox: '0 0 16 16', 'aria-hidden': true, focusable: false, fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round',} as const;export type IconName = | 'chevron-right' | 'chevron-down''use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends * the step in progress at once: its promise rejects, which ends the script right there, and the * pointer fades out. * * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame. * The points of the flight are handed to whoever wants to move along with it. The arrow and the * hand are HTML boxes that swap by their visibility: a change of style inside an SVG would lay * the SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = { /** Resolves after `ms`. */ wait: (ms: number) => Promise<void>;'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- The card is a section named by its title; the commits are an ordered list, each row read as its message, branch, hash and author. The graph is decorative (aria-hidden), and the decoding words have a hidden copy of the real text.
- Create PR, Resolve conflicts, Merge and Open PR are real buttons; the work under way shows as a disabled button. Where the pull request stands is announced in a polite live region.
- The conflicts link is a button (aria-expanded) that opens the conflicting lines on hover, focus or click; the checks are buttons whose tooltips are their descriptions. Escape closes what is open.
- The disclosure over the card is a button that lists the steps the agent took; the diff stat lists the changed files.
- Under reduced motion nothing decodes, spins, pulses or streams, and the demo never plays.