Conversation Tree
The branching map of a whole chat — a flat parentId list nested into a vertical tree, the current path lit as a continuous spine, abandoned forks folded into counted stubs, WAI-ARIA tree keyboard navigation, and four data states.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/conversation-tree.jsonPrompt
Build a React + TypeScript + Tailwind "ConversationTree" component with zod and
lucide-react. Draw the tree with plain SVG per row — no graph library, no canvas,
no layout engine.
Contract
- A zod schema (`conversationTreeNodeSchema` in a sibling contract file) is the
single source of truth for ONE message: { id, parentId?: string|null, role:
"user"|"assistant"|"system"|"tool", excerpt, onPath?: boolean, label?: string,
createdAt?: string }.
- The input is a FLAT edge list, never a nested `children[]` tree. A chat backend
hands you rows keyed by parent_message_id; asking the caller to nest them first
would push the hard part — orphans, cycles, ordering — into every consumer.
- `onPath` is data, not internal state. Which branch a reader sits on lives in
their store or in the URL, it has to survive a reload, and a "regenerate"
mutation moves it. The component never invents it.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
map's own render state, and `ready` with zero nodes falls through to the empty
body rather than drawing a lone border.
- Props: nodes; status; onNavigate?(id, node); density? ("comfortable" |
"compact"); excerptLines?; indent?; rowHeight?; expandedIds? /
defaultExpandedIds? / onExpandedChange?; offPathBranches? ("collapse" |
"expand", default "collapse"); showTimestamps?; showSummary?; locale?
("en-US"); timeZone? ("UTC"); labels?; errorMessage?; onRetry?; emptyState?;
skeletonRows?; label? ("Conversation map"); className plus the rest of the div
props, ref forwarded to the root.
- Every numeric prop is clamped (excerptLines >= 1, indent >= 8) and the row
height can only ever be RAISED to fit the lines it was told to show, so a bad
number produces a roomy row instead of clipped text.
- Export two pure functions next to the component:
`buildConversationTreeLayout(nodes)` (the whole nesting/ordering algorithm,
testable without rendering) and `markActivePath(nodes, targetId, { prefer })`.
Behavior
- NESTING IS DEFENSIVE. Every malformed variant of real chat data has a defined
outcome and nothing is ever dropped: a duplicate id keeps the first row (two
rows sharing an id would share a React key and a parent slot); a parentId that
is not in the array — a paged window, a trimmed head — promotes the node to a
root and flags it; a self-parent does the same; a parent chain that re-enters
itself gets the closing link CUT so the tree stays a tree; several roots all
render. Cycle detection is one O(n) pass with a "settled" set, so a 5,000-node
transcript costs one walk, not n walks.
- The pre-order walk is ITERATIVE, with an explicit stack. A 2,000-turn support
thread is a 2,000-deep chain and recursion would blow the call stack on data
the user has no idea is unusual. Sibling order is the caller's chronology; the
component never re-sorts messages.
- Subtree totals (descendantCount, containsPath) come from ONE reverse pass over
the pre-order array: a parent always precedes its descendants, so walking
backwards means every child has already reported in.
- EXPANSION IS POLICY + OVERRIDES, never a snapshot of ids. The policy is "open
whatever contains a path node" — so the spine is visible and every branch you
left is a closed, counted stub. Only rows the reader actually toggled carry an
override. A snapshot taken on mount would go stale the moment the agent appends
a reply: the new branch would be missing from the set and would render
collapsed forever, and re-running the policy would need an effect on every data
change. When `expandedIds` is passed the component is controlled and the policy
is ignored; `onExpandedChange` fires in both modes.
- When NO node is marked `onPath`, the policy expands everything: there is no
current path to privilege, and collapsing to a single root would hide the whole
conversation behind a stub.
- The map is a real WAI-ARIA tree: role="tree" plus flat role="treeitem" rows
carrying aria-level / aria-setsize / aria-posinset, aria-expanded on rows that
have children, aria-selected on the tip of the path, aria-current on every path
row, and a ROVING TABINDEX so the whole map is one tab stop. Keys: Up/Down move
through visible rows, Home/End jump to the ends, Right opens a closed row and
descends into an open one, Left closes an open row and otherwise climbs to the
parent, Enter/Space activate, and `*` opens every fork at the current level at
once. Focus after an expansion is applied in an effect, so a row revealed by
the same keystroke that opened its parent is already in the DOM.
- The disclosure pill is aria-hidden on purpose: the row already carries
aria-expanded and Right/Left are the keyboard path, so a real button inside a
treeitem would announce the same state twice and add a second tab stop. Its
count is instead put into the row's accessible name ("7 messages hidden in this
branch") — the number is the reason the stub exists.
- Clicking the pill stops propagation: opening a branch and jumping into it are
two different intentions. Clicking anywhere else on the row fires
onNavigate(id, node) AND moves the roving tab stop there. Omit onNavigate and
rows lose the pointer cursor and the handler entirely — no fake affordance —
while the tree stays keyboard-navigable for reading.
- Batch every expansion write. `*` opens N siblings, and N separate setState
calls would each start from the same stale object so only the last would
survive; one merged write fixes it and is also what the controlled mode emits.
- Focus is DERIVED, never repaired by an effect: if the focused row is collapsed
away or removed by new data, the tab stop falls back to the tip of the current
path, then to the deepest path row, then to the first row.
- The component owns no clock and no timers. Timestamps are formatted with a
fixed locale and time zone (defaults "en-US"/UTC) so the server and the browser
print the same string, Intl construction is wrapped in try/catch because a
typo'd IANA zone throws, and an unparseable instant prints verbatim instead of
becoming "Invalid Date".
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
var(--primary) for the lit spine and the tip halo, var(--border) for idle
lines, bg-primary/10 for the tip row, text-destructive for the cycle note and
the error branch, and var(--chart-1..4) for the four roles, each mixed towards
var(--foreground) with color-mix: the chart ramp is tuned for chart areas, where
~3.6:1 on a card is enough, and a 4px dot — drawn at 0.55 opacity when it is off
the path — needs the headroom. No hard-coded colours anywhere.
- FOUR ROLES ARE FOUR SHAPES, not four tints: filled circle (user), hollow circle
(assistant), square (system), diamond (tool) — plus an sr-only role word. Shape
is the channel that survives greyscale, a colour-blind reader and a 4px radius.
- Each row draws its own gutter as one <svg> with an explicit width and height
(an <svg> without them falls back to its 300x150 intrinsic size). Four kinds of
segment: ancestor verticals in the columns to the left, the elbow arriving at
this node (rounded when it is the last child, square when it is not), the tail
heading down to its next sibling, and a short descender under the dot when the
node is open. Every coordinate comes from THIS row — no row measures another,
so there is no absolute positioning and no ResizeObserver.
- The gutter arrays are built by appending one value per level
(`[...parentGuides, !isLastChild]`), which makes the whole picture a pure
function of the ancestor chain.
- The spine must be CONTINUOUS. A segment arriving from above is shared with the
parent's run down to a LATER sibling, so it stays lit when the path continues
past this row; colouring it by the current node alone would break the spine
into disconnected pieces at every fork it skips over.
- The gutter is sized from the deepest VISIBLE row, so a map with all its deep
branches folded away does not reserve a dozen empty columns. When it is still
wider than the card, the tree scrolls inside its own box — the page never gains
a horizontal scrollbar and no row is silently clipped.
- Excerpts are clamped with -webkit-line-clamp to `excerptLines` and the row
height is derived from them; the string itself is never truncated, so a screen
reader still reads the whole line. Long unbroken tokens use wrap-anywhere plus
min-w-0, not break-words: only overflow-wrap:anywhere lowers an element's
min-content width, which is what actually stops a 90-character URL from making
the card wider than its container.
- One sr-only role="status" line for the whole map states the path length and the
number of folded messages — 40 rows must not mean 40 live regions. Loading is a
skeleton with staggered indents; the pulse and every transition are
motion-reduce-guarded.
- cn() merges the consumer's className into the root, which also spreads the
remaining props and forwards its ref.
Customization levers
- Density: `density="compact"` halves the row and clamps to one line; or tune the
three knobs directly — `indent` (pixels per level), `excerptLines`, `rowHeight`
(only ever raised to fit the text).
- Default reading: `offPathBranches="expand"` turns the map from "the path plus
stubs" into a full tree — right for a small conversation or a debugging panel;
`defaultExpandedIds` pre-opens named branches on top of the policy.
- Ownership: pass `expandedIds` + `onExpandedChange` to keep the open set in your
store (URL state, a saved view). Pass `onNavigate` and re-mark the path with
`markActivePath(nodes, id, { prefer: "newest" | "oldest" })` — that helper walks
up to the root and then keeps descending, because choosing a fork means
"continue down that fork", not "stop here".
- Chrome: `showSummary` toggles the counts strip, `showTimestamps` the clock
column, `label` names the tree, `emptyState` replaces the empty body,
`errorMessage` + `onRetry` own the envelope failure, `skeletonRows` sizes the
loading state.
- Wording: every sentence lives in `labels` as a whole template with
`{placeholders}` ("{count} message{s} hidden in this branch"), merged over the
defaults. Never concatenate fragments in JSX — that is what makes a UI
untranslatable; `{s}` is the English plural suffix and a translator who doesn't
need it just writes a form without it.
- Per-node chrome: `label` is your own pill (model name, "regenerated", a branch
title) and `createdAt` drives the clock column; both hide themselves on narrow
containers via container queries instead of overflowing.Concepts
- The path is data, the tree is derived — which chain a reader sits on lives in your store or in the URL as
onPath, because it has to survive a reload and a regenerate. Everything else on screen (depth, guide columns, subtree counts, which branch is open) is recomputed from the flat node array on every render, so there is no second copy of the truth to fall out of sync. - Policy + overrides, not a snapshot — the default reading is "open whatever contains a path node, fold everything else into a counted stub", re-derived from the data each render. A set of ids captured on mount goes stale the instant the agent appends a reply; here the new branch already obeys the policy while the rows the reader personally toggled keep their choice, and "Reset view" simply drops the overrides.
- A counted stub instead of a hidden branch — a fork you did not take collapses to one row that says how many messages are behind it. The count is the information: it is the difference between "an abandoned one-liner" and "a 40-message investigation you forgot about", and it lives in the accessible name too, not only in the pill.
- The spine has to be continuous — the vertical arriving at a row is shared with the parent's run down to a later sibling, so it stays lit whenever the path continues past that row. Colouring each segment by its own node alone is the bug that makes a highlighted path look like dashes: it breaks at every fork the path skips over.
- Malformed links degrade, they never throw — a parent outside the paged window, a message that claims to be its own parent, a two-message cycle: each becomes a root with a flag, and the map says so in a note. Chat data arrives paginated and half-repaired, and a viewer that crashes on it is a viewer you cannot ship.
- One tab stop, tree keys inside — roving tabindex means the map costs a keyboard user a single Tab, and Up/Down/Left/Right/Home/End/
*do the rest, exactly as the WAI-ARIA tree pattern specifies. The disclosure pill is deliberately hidden from assistive tech:aria-expandedon the row already carries that state, and a button inside a treeitem would announce it twice.
Guardrail Trace
The safety evaluation of one request, rule by rule — a verdict header derived from the rules rather than asserted beside them, matched-span excerpts with per-rule rationale, verdict filter chips, a passing-row budget that can never hide a block, and four data states.
Agent Permissions
A capability-scoping panel for one agent — per-tool switches with risk bands, a spend cap metered against what is already spent, an add-and-remove domain allowlist, an ordered file-access scale with policy ceilings, and an unsaved-changes bar that counts every change that expands the agent's authority.