Mischief

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.

You

How do I undo a commit I already pushed?
Version 2 of 3

Assistant

Use git revert with the commit hash. It adds a new commit that reverses the old one, so nobody else's history is rewritten.

You

Does that work for a merge commit?

Assistant

Yes. Add -m 1 to say which parent to keep: git revert -m 1 followed by the merge commit's hash.

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/message-branches
import { MessageBranches } from "mischief-ui/message-branches"
Also installs
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/message-branches/message-branches.tsx
"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" },
]
q1 and q2 are two versions of the first question.

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.