AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, ChevronDown, ChevronRight, GitBranch, MessageSquare, RotateCcw } from "lucide-react"

import { cn } from "@/lib/utils"
import type { ConversationTreeNode, ConversationTreeRole, ConversationTreeStatus } from "./conversation-tree.contract"

/* -------------------------------------------------------------------------- */
/* Layout — the whole point of this component                                  */
/* -------------------------------------------------------------------------- */

/**
 * One nested message, carrying everything its row needs to draw itself.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/conversation-tree.json

Prompt

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-expanded on the row already carries that state, and a button inside a treeitem would announce it twice.

On This Page