diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index eb780f521bd..cc58f0f30e2 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -81,6 +81,9 @@ export function Header() { Subagent session + + Parent {keybind.print("session_parent" as any)} + Prev {keybind.print("session_child_cycle_reverse")} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index fcf562782a4..3bd4ca4fc59 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -231,6 +231,13 @@ export function Session() { } } + function goToParent() { + const parentID = session()?.parentID + if (parentID) { + navigate({ type: "session", sessionID: parentID }) + } + } + const command = useCommandDialog() command.register(() => [ { @@ -679,6 +686,17 @@ export function Session() { dialog.clear() }, }, + { + title: "Go to parent session", + value: "session.parent", + keybind: "session_parent" as any, + category: "Session", + disabled: !session()?.parentID, + onSelect: (dialog) => { + goToParent() + dialog.clear() + }, + }, ]) const revertInfo = createMemo(() => session()?.revert) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index c63f5116ab2..2e5e6787f3d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -1,17 +1,17 @@ import { useSync } from "@tui/context/sync" -import { createMemo, For, Show, Switch, Match } from "solid-js" +import { createMemo, For, Show, Switch, Match, createSignal, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "../../context/theme" +import { useRoute } from "../../context/route" import { Locale } from "@/util/locale" import path from "path" -import type { AssistantMessage } from "@opencode-ai/sdk" -import { Global } from "@/global" +import type { AssistantMessage, ToolPart } from "@opencode-ai/sdk" import { Installation } from "@/installation" -import { useKeybind } from "../../context/keybind" import { useDirectory } from "../../context/directory" export function Sidebar(props: { sessionID: string }) { const sync = useSync() + const route = useRoute() const { theme } = useTheme() const session = createMemo(() => sync.session.get(props.sessionID)!) const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? []) @@ -23,11 +23,43 @@ export function Sidebar(props: { sessionID: string }) { diff: true, todo: true, lsp: true, + subagents: true, }) + // Animated spinner + const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + const [spinnerIndex, setSpinnerIndex] = createSignal(0) + + const intervalId = setInterval(() => { + setSpinnerIndex((prev) => (prev + 1) % spinnerFrames.length) + }, 100) + onCleanup(() => clearInterval(intervalId)) + // Sort MCP servers alphabetically for consistent display order const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b))) + const taskToolParts = createMemo(() => { + const parts: ToolPart[] = [] + for (const message of messages()) { + for (const part of sync.data.part[message.id] ?? []) { + if (part.type === "tool" && part.tool === "task") parts.push(part) + } + } + return parts + }) + + const subagentGroups = createMemo(() => { + const groups = new Map() + for (const part of taskToolParts()) { + const input = part.state.input as Record + const agentName = input?.subagent_type as string + if (!agentName) continue + if (!groups.has(agentName)) groups.set(agentName, []) + groups.get(agentName)!.push(part) + } + return Array.from(groups.entries()) + }) + const cost = createMemo(() => { const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0) return new Intl.NumberFormat("en-US", { @@ -48,7 +80,6 @@ export function Sidebar(props: { sessionID: string }) { } }) - const keybind = useKeybind() const directory = useDirectory() const hasProviders = createMemo(() => @@ -129,6 +160,73 @@ export function Sidebar(props: { sessionID: string }) { + 0}> + + subagentGroups().length > 2 && setExpanded("subagents", !expanded.subagents)} + > + 2}> + {expanded.subagents ? "▼" : "▶"} + + + Subagents + + + + + {([agentName, parts]) => { + const hasActive = () => + parts.some((p) => p.state.status === "running" || p.state.status === "pending") + return ( + + + + • + + + {agentName} + + + + {(part) => { + const isActive = () => part.state.status === "running" || part.state.status === "pending" + const isError = () => part.state.status === "error" + const input = part.state.input as Record + const description = (input?.description as string) ?? "" + const stateMetadata = (part.state as { metadata?: Record }).metadata + const sessionId = (part.metadata?.sessionId ?? stateMetadata?.sessionId) as + | string + | undefined + return ( + { + if (e.button === 0 && sessionId) { + route.navigate({ type: "session", sessionID: sessionId }) + } + }} + > + + {isActive() ? spinnerFrames[spinnerIndex()] : isError() ? "✗" : "✓"} + + + {description} + + + ) + }} + + + ) + }} + + + + right").describe("Next child session"), session_child_cycle_reverse: z.string().optional().default("left").describe("Previous child session"), + session_parent: z.string().optional().default("up").describe("Go to parent session"), terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"), }) .strict() diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index bf23f77ecad..b3c16f605bb 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -864,6 +864,10 @@ export type KeybindsConfig = { * Previous child session */ session_child_cycle_reverse?: string + /** + * Go to parent session + */ + session_parent?: string /** * Suspend terminal */ diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index f63457cc026..ffe712ccd41 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -89,10 +89,11 @@ A general-purpose agent for researching complex questions, searching for code, a ``` 3. **Navigation between sessions**: When subagents create their own child sessions, you can navigate between the parent session and all child sessions using: + - **\+Up** (or your configured `session_parent` keybind) to go directly to the parent session - **\+Right** (or your configured `session_child_cycle` keybind) to cycle forward through parent → child1 → child2 → ... → parent - **\+Left** (or your configured `session_child_cycle_reverse` keybind) to cycle backward through parent ← child1 ← child2 ← ... ← parent - This allows you to seamlessly switch between the main conversation and specialized subagent work. + You can also click on any subagent task in the sidebar to navigate directly to that subagent's session. ---