feat(tui): add visual branch tree and breadcrumb navigation

- Add Branches section to sidebar showing parent/siblings/children
- Clickable branch items to navigate between related sessions
- Add breadcrumb trail in header showing session ancestry
- Clickable ancestors to jump up the session tree

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Artur Do Lago
2026-01-11 02:26:03 +01:00
parent d887ab6093
commit dc9ce08ee8
3 changed files with 132 additions and 19 deletions
+1 -1
View File
@@ -183,7 +183,7 @@ Agent-core is a fork of OpenCode with three personas (Zee, Stanley, Johny) shari
### 4.3 Conversation
- [x] Better compaction summaries (via continuity.ts generateSummary)
- [ ] Conversation branching
- [x] Conversation branching (fork API + visual branch tree in sidebar + breadcrumb navigation)
- [ ] Export conversations
---
@@ -1,11 +1,12 @@
import { type Accessor, createMemo, Match, Show, Switch } from "solid-js"
import { useRouteData } from "@tui/context/route"
import { type Accessor, createMemo, For, Match, Show, Switch } from "solid-js"
import { useRouteData, useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { pipe, sumBy } from "remeda"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
import { useKeybind } from "../../context/keybind"
import { Locale } from "@/util/locale"
const Title = (props: { session: Accessor<Session> }) => {
const { theme } = useTheme()
@@ -30,9 +31,23 @@ const ContextInfo = (props: { context: Accessor<string | undefined>; cost: Acces
export function Header() {
const route = useRouteData("session")
const sync = useSync()
const { navigate } = useRoute()
const session = createMemo(() => sync.session.get(route.sessionID)!)
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
// Build ancestry chain for breadcrumbs
const ancestry = createMemo(() => {
const chain: Session[] = []
let current = session()
while (current?.parentID) {
const parent = sync.data.session.find((s) => s.id === current!.parentID)
if (!parent) break
chain.unshift(parent)
current = parent
}
return chain
})
const cost = createMemo(() => {
const total = pipe(
messages(),
@@ -75,21 +90,38 @@ export function Header() {
>
<Switch>
<Match when={session()?.parentID}>
<box flexDirection="row" gap={2}>
<text fg={theme.text}>
<b>Subagent session</b>
</text>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
</text>
<text fg={theme.text}>
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
</text>
<text fg={theme.text}>
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
</text>
<box flexGrow={1} flexShrink={1} />
<ContextInfo context={context} cost={cost} />
<box flexDirection="column" gap={0}>
<box flexDirection="row" gap={1} alignItems="center">
<For each={ancestry()}>
{(ancestor) => (
<>
<text
fg={theme.accent}
onMouseDown={() => navigate({ type: "session", sessionID: ancestor.id })}
>
{Locale.truncateMiddle(ancestor.title ?? "Session", 20)}
</text>
<text fg={theme.textMuted}>&gt;</text>
</>
)}
</For>
<text fg={theme.text}>
<b>{Locale.truncateMiddle(session()?.title ?? "Current", 20)}</b>
</text>
<box flexGrow={1} flexShrink={1} />
<ContextInfo context={context} cost={cost} />
</box>
<box flexDirection="row" gap={2}>
<text fg={theme.textMuted}>
Parent <span style={{ fg: theme.border }}>{keybind.print("session_parent")}</span>
</text>
<text fg={theme.textMuted}>
Prev <span style={{ fg: theme.border }}>{keybind.print("session_child_cycle_reverse")}</span>
</text>
<text fg={theme.textMuted}>
Next <span style={{ fg: theme.border }}>{keybind.print("session_child_cycle")}</span>
</text>
</box>
</box>
</Match>
<Match when={true}>
@@ -4,13 +4,14 @@ import { createStore } from "solid-js/store"
import { useTheme } from "../../context/theme"
import { Locale } from "@/util/locale"
import path from "path"
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
import { Global } from "@/global"
import { Installation } from "@/installation"
import { useKeybind } from "../../context/keybind"
import { useDirectory } from "../../context/directory"
import { useKV } from "../../context/kv"
import { TodoItem } from "../../component/todo-item"
import { useRoute } from "../../context/route"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const sync = useSync()
@@ -68,6 +69,33 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
)
const gettingStartedDismissed = createMemo(() => kv.get("dismissed_getting_started", false))
const { navigate } = useRoute()
// Branch tree data
const parentSession = createMemo(() => {
const parent = session()?.parentID
if (!parent) return null
return sync.data.session.find((s) => s.id === parent) ?? null
})
const childSessions = createMemo(() =>
sync.data.session
.filter((s) => s.parentID === props.sessionID)
.sort((a, b) => b.time.created - a.time.created),
)
const siblingsSessions = createMemo(() => {
const parent = session()?.parentID
if (!parent) return []
return sync.data.session
.filter((s) => s.parentID === parent && s.id !== props.sessionID)
.sort((a, b) => b.time.created - a.time.created)
})
const hasBranches = createMemo(
() => parentSession() !== null || childSessions().length > 0 || siblingsSessions().length > 0,
)
return (
<Show when={session()}>
<box
@@ -264,6 +292,59 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
</Show>
</box>
</Show>
<Show when={hasBranches()}>
<box>
<text fg={theme.text}>
<b>Branches</b>
</text>
<Show when={parentSession()}>
<box
flexDirection="row"
gap={1}
onMouseDown={() => navigate({ type: "session", sessionID: parentSession()!.id })}
>
<text fg={theme.textMuted}></text>
<text fg={theme.accent}>
{Locale.truncateMiddle(parentSession()!.title ?? "Parent", 30)}
</text>
</box>
</Show>
<For each={siblingsSessions()}>
{(sibling) => (
<box
flexDirection="row"
gap={1}
onMouseDown={() => navigate({ type: "session", sessionID: sibling.id })}
>
<text fg={theme.textMuted}></text>
<text fg={theme.textMuted}>
{Locale.truncateMiddle(sibling.title ?? "Branch", 30)}
</text>
</box>
)}
</For>
<box flexDirection="row" gap={1}>
<text fg={theme.primary}></text>
<text fg={theme.text}>
<b>{Locale.truncateMiddle(session()?.title ?? "Current", 30)}</b>
</text>
</box>
<For each={childSessions()}>
{(child) => (
<box
flexDirection="row"
gap={1}
onMouseDown={() => navigate({ type: "session", sessionID: child.id })}
>
<text fg={theme.textMuted}></text>
<text fg={theme.accent}>
{Locale.truncateMiddle(child.title ?? "Child", 30)}
</text>
</box>
)}
</For>
</box>
</Show>
</box>
</scrollbox>