fix(tui): navigate nested subagents (#43290)

Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-08-18 23:02:45 +02:00
committed by GitHub
parent cb39ea1136
commit 511b4556a2
3 changed files with 84 additions and 41 deletions
@@ -1,6 +1,7 @@
import { createMemo, For, Show, createEffect, onMount, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { TextAttributes, ScrollBoxRenderable } from "@opentui/core"
import type { SessionInfo } from "@opencode-ai/client"
import { useRoute, useRouteData } from "../../../context/route"
import { useData } from "../../../context/data"
import { useClient } from "../../../context/client"
@@ -9,6 +10,7 @@ import { Locale } from "../../../util/locale"
import { Keymap } from "../../../context/keymap"
import { useComposerTab } from "./index"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { sessionFamily } from "../../../util/session"
interface SubagentEntry {
sessionID: string
@@ -16,6 +18,7 @@ interface SubagentEntry {
title: string
status: string
current: boolean
prefix: string
}
export function SubagentsTab(props: { sessionID: string }) {
@@ -34,47 +37,24 @@ export function SubagentsTab(props: { sessionID: string }) {
const current = session()
if (!current) return []
const result: SubagentEntry[] = []
if (current.parentID) {
const siblings = data.session.list().filter((s) => s.parentID === current.parentID)
for (const sibling of siblings) {
const title = withTimestampedFallback(sibling)
const result = sessionFamily<SessionInfo>(data.session.list(), current.id).map(
({ session, prefix }): SubagentEntry => {
const title = withTimestampedFallback(session)
const agentMatch = title.match(/@(\w+) subagent/)
const agent = sibling.agent
? Locale.titlecase(sibling.agent)
: agentMatch
? Locale.titlecase(agentMatch[1])
: "Subagent"
const name = agentMatch ? title.replace(agentMatch[0], "").trim() || title : title
result.push({
sessionID: sibling.id,
agent,
title: name,
status: data.session.status(sibling.id),
current: sibling.id === route.sessionID,
})
}
} else {
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
for (const child of children) {
const title = withTimestampedFallback(child)
const agentMatch = title.match(/@(\w+) subagent/)
const agent = child.agent
? Locale.titlecase(child.agent)
: agentMatch
? Locale.titlecase(agentMatch[1])
: "Subagent"
const name = agentMatch ? title.replace(agentMatch[0], "").trim() || title : title
result.push({
sessionID: child.id,
agent,
title: name,
status: data.session.status(child.id),
current: child.id === route.sessionID,
})
}
}
return {
sessionID: session.id,
agent: session.agent
? Locale.titlecase(session.agent)
: agentMatch
? Locale.titlecase(agentMatch[1])
: "Subagent",
title: agentMatch ? title.replace(agentMatch[0], "").trim() || title : title,
status: data.session.status(session.id),
current: session.id === route.sessionID,
prefix,
}
},
)
return result.filter((entry) => (store.active ? entry.status === "running" : entry.status !== "running"))
})
@@ -264,6 +244,7 @@ export function SubagentsTab(props: { sessionID: string }) {
attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none"
>
{entry.prefix}
{entry.agent}: {entry.title}
</text>
</box>
+41
View File
@@ -1,6 +1,47 @@
import type { ModelInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { Locale } from "./locale"
type SessionNode = {
id: string
parentID?: string | null
}
export function sessionFamily<T extends SessionNode>(sessions: readonly T[], sessionID: string) {
const byID = new Map(sessions.map((session) => [session.id, session]))
const current = byID.get(sessionID)
if (!current) return []
const children = new Map<string, T[]>()
sessions.forEach((session) => {
if (!session.parentID) return
const group = children.get(session.parentID)
if (group) group.push(session)
else children.set(session.parentID, [session])
})
function root(session: T): T {
const parent = session.parentID ? byID.get(session.parentID) : undefined
return parent ? root(parent) : session
}
function walk(parentID: string, ancestors: boolean[]): Array<{ session: T; prefix: string }> {
const group = children.get(parentID) ?? []
return group.flatMap((session, index) => {
const last = index === group.length - 1
const prefix =
ancestors.length === 0
? ""
: ancestors
.slice(1)
.map((ancestor) => (ancestor ? " " : "│ "))
.join("") + (last ? "└─ " : "├─ ")
return [{ session, prefix }, ...walk(session.id, [...ancestors, last])]
})
}
return walk(root(current).id, [])
}
export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessageInfo>, boundary?: string) {
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
if (boundary && boundaryIndex === -1) return undefined
+22 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client"
import { lastAssistantWithUsage } from "../../src/util/session"
import { lastAssistantWithUsage, sessionFamily } from "../../src/util/session"
const assistant = (id: string, input: number): SessionMessageInfo => ({
id,
@@ -13,6 +13,27 @@ const assistant = (id: string, input: number): SessionMessageInfo => ({
})
describe("util.session", () => {
test("flattens nested subagents from any session in the family", () => {
const sessions = [
{ id: "root" },
{ id: "child-a", parentID: "root" },
{ id: "grandchild-a", parentID: "child-a" },
{ id: "great-grandchild-a", parentID: "grandchild-a" },
{ id: "grandchild-a2", parentID: "child-a" },
{ id: "child-b", parentID: "root" },
{ id: "grandchild-b", parentID: "child-b" },
]
expect(sessionFamily(sessions, "great-grandchild-a")).toEqual([
{ session: sessions[1], prefix: "" },
{ session: sessions[2], prefix: "├─ " },
{ session: sessions[3], prefix: "│ └─ " },
{ session: sessions[4], prefix: "└─ " },
{ session: sessions[5], prefix: "" },
{ session: sessions[6], prefix: "└─ " },
])
})
test("tracks usage across undo and redo boundaries", () => {
const messages = [assistant("msg_z", 10), assistant("msg_a", 30)]