mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-25 12:45:51 -04:00
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:
+1
-1
@@ -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}>></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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user