fix(tui): navigate nested subagents (#43290)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
cb39ea1136
commit
511b4556a2
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user