diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 335cda3bfbc..52d017335cd 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 --- diff --git a/packages/agent-core/src/cli/cmd/tui/routes/session/header.tsx b/packages/agent-core/src/cli/cmd/tui/routes/session/header.tsx index 74cc9af4e87..9ffff6cc097 100644 --- a/packages/agent-core/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/agent-core/src/cli/cmd/tui/routes/session/header.tsx @@ -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 }) => { const { theme } = useTheme() @@ -30,9 +31,23 @@ const ContextInfo = (props: { context: Accessor; 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() { > - - - Subagent session - - - Parent {keybind.print("session_parent")} - - - Prev {keybind.print("session_child_cycle_reverse")} - - - Next {keybind.print("session_child_cycle")} - - - + + + + {(ancestor) => ( + <> + navigate({ type: "session", sessionID: ancestor.id })} + > + {Locale.truncateMiddle(ancestor.title ?? "Session", 20)} + + > + + )} + + + {Locale.truncateMiddle(session()?.title ?? "Current", 20)} + + + + + + + Parent {keybind.print("session_parent")} + + + Prev {keybind.print("session_child_cycle_reverse")} + + + Next {keybind.print("session_child_cycle")} + + diff --git a/packages/agent-core/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/agent-core/src/cli/cmd/tui/routes/session/sidebar.tsx index ef511e91edd..aae9b95df98 100644 --- a/packages/agent-core/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/agent-core/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -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 ( + + + + Branches + + + navigate({ type: "session", sessionID: parentSession()!.id })} + > + + + {Locale.truncateMiddle(parentSession()!.title ?? "Parent", 30)} + + + + + {(sibling) => ( + navigate({ type: "session", sessionID: sibling.id })} + > + + + {Locale.truncateMiddle(sibling.title ?? "Branch", 30)} + + + )} + + + + + {Locale.truncateMiddle(session()?.title ?? "Current", 30)} + + + + {(child) => ( + navigate({ type: "session", sessionID: child.id })} + > + + + {Locale.truncateMiddle(child.title ?? "Child", 30)} + + + )} + + +