Skip to content

Commit e768f4c

Browse files
committed
feat: add subagents sidebar with clickable navigation and parent keybind
1 parent 0eaec2a commit e768f4c

File tree

5 files changed

+125
-6
lines changed

5 files changed

+125
-6
lines changed

packages/opencode/src/cli/cmd/tui/routes/session/header.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ export function Header() {
8181
<text fg={theme.text}>
8282
<b>Subagent session</b>
8383
</text>
84+
<text fg={theme.text}>
85+
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent" as any)}</span>
86+
</text>
8487
<text fg={theme.text}>
8588
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
8689
</text>

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,13 @@ export function Session() {
231231
}
232232
}
233233

234+
function goToParent() {
235+
const parentID = session()?.parentID
236+
if (parentID) {
237+
navigate({ type: "session", sessionID: parentID })
238+
}
239+
}
240+
234241
const command = useCommandDialog()
235242
command.register(() => [
236243
{
@@ -675,6 +682,17 @@ export function Session() {
675682
dialog.clear()
676683
},
677684
},
685+
{
686+
title: "Go to parent session",
687+
value: "session.parent",
688+
keybind: "session_parent" as any,
689+
category: "Session",
690+
disabled: !session()?.parentID,
691+
onSelect: (dialog) => {
692+
goToParent()
693+
dialog.clear()
694+
},
695+
},
678696
])
679697

680698
const revertInfo = createMemo(() => session()?.revert)

packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import { useSync } from "@tui/context/sync"
2-
import { createMemo, For, Show, Switch, Match } from "solid-js"
2+
import { createMemo, For, Show, Switch, Match, createSignal, onCleanup } from "solid-js"
33
import { createStore } from "solid-js/store"
44
import { useTheme } from "../../context/theme"
5+
import { useRoute } from "../../context/route"
56
import { Locale } from "@/util/locale"
67
import path from "path"
7-
import type { AssistantMessage } from "@opencode-ai/sdk"
8-
import { Global } from "@/global"
8+
import type { AssistantMessage, ToolPart } from "@opencode-ai/sdk"
99
import { Installation } from "@/installation"
10-
import { useKeybind } from "../../context/keybind"
1110
import { useDirectory } from "../../context/directory"
1211

1312
export function Sidebar(props: { sessionID: string }) {
1413
const sync = useSync()
14+
const route = useRoute()
1515
const { theme } = useTheme()
1616
const session = createMemo(() => sync.session.get(props.sessionID)!)
1717
const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? [])
@@ -23,11 +23,43 @@ export function Sidebar(props: { sessionID: string }) {
2323
diff: true,
2424
todo: true,
2525
lsp: true,
26+
subagents: true,
2627
})
2728

29+
// Animated spinner
30+
const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
31+
const [spinnerIndex, setSpinnerIndex] = createSignal(0)
32+
33+
const intervalId = setInterval(() => {
34+
setSpinnerIndex((prev) => (prev + 1) % spinnerFrames.length)
35+
}, 100)
36+
onCleanup(() => clearInterval(intervalId))
37+
2838
// Sort MCP servers alphabetically for consistent display order
2939
const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b)))
3040

41+
const taskToolParts = createMemo(() => {
42+
const parts: ToolPart[] = []
43+
for (const message of messages()) {
44+
for (const part of sync.data.part[message.id] ?? []) {
45+
if (part.type === "tool" && part.tool === "task") parts.push(part)
46+
}
47+
}
48+
return parts
49+
})
50+
51+
const subagentGroups = createMemo(() => {
52+
const groups = new Map<string, ToolPart[]>()
53+
for (const part of taskToolParts()) {
54+
const input = part.state.input as Record<string, unknown>
55+
const agentName = input?.subagent_type as string
56+
if (!agentName) continue
57+
if (!groups.has(agentName)) groups.set(agentName, [])
58+
groups.get(agentName)!.push(part)
59+
}
60+
return Array.from(groups.entries())
61+
})
62+
3163
const cost = createMemo(() => {
3264
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
3365
return new Intl.NumberFormat("en-US", {
@@ -48,7 +80,6 @@ export function Sidebar(props: { sessionID: string }) {
4880
}
4981
})
5082

51-
const keybind = useKeybind()
5283
const directory = useDirectory()
5384

5485
const hasProviders = createMemo(() =>
@@ -129,6 +160,71 @@ export function Sidebar(props: { sessionID: string }) {
129160
</Show>
130161
</box>
131162
</Show>
163+
<Show when={subagentGroups().length > 0}>
164+
<box>
165+
<box
166+
flexDirection="row"
167+
gap={1}
168+
onMouseDown={() => subagentGroups().length > 2 && setExpanded("subagents", !expanded.subagents)}
169+
>
170+
<Show when={subagentGroups().length > 2}>
171+
<text fg={theme.text}>{expanded.subagents ? "▼" : "▶"}</text>
172+
</Show>
173+
<text fg={theme.text}>
174+
<b>Subagents</b>
175+
</text>
176+
</box>
177+
<Show when={subagentGroups().length <= 2 || expanded.subagents}>
178+
<For each={subagentGroups()}>
179+
{([agentName, parts]) => {
180+
const hasActive = () =>
181+
parts.some((p) => p.state.status === "running" || p.state.status === "pending")
182+
return (
183+
<box>
184+
<box flexDirection="row" gap={1}>
185+
<text flexShrink={0} style={{ fg: hasActive() ? theme.success : theme.text }}>
186+
187+
</text>
188+
<text fg={theme.text} wrapMode="word">
189+
{agentName}
190+
</text>
191+
</box>
192+
<For each={parts}>
193+
{(part) => {
194+
const isActive = () => part.state.status === "running" || part.state.status === "pending"
195+
const isError = () => part.state.status === "error"
196+
const input = part.state.input as Record<string, unknown>
197+
const description = (input?.description as string) ?? ""
198+
const stateMetadata = (part.state as { metadata?: Record<string, unknown> }).metadata
199+
const sessionId = (part.metadata?.sessionId ?? stateMetadata?.sessionId) as
200+
| string
201+
| undefined
202+
return (
203+
<box
204+
flexDirection="row"
205+
gap={1}
206+
paddingLeft={2}
207+
onMouseDown={() => {
208+
if (sessionId) route.navigate({ type: "session", sessionID: sessionId })
209+
}}
210+
>
211+
<text flexShrink={0} fg={isActive() ? theme.success : theme.textMuted}>
212+
{isActive() ? spinnerFrames[spinnerIndex()] : isError() ? "✗" : "✓"}
213+
</text>
214+
<text fg={isActive() ? theme.text : theme.textMuted} wrapMode="word">
215+
{description}
216+
</text>
217+
</box>
218+
)
219+
}}
220+
</For>
221+
</box>
222+
)
223+
}}
224+
</For>
225+
</Show>
226+
</box>
227+
</Show>
132228
<box>
133229
<box
134230
flexDirection="row"

packages/opencode/src/config/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,7 @@ export namespace Config {
441441
history_next: z.string().optional().default("down").describe("Next history item"),
442442
session_child_cycle: z.string().optional().default("<leader>right").describe("Next child session"),
443443
session_child_cycle_reverse: z.string().optional().default("<leader>left").describe("Previous child session"),
444+
session_parent: z.string().optional().default("<leader>up").describe("Go to parent session"),
444445
terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"),
445446
})
446447
.strict()

packages/web/src/content/docs/agents.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,11 @@ A general-purpose agent for researching complex questions, searching for code, a
8989
```
9090

9191
3. **Navigation between sessions**: When subagents create their own child sessions, you can navigate between the parent session and all child sessions using:
92+
- **\<Leader>+Up** (or your configured `session_parent` keybind) to go directly to the parent session
9293
- **\<Leader>+Right** (or your configured `session_child_cycle` keybind) to cycle forward through parent → child1 → child2 → ... → parent
9394
- **\<Leader>+Left** (or your configured `session_child_cycle_reverse` keybind) to cycle backward through parent ← child1 ← child2 ← ... ← parent
9495

95-
This allows you to seamlessly switch between the main conversation and specialized subagent work.
96+
You can also click on any subagent task in the sidebar to navigate directly to that subagent's session.
9697

9798
---
9899

0 commit comments

Comments
 (0)