Chat Branching Tree
A branching chat transcript where every assistant turn can be regenerated into sibling versions, an inline pager flips between them, editing a user message forks a fresh branch, and a live SVG minimap renders the whole conversation as a navigable tree.
MCP
Code
:root {
--bg: #0c0d10;
--surface: #15161b;
--surface-2: #1d1f27;
--ink: #e9eaf0;
--muted: #9a9cab;
--accent: #8b5cf6;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--line: rgba(255, 255, 255, 0.1);
--line-2: rgba(255, 255, 255, 0.18);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
}
body {
min-height: 100vh;
background:
radial-gradient(1200px 600px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
radial-gradient(1000px 500px at 110% 10%, rgba(34, 211, 238, 0.1), transparent 55%),
var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
padding: clamp(12px, 3vw, 32px);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.app {
max-width: 1120px;
margin: 0 auto;
}
/* ---------- header ---------- */
.app__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand__dot {
width: 34px;
height: 34px;
border-radius: 10px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
box-shadow: 0 6px 20px rgba(139, 92, 246, 0.4);
flex: none;
}
.brand h1 {
margin: 0;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.brand__sub {
margin: 2px 0 0;
font-size: 0.82rem;
color: var(--muted);
}
.stats {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.stat {
font-size: 0.78rem;
color: var(--muted);
background: var(--surface);
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 12px;
}
.stat b {
color: var(--ink);
font-weight: 600;
}
/* ---------- layout ---------- */
.layout {
display: grid;
grid-template-columns: 1fr 320px;
gap: 16px;
align-items: start;
}
/* ---------- chat ---------- */
.chat {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
display: flex;
flex-direction: column;
min-height: 560px;
overflow: hidden;
}
.chat__scroll {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 18px;
scroll-behavior: smooth;
max-height: 62vh;
}
.chat__scroll:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
/* ---------- turn ---------- */
.turn {
display: flex;
gap: 12px;
animation: fade 0.25s ease;
}
@keyframes fade {
from {
opacity: 0;
transform: translateY(6px);
}
}
.turn__avatar {
flex: none;
width: 32px;
height: 32px;
border-radius: 9px;
display: grid;
place-items: center;
font-size: 0.72rem;
font-weight: 700;
border: 1px solid var(--line-2);
}
.turn--user .turn__avatar {
background: rgba(34, 211, 238, 0.14);
color: var(--accent-2);
}
.turn--assistant .turn__avatar {
background: rgba(139, 92, 246, 0.16);
color: var(--accent);
}
.turn__body {
flex: 1;
min-width: 0;
}
.turn__meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
}
.turn__role {
font-size: 0.78rem;
font-weight: 600;
}
.turn__tag {
font-size: 0.66rem;
color: var(--warn);
background: rgba(251, 191, 36, 0.12);
border: 1px solid rgba(251, 191, 36, 0.28);
border-radius: 999px;
padding: 1px 7px;
letter-spacing: 0.02em;
}
.bubble {
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 12px 14px;
font-size: 0.9rem;
color: var(--ink);
}
.turn--user .bubble {
background: linear-gradient(180deg, rgba(34, 211, 238, 0.08), rgba(34, 211, 238, 0.03));
border-color: rgba(34, 211, 238, 0.22);
}
.bubble p {
margin: 0 0 10px;
}
.bubble p:last-child {
margin-bottom: 0;
}
.bubble ul {
margin: 0 0 10px;
padding-left: 18px;
}
.bubble li {
margin-bottom: 4px;
}
.bubble code {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.82em;
background: rgba(255, 255, 255, 0.07);
border: 1px solid var(--line);
border-radius: 6px;
padding: 1px 5px;
}
.bubble pre {
margin: 0 0 10px;
background: #0e0f13;
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 11px 13px;
overflow-x: auto;
}
.bubble pre code {
background: none;
border: 0;
padding: 0;
font-size: 0.8rem;
line-height: 1.55;
color: #d7d9e3;
}
/* ---------- turn actions ---------- */
.turn__actions {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
flex-wrap: wrap;
}
.pager {
display: inline-flex;
align-items: center;
gap: 2px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 999px;
padding: 2px;
}
.pager button {
appearance: none;
border: 0;
background: none;
color: var(--muted);
width: 22px;
height: 22px;
border-radius: 999px;
cursor: pointer;
font-size: 0.8rem;
line-height: 1;
display: grid;
place-items: center;
transition: color 0.15s, background 0.15s;
}
.pager button:hover:not(:disabled) {
color: var(--ink);
background: rgba(255, 255, 255, 0.06);
}
.pager button:disabled {
opacity: 0.35;
cursor: default;
}
.pager__count {
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
color: var(--muted);
padding: 0 4px;
min-width: 30px;
text-align: center;
}
.iconbtn {
appearance: none;
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--muted);
font-family: inherit;
font-size: 0.74rem;
font-weight: 500;
padding: 4px 9px;
border-radius: 999px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.iconbtn:hover {
color: var(--ink);
border-color: var(--line-2);
background: rgba(255, 255, 255, 0.05);
}
.iconbtn svg {
width: 13px;
height: 13px;
}
.iconbtn--regen:hover {
color: var(--accent);
border-color: rgba(139, 92, 246, 0.4);
}
/* ---------- inline editor ---------- */
.editor {
display: flex;
flex-direction: column;
gap: 8px;
}
.editor textarea {
width: 100%;
resize: vertical;
min-height: 56px;
font-family: inherit;
font-size: 0.9rem;
color: var(--ink);
background: #0e0f13;
border: 1px solid var(--accent);
border-radius: var(--r-md);
padding: 10px 12px;
line-height: 1.5;
}
.editor textarea:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.editor__row {
display: flex;
gap: 8px;
align-items: center;
}
.editor__hint {
font-size: 0.72rem;
color: var(--muted);
margin-left: auto;
}
/* ---------- buttons ---------- */
.btn {
appearance: none;
font-family: inherit;
font-weight: 600;
font-size: 0.82rem;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: var(--surface-2);
color: var(--ink);
padding: 8px 14px;
cursor: pointer;
transition: transform 0.12s, background 0.15s, border-color 0.15s, opacity 0.15s;
display: inline-flex;
align-items: center;
gap: 7px;
}
.btn:hover {
border-color: var(--line-2);
background: rgba(255, 255, 255, 0.06);
}
.btn:active {
transform: translateY(1px);
}
.btn--primary {
background: linear-gradient(135deg, var(--accent), #7c4ef0);
border-color: transparent;
color: #fff;
}
.btn--primary:hover {
background: linear-gradient(135deg, #9a6cff, var(--accent));
}
.btn--ghost {
background: none;
border-color: var(--line);
color: var(--muted);
}
.btn--ghost:hover {
color: var(--ink);
}
.btn--sm {
padding: 5px 10px;
font-size: 0.74rem;
}
.btn kbd {
font-family: "JetBrains Mono", monospace;
font-size: 0.72rem;
background: rgba(0, 0, 0, 0.25);
border-radius: 5px;
padding: 1px 5px;
}
.btn:focus-visible,
.iconbtn:focus-visible,
.pager button:focus-visible,
.map__node:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
/* ---------- composer ---------- */
.composer {
display: flex;
gap: 10px;
padding: 14px;
border-top: 1px solid var(--line);
background: rgba(0, 0, 0, 0.15);
}
.composer__input {
flex: 1;
font-family: inherit;
font-size: 0.9rem;
color: var(--ink);
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 11px 14px;
}
.composer__input::placeholder {
color: var(--muted);
}
.composer__input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
border-color: var(--accent);
}
.btn--send {
flex: none;
}
/* ---------- minimap ---------- */
.map {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 16px;
position: sticky;
top: 16px;
}
.map__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.map__head h2 {
margin: 0;
font-size: 0.92rem;
font-weight: 600;
}
.map__canvas {
background: #0e0f13;
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 8px;
overflow: auto;
max-height: 340px;
}
.map__canvas svg {
display: block;
}
.map__edge {
fill: none;
stroke: var(--line-2);
stroke-width: 1.5;
}
.map__edge--active {
stroke: url(#activeGrad);
stroke-width: 2.5;
}
.map__node {
cursor: pointer;
transition: transform 0.12s;
}
.map__node circle {
stroke: var(--bg);
stroke-width: 2;
transition: r 0.12s, fill 0.15s;
}
.map__node:hover circle {
stroke: var(--ink);
}
.node-user {
fill: var(--accent-2);
}
.node-asst {
fill: var(--accent);
}
.node-dim {
fill: #3a3c48;
}
.map__node--current circle {
stroke: #fff;
stroke-width: 2.5;
}
.legend {
list-style: none;
margin: 12px 0 0;
padding: 0;
display: flex;
gap: 12px;
flex-wrap: wrap;
font-size: 0.72rem;
color: var(--muted);
}
.legend li {
display: flex;
align-items: center;
gap: 5px;
}
.swatch {
width: 10px;
height: 10px;
border-radius: 3px;
}
.swatch--user {
background: var(--accent-2);
}
.swatch--asst {
background: var(--accent);
}
.swatch--active {
background: linear-gradient(135deg, var(--accent), var(--accent-2));
}
.map__hint {
font-size: 0.72rem;
color: var(--muted);
margin: 10px 0 0;
}
/* ---------- responsive ---------- */
@media (max-width: 860px) {
.layout {
grid-template-columns: 1fr;
}
.map {
position: static;
}
.chat {
min-height: 480px;
}
}
@media (max-width: 520px) {
body {
padding: 12px;
}
.app__head {
align-items: flex-start;
}
.chat__scroll {
padding: 14px;
max-height: 58vh;
}
.btn--send kbd {
display: none;
}
.editor__hint {
display: none;
}
}// ---------------------------------------------------------------------------
// Chat Branching Tree
// A conversation stored as a TREE. The transcript renders only the currently
// active path. Regenerating an assistant turn appends a sibling; editing a user
// message forks a new branch; the SVG minimap visualizes and navigates it all.
// ---------------------------------------------------------------------------
(() => {
"use strict";
/* ---------------- tree model ---------------- */
let seq = 0;
const nid = () => "n" + ++seq;
// Virtual root so the very first user message can also fork into siblings.
const forest = { id: "root", children: [], active: 0, parent: null };
function makeNode(role, text, parent) {
const node = { id: nid(), role, text, parent, children: [], active: 0 };
parent.children.push(node);
parent.active = parent.children.length - 1;
return node;
}
/* ---------------- canned generation ---------------- */
// Assistant replies vary by `variant` so regenerations are visibly different.
const REPLIES = [
(t) => [
`Optimistic UI means you update the interface **before** the server confirms — you assume the request for "${clip(t)}" succeeds and roll back only if it fails.`,
`- Apply the change to local state immediately\n- Fire the network request in the background\n- On error, revert and surface a toast`,
`It trades a tiny correctness risk for a big responsiveness win.`,
],
(t) => [
`Here's a tighter take. For "${clip(t)}", keep a snapshot of the previous state, mutate optimistically, then reconcile:`,
"```js\nsetItems(next); // optimistic\ntry { await save(next); }\ncatch { setItems(prev); } // rollback\n```",
`The key is that \`prev\` is captured *synchronously* so rollback is exact.`,
],
(t) => [
`Another angle: think of it as a two-phase commit in the UI. "${clip(t)}" gets a pending flag, so you can grey it out while the write is in flight.`,
`- Phase 1 — render pending state\n- Phase 2 — settle to confirmed or error`,
`Libraries like React Query expose this via \`onMutate\` / \`onError\` / \`onSettled\`.`,
],
];
const FOLLOWUPS = [
(t) => [
`Sure. A minimal like button with rollback:`,
"```jsx\nfunction Like({ liked, onToggle }) {\n const [on, setOn] = useState(liked);\n return <button onClick={() => {\n const prev = on;\n setOn(!on); // optimistic\n onToggle(!on).catch(() => setOn(prev));\n }}>{on ? '♥' : '♡'}</button>;\n}",
`That's the whole pattern in one component.`,
],
(t) => [
`Here's the same idea with a reducer so multiple optimistic edits stack cleanly:`,
"```js\nfunction reducer(s, a) {\n if (a.type === 'toggle') return { ...s, on: !s.on, pending: true };\n if (a.type === 'ok') return { ...s, pending: false };\n if (a.type === 'fail') return { ...s, on: a.prev, pending: false };\n}",
`Dispatch \`toggle\` up front, then \`ok\`/\`fail\` when the promise settles.`,
],
];
const GENERIC = [
(t) => [
`Good question. On "${clip(t)}" — the short version is: model the happy path in the UI first, then treat the server as a *confirmation* rather than a *gate*.`,
`- Predict the outcome locally\n- Keep the pre-change state for rollback\n- Only block on the network when correctness truly demands it`,
`Want me to expand any of those points?`,
],
(t) => [
`Let me reframe "${clip(t)}". Instead of asking "did it save?", ask "can I show the result now and reconcile later?" — that mindset unlocks most snappy interactions.`,
"```txt\noptimistic → pending → (confirmed | reverted)\n```",
`Every fast app you like is quietly doing this.`,
],
(t) => [
`Here's a fresh angle on "${clip(t)}": batch your optimistic updates so the UI never flickers between intermediate states.`,
`- Queue mutations\n- Flush on the next frame with \`requestAnimationFrame\`\n- Reconcile the whole batch against the server response`,
],
];
function clip(s) {
s = (s || "").trim().replace(/[.?!\s]+$/, "");
return s.length > 48 ? s.slice(0, 45) + "…" : s;
}
let genCount = 0;
function generate(userText, depth, variant) {
// Pick a bank based on how deep in the conversation we are, then a variant.
let bank;
if (depth <= 0) bank = REPLIES;
else if (depth === 1) bank = FOLLOWUPS;
else bank = GENERIC;
const paras = bank[variant % bank.length](userText);
return paras.join("\n\n");
}
/* ---------------- markdown-lite renderer ---------------- */
function esc(s) {
return s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
}
function renderMarkdown(text) {
const blocks = text.split(/\n\n+/);
let html = "";
for (const block of blocks) {
const fence = block.match(/^```(?:\w+)?\n([\s\S]*?)\n?```$/);
if (fence) {
html += `<pre><code>${esc(fence[1])}</code></pre>`;
continue;
}
const lines = block.split("\n");
if (lines.every((l) => /^-\s+/.test(l))) {
html += "<ul>" + lines.map((l) => `<li>${inline(l.replace(/^-\s+/, ""))}</li>`).join("") + "</ul>";
} else {
html += `<p>${inline(block)}</p>`;
}
}
return html;
}
function inline(s) {
return esc(s)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
}
/* ---------------- active path ---------------- */
function activePath() {
const path = [];
let parent = forest;
while (parent.children.length) {
const clamped = Math.min(parent.active, parent.children.length - 1);
parent.active = clamped;
const node = parent.children[clamped];
path.push(node);
parent = node;
}
return path;
}
// Redirect the active path so it passes through `node`.
function focusNode(node) {
let n = node;
while (n.parent) {
n.parent.active = n.parent.children.indexOf(n);
n = n.parent;
}
render();
}
/* ---------------- DOM refs ---------------- */
const thread = document.getElementById("thread");
const mapCanvas = document.getElementById("mapCanvas");
const composer = document.getElementById("composer");
const composerInput = document.getElementById("composerInput");
const statTurns = document.getElementById("statTurns");
const statBranches = document.getElementById("statBranches");
const statNodes = document.getElementById("statNodes");
const resetBtn = document.getElementById("resetBtn");
const ICONS = {
regen:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>',
edit:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>',
};
/* ---------------- transcript render ---------------- */
let editingId = null;
function render() {
const path = activePath();
thread.innerHTML = "";
path.forEach((node, i) => {
const parent = node.parent;
const siblings = parent.children;
const el = document.createElement("article");
el.className = "turn turn--" + node.role;
el.dataset.id = node.id;
const avatar = document.createElement("div");
avatar.className = "turn__avatar";
avatar.setAttribute("aria-hidden", "true");
avatar.textContent = node.role === "user" ? "You" : "AI";
const body = document.createElement("div");
body.className = "turn__body";
const meta = document.createElement("div");
meta.className = "turn__meta";
const role = document.createElement("span");
role.className = "turn__role";
role.textContent = node.role === "user" ? "You" : "Assistant";
meta.appendChild(role);
if (siblings.length > 1) {
const tag = document.createElement("span");
tag.className = "turn__tag";
tag.textContent = node.role === "user" ? "forked" : "regenerated";
meta.appendChild(tag);
}
body.appendChild(meta);
if (editingId === node.id) {
body.appendChild(buildEditor(node));
} else {
const bubble = document.createElement("div");
bubble.className = "bubble";
bubble.innerHTML = renderMarkdown(node.text);
body.appendChild(bubble);
body.appendChild(buildActions(node, siblings));
}
el.appendChild(avatar);
el.appendChild(body);
thread.appendChild(el);
});
renderMap(path);
updateStats();
thread.scrollTop = thread.scrollHeight;
}
function buildActions(node, siblings) {
const wrap = document.createElement("div");
wrap.className = "turn__actions";
// Pager across siblings (versions of this turn).
if (siblings.length > 1) {
const idx = siblings.indexOf(node);
const pager = document.createElement("div");
pager.className = "pager";
pager.setAttribute("role", "group");
pager.setAttribute("aria-label", "Switch between versions of this turn");
const prev = document.createElement("button");
prev.type = "button";
prev.innerHTML = "‹";
prev.setAttribute("aria-label", "Previous version");
prev.disabled = idx === 0;
prev.addEventListener("click", () => {
node.parent.active = idx - 1;
render();
});
const count = document.createElement("span");
count.className = "pager__count";
count.textContent = `${idx + 1} / ${siblings.length}`;
const next = document.createElement("button");
next.type = "button";
next.innerHTML = "›";
next.setAttribute("aria-label", "Next version");
next.disabled = idx === siblings.length - 1;
next.addEventListener("click", () => {
node.parent.active = idx + 1;
render();
});
pager.append(prev, count, next);
wrap.appendChild(pager);
}
if (node.role === "assistant") {
const regen = document.createElement("button");
regen.type = "button";
regen.className = "iconbtn iconbtn--regen";
regen.innerHTML = ICONS.regen + "<span>Regenerate</span>";
regen.addEventListener("click", () => regenerate(node));
wrap.appendChild(regen);
} else {
const edit = document.createElement("button");
edit.type = "button";
edit.className = "iconbtn";
edit.innerHTML = ICONS.edit + "<span>Edit & fork</span>";
edit.addEventListener("click", () => {
editingId = node.id;
render();
const ta = thread.querySelector("textarea");
if (ta) {
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
}
});
wrap.appendChild(edit);
}
return wrap;
}
function buildEditor(node) {
const editor = document.createElement("div");
editor.className = "editor";
const ta = document.createElement("textarea");
ta.value = node.text;
ta.setAttribute("aria-label", "Edit message and fork a new branch");
const row = document.createElement("div");
row.className = "editor__row";
const save = document.createElement("button");
save.type = "button";
save.className = "btn btn--primary btn--sm";
save.textContent = "Fork branch";
save.addEventListener("click", () => commitEdit(node, ta.value));
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "btn btn--ghost btn--sm";
cancel.textContent = "Cancel";
cancel.addEventListener("click", () => {
editingId = null;
render();
});
const hint = document.createElement("span");
hint.className = "editor__hint";
hint.innerHTML = "⌘⏎ to fork · Esc to cancel";
ta.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
editingId = null;
render();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
commitEdit(node, ta.value);
}
});
row.append(save, cancel, hint);
editor.append(ta, row);
return editor;
}
/* ---------------- mutations ---------------- */
function depthOf(node) {
// number of user turns that precede this one on its chain
let d = -1,
n = node;
while (n && n.parent) {
if (n.role === "user") d++;
n = n.parent;
}
return d;
}
function regenerate(assistantNode) {
const parent = assistantNode.parent; // a user node (or forest for edge cases)
const userText = parent.role === "user" ? parent.text : "the topic";
const variant = parent.children.length; // next variant
const fresh = makeNode("assistant", generate(userText, depthOf(parent) - 0, variant), parent);
parent.active = parent.children.indexOf(fresh);
genCount++;
render();
}
function commitEdit(userNode, newText) {
newText = newText.trim();
editingId = null;
if (!newText) {
render();
return;
}
const parent = userNode.parent;
const forked = makeNode("user", newText, parent);
parent.active = parent.children.indexOf(forked);
// draft a fresh assistant reply on the new branch
makeNode("assistant", generate(newText, depthOf(forked), 0), forked);
render();
}
function sendMessage(text) {
text = text.trim();
if (!text) return;
const path = activePath();
const leaf = path[path.length - 1] || forest;
const userNode = makeNode("user", text, leaf);
makeNode("assistant", generate(text, depthOf(userNode), 0), userNode);
render();
}
/* ---------------- minimap (inline SVG tree) ---------------- */
function renderMap(path) {
const activeIds = new Set(path.map((n) => n.id));
const currentId = path.length ? path[path.length - 1].id : null;
// layout: x by depth, y by DFS leaf order
let leaf = 0;
const COL = 46;
const ROW = 34;
const PAD = 18;
function layout(node, depth) {
node._x = PAD + depth * COL;
if (node.children.length === 0) {
node._y = PAD + leaf * ROW;
leaf++;
} else {
node.children.forEach((c) => layout(c, depth + 1));
node._y = (node.children[0]._y + node.children[node.children.length - 1]._y) / 2;
}
}
forest.children.forEach((n) => layout(n, 0));
const nodes = [];
let maxX = 0;
(function collect(n) {
nodes.push(n);
maxX = Math.max(maxX, n._x);
n.children.forEach(collect);
})({ children: forest.children, _x: 0, _y: 0 });
const w = maxX + PAD;
const h = PAD + leaf * ROW;
const svgns = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgns, "svg");
svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
svg.setAttribute("width", w);
svg.setAttribute("height", h);
svg.setAttribute("role", "tree");
svg.setAttribute("aria-label", "Conversation branch tree");
// gradient for active edges
const defs = document.createElementNS(svgns, "defs");
defs.innerHTML =
'<linearGradient id="activeGrad" x1="0" y1="0" x2="1" y2="0">' +
'<stop offset="0" stop-color="#8b5cf6"/><stop offset="1" stop-color="#22d3ee"/></linearGradient>';
svg.appendChild(defs);
// edges
forest.children.forEach(function drawEdges(node) {
node.children.forEach((child) => {
const p = document.createElementNS(svgns, "path");
const mx = (node._x + child._x) / 2;
p.setAttribute("d", `M${node._x},${node._y} C${mx},${node._y} ${mx},${child._y} ${child._x},${child._y}`);
const on = activeIds.has(node.id) && activeIds.has(child.id);
p.setAttribute("class", "map__edge" + (on ? " map__edge--active" : ""));
svg.appendChild(p);
drawEdges(child);
});
});
// nodes
nodes.forEach((node) => {
if (!node.id) return; // skip synthetic root wrapper
const g = document.createElementNS(svgns, "g");
const onPath = activeIds.has(node.id);
g.setAttribute(
"class",
"map__node" + (node.id === currentId ? " map__node--current" : "")
);
g.setAttribute("tabindex", "0");
g.setAttribute("role", "treeitem");
g.setAttribute(
"aria-label",
`${node.role} turn${onPath ? ", on active path" : ""}. ${clip(node.text)}`
);
const c = document.createElementNS(svgns, "circle");
c.setAttribute("cx", node._x);
c.setAttribute("cy", node._y);
c.setAttribute("r", node.id === currentId ? 8 : 6);
const cls = onPath ? (node.role === "user" ? "node-user" : "node-asst") : "node-dim";
c.setAttribute("class", cls);
g.appendChild(c);
const title = document.createElementNS(svgns, "title");
title.textContent = `${node.role}: ${clip(node.text)}`;
g.appendChild(title);
const activate = () => focusNode(node);
g.addEventListener("click", activate);
g.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
activate();
}
});
svg.appendChild(g);
});
mapCanvas.innerHTML = "";
mapCanvas.appendChild(svg);
}
/* ---------------- stats ---------------- */
function updateStats() {
let nodeCount = 0;
let branchPoints = 0;
(function walk(n) {
n.children.forEach((c) => {
nodeCount++;
walk(c);
});
if (n.children.length > 1) branchPoints += n.children.length - 1;
})(forest);
statTurns.textContent = activePath().length;
statBranches.textContent = branchPoints;
statNodes.textContent = nodeCount;
}
/* ---------------- seed conversation ---------------- */
function seed() {
forest.children.length = 0;
forest.active = 0;
seq = 0;
editingId = null;
const u1 = makeNode("user", "How does optimistic UI updating work in React?", forest);
makeNode("assistant", generate(u1.text, 0, 0), u1); // version 1
makeNode("assistant", generate(u1.text, 0, 1), u1); // version 2 (a sibling)
u1.active = 0;
const a1 = u1.children[0];
const u2 = makeNode("user", "Nice — can you show a minimal example?", a1);
makeNode("assistant", generate(u2.text, 1, 0), u2);
}
/* ---------------- events ---------------- */
composer.addEventListener("submit", (e) => {
e.preventDefault();
sendMessage(composerInput.value);
composerInput.value = "";
composerInput.focus();
});
resetBtn.addEventListener("click", () => {
seed();
render();
});
seed();
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chat Branching Tree</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="app" role="application" aria-label="Chat branching tree demo">
<header class="app__head">
<div class="brand">
<span class="brand__dot" aria-hidden="true"></span>
<div>
<h1>Chat Branching Tree</h1>
<p class="brand__sub">Regenerate replies into siblings, page between versions, fork by editing.</p>
</div>
</div>
<div class="stats" id="stats" aria-live="polite">
<span class="stat"><b id="statTurns">0</b> turns</span>
<span class="stat"><b id="statBranches">0</b> branches</span>
<span class="stat"><b id="statNodes">0</b> nodes</span>
</div>
</header>
<div class="layout">
<!-- Conversation -->
<section class="chat" aria-label="Conversation transcript">
<div class="chat__scroll" id="thread" tabindex="0" aria-label="Active conversation path"></div>
<form class="composer" id="composer" autocomplete="off">
<label class="sr-only" for="composerInput">Message</label>
<input
id="composerInput"
class="composer__input"
type="text"
placeholder="Continue the conversation…"
/>
<button class="btn btn--send" type="submit">
Send
<kbd>⏎</kbd>
</button>
</form>
</section>
<!-- Minimap -->
<aside class="map" aria-label="Conversation tree minimap">
<div class="map__head">
<h2>Branch map</h2>
<button class="btn btn--ghost btn--sm" id="resetBtn" type="button">Reset</button>
</div>
<div class="map__canvas" id="mapCanvas"></div>
<ul class="legend" aria-hidden="true">
<li><span class="swatch swatch--user"></span> user</li>
<li><span class="swatch swatch--asst"></span> assistant</li>
<li><span class="swatch swatch--active"></span> active path</li>
</ul>
<p class="map__hint">Click any node to jump the active path there.</p>
</aside>
</div>
</main>
<script src="script.js"></script>
</body>
</html>Chat Branching Tree models a conversation the way modern AI chat apps do: not as a flat list, but as a tree. Every message is a node with a parent and children. When you regenerate an assistant reply, a new sibling is appended and the turn shows a 2/3 pager so you can flip between alternative answers without losing any of them. Editing a user message forks a new branch from that point and drafts a fresh reply, leaving the original path intact.
The transcript renders only the currently active path through the tree. A compact minimap on the side draws the full conversation graph as an inline SVG — active path highlighted, dots colored by role — and every node is clickable, retargeting the active path in one tap. Prev/next pagers, edit forks, and minimap clicks all mutate the same tree and trigger a single re-render.
The demo leans on the platform: an inline SVG graph laid out with a small recursive tree algorithm, contenteditable-free inline editing via a swapped <textarea>, and keyboard-accessible controls with visible focus. No frameworks, no build — just a tree data structure and vanilla DOM.