123 / Agent UI
Message Branches
The 2 / 3 switcher on a message that was edited or regenerated, with an overview of every place the conversation forked and which path is on screen.
"use client"
import * as React from "react"
import { Message } from "mischief-ui/message"
import {
MessageBranches,
type BranchNode,
} from "mischief-ui/message-branches"
type Turn = BranchNode & { role: "user" | "assistant"; text: string }
const turns: Turn[] = [
{
id: "q1",
parentId: null,
role: "user",
label: "Undo the last commit, keep changes",
text: "How do I undo my last commit but keep the changes?",
},
{
id: "a1",
parentId: "q1",
role: "assistant",
label: "git reset --soft HEAD~1",
text: "Run git reset --soft HEAD~1. The commit goes away and its changes stay staged, ready to commit again.",
},
{
id: "a1-retry",
parentId: "q1",
role: "assistant",
label: "git reset HEAD~1",
text: "Run git reset HEAD~1. That uses the default mixed mode, so the commit goes away and your edits stay in the working tree, unstaged.",
},
{
id: "q2",
parentId: null,
role: "user",
label: "Undo a commit already pushed",
text: "How do I undo a commit I already pushed?",
},
{
id: "a2",
parentId: "q2",
role: "assistant",
label: "git revert <commit>",
text: "Use git revert with the commit hash. It adds a new commit that reverses the old one, so nobody else's history is rewritten.",
},
{
id: "q2-follow",
parentId: "a2",
role: "user",
label: "Does that work for a merge?",
text: "Does that work for a merge commit?",
},
{
id: "a2-follow",
parentId: "q2-follow",
role: "assistant",
label: "git revert -m 1",
text: "Yes. Add -m 1 to say which parent to keep: git revert -m 1 followed by the merge commit's hash.",
},
{
id: "q3",
parentId: null,
role: "user",
label: "Change the last commit message",
text: "How do I change the message of my last commit?",
},
{
id: "a3",
parentId: "q3",
role: "assistant",
label: "git commit --amend",
text: "Run git commit --amend and edit the message. If the commit is already pushed this rewrites history, so keep it to your own branch.",
},
]
const byId = new Map(turns.map((turn) => [turn.id, turn]))
/** From the chosen message up to the first, then down its newest replies. */
function pathThrough(id: string) {
const path: string[] = []
for (let at = byId.get(id); at; at = byId.get(at.parentId ?? "")) {
path.unshift(at.id)
}
for (;;) {
const last = path[path.length - 1]
const replies = turns.filter((turn) => turn.parentId === last)
if (replies.length === 0) return path
path.push(replies[replies.length - 1]!.id)
}
}
export function MessageBranchesDemo() {
const [activePath, setActivePath] = React.useState(() => pathThrough("q2"))
return (
<div className="bg-background border-border w-full max-w-[36rem] rounded-[1.25rem] border p-4 shadow-sm sm:p-5">
{activePath.map((id) => {
const turn = byId.get(id)!
return (
<div
key={id}
className={`flex flex-col ${turn.role === "user" ? "items-end" : "items-start"}`}
>
<Message className="w-full" role={turn.role}>
{turn.text}
</Message>
<MessageBranches
activePath={activePath}
className="-mt-2 mb-1"
messageId={id}
nodes={turns}
onSelect={(branchId) => setActivePath(pathThrough(branchId))}
/>
</div>
)
})}
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/message-branchesimport { MessageBranches } from "mischief-ui/message-branches"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { ChevronLeft, ChevronRight, GitFork } from "lucide-react" import { cn } from "@/lib/utils" export type BranchNode = { id: string parentId: string | null /** A short line of the message, shown in the branch overview. */ label?: string} Usage
export function Thread({ nodes, activePath, select }) {
return activePath.map((id) => (
<div key={id}>
<Message role={roleOf(id)}>{textOf(id)}</Message>
<MessageBranches
nodes={nodes}
activePath={activePath}
messageId={id}
onSelect={select}
/>
</div>
))
}The tree it reads
nodes is every message as an id and a parentId, with null for a first message. Messages that share a parent are versions of each other, in the order they appear. activePath is the ids on screen, from the first message down. The control renders nothing on a message that has only one version, so it can go under every message.
const nodes = [
{ id: "q1", parentId: null, label: "Undo the last commit" },
{ id: "a1", parentId: "q1", label: "git reset --soft HEAD~1" },
{ id: "q2", parentId: null, label: "Undo a pushed commit" },
{ id: "a2", parentId: "q2", label: "git revert" },
]What onSelect asks of you
The arrows and the overview both call onSelect with a message id. Deciding what is shown below that message is yours. Most chat products follow the newest reply at each level, and the example does the same.
function pathThrough(id) {
const path = ancestorsOf(id).concat(id)
let replies = childrenOf(id)
while (replies.length > 0) {
const newest = replies[replies.length - 1]
path.push(newest.id)
replies = childrenOf(newest.id)
}
return path
}The overview
The fork button opens every branch at once. A run of messages with no fork in it is one row, labelled with its first message and its length, so a long thread edited once stays two rows deep. Rows on the path being shown are marked. Up, Down, Home, and End move between rows, Enter chooses one, and Escape closes it. Set overview to false to keep only the arrows.
API
nodesreadonly BranchNode[]Every message in the conversation, by id and parent.id, parentId, label (BranchNode)string, string | null, stringA message, the message it replies to, and the short line the overview shows for it.activePathreadonly string[]The ids on screen, first message first.messageIdstringThe message this control sits on.onSelect(branchId: string) => voidCalled with the version or branch chosen.overviewbooleanShows the branch overview button. Defaults to true.labelstringThe accessible name of the control.classNamestringClasses for the root....divPropsHTMLAttributesNative div attributes.Accessibility
The control is a named group of native buttons. The count reads as Version 2 of 3, and a step is announced politely. The first and last arrows are marked aria-disabled rather than disabled, so pressing Next on the last version does not throw focus out of the control. The overview is a labelled dialog of nested lists whose rows are buttons named with their label, length, and whether they are shown; it opens with focus on the row on screen and returns focus to its button when it closes. Reduced motion removes the counter's slide and the overview's entrance.