Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0ded253
feat(app): add subagents tab with live status, cost, and token tracking
sdpfigueiredo Jul 28, 2026
42be18b
test(app): cover subagents children derivation
sdpfigueiredo Jul 28, 2026
3e90ef2
test(app): assert subagent status updates reactively
sdpfigueiredo Jul 28, 2026
31618ba
test(app): cover subagent formatters status mapping and i18n
sdpfigueiredo Jul 28, 2026
570bf82
fix(app): resolve final review findings
sdpfigueiredo Jul 28, 2026
ca9bec6
fix(app): correct cost=0 rendering and await a test mock
sdpfigueiredo Jul 28, 2026
5f08ecb
Merge branch 'dev' into sub-agents-tab
sdpfigueiredo Aug 2, 2026
217fd65
Merge remote-tracking branch 'upstream/dev' into sub-agents-tab
sdpfigueiredo Aug 5, 2026
e46735b
fix(i18n): add session.agents keys to new locales from upstream
sdpfigueiredo Aug 5, 2026
fd44f68
Merge branch 'dev' into sub-agents-tab
sdpfigueiredo Aug 10, 2026
269edf5
Merge branch 'dev' into sub-agents-tab
sdpfigueiredo Aug 13, 2026
3c72cf5
Merge branch 'dev' into sub-agents-tab
sdpfigueiredo Aug 16, 2026
36c16e7
Merge branch 'dev' into sub-agents-tab
sdpfigueiredo Aug 17, 2026
742c522
Merge branch 'dev' into sub-agents-tab
sdpfigueiredo Aug 17, 2026
403b40e
Merge branch 'dev' into sub-agents-tab
sdpfigueiredo Aug 19, 2026
3a84b36
Merge branch 'dev' into sub-agents-tab
sdpfigueiredo Aug 19, 2026
27911d9
Merge branch 'dev' into sub-agents-tab
sdpfigueiredo Aug 20, 2026
d0ce80c
fix(i18n): add session.agents keys and dv plural variants
sdpfigueiredo Aug 22, 2026
9bbd35a
Merge remote-tracking branch 'upstream/dev' into sub-agents-tab
sdpfigueiredo Aug 22, 2026
fe7bd02
fix(i18n): translate session.agents keys for 44 locales
sdpfigueiredo Aug 22, 2026
3955873
Merge branch 'dev' into sub-agents-tab
sdpfigueiredo Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/app/src/components/session/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export { SessionHeader } from "./session-header"
export { SessionContextTab } from "./session-context-tab"
export { SessionAgentsTab } from "./session-agents-tab"
export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTabV2 } from "./session-sortable-tab-v2"
export { SessionSubAgentsUsage } from "./session-sub-agents-usage"
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view"
138 changes: 138 additions & 0 deletions packages/app/src/components/session/session-agents-tab.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
import type { ToolPart, ToolState } from "@opencode-ai/sdk/v2/client"

// Pure-function coverage for the terminal-state reader ported from the CLI footer
// (`packages/opencode/src/cli/cmd/run/subagent-data.ts:295-309`). Nothing is rendered here, but the
// module under test is a `.tsx` whose transitive `@solidjs/router` import evaluates client-only APIs
// at module scope, so it is stubbed the same way `src/components/file-tree.test.ts` does.
let text: typeof import("./session-agents-tab").text
let taskMetadata: typeof import("./session-agents-tab").taskMetadata
let taskSessionID: typeof import("./session-agents-tab").taskSessionID
let taskStatus: typeof import("./session-agents-tab").taskStatus

beforeAll(async () => {
await mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
}))
const mod = await import("./session-agents-tab")
text = mod.text
taskMetadata = mod.taskMetadata
taskSessionID = mod.taskSessionID
taskStatus = mod.taskStatus
})

const input = {}
const time = { start: 1, end: 2 }

function part(state: ToolState, metadata?: Record<string, unknown>): ToolPart {
return {
id: "prt_test",
sessionID: "ses_parent",
messageID: "msg_parent",
type: "tool",
callID: "call_test",
tool: "task",
state,
...(metadata ? { metadata } : {}),
}
}

function pending(): ToolState {
return { status: "pending", input, raw: "" }
}

function failed(error: string, metadata?: Record<string, unknown>): ToolState {
return { status: "error", input, error, time, ...(metadata ? { metadata } : {}) }
}

function completed(output: string): ToolState {
return { status: "completed", input, output, title: "", metadata: {}, time }
}

describe("taskStatus", () => {
const cases: [string, string, ToolPart][] = [
["completed wire status", "completed", part(completed(""))],
["completed with ordinary output", "completed", part(completed("Task completed in 4s."))],
// The `task` tool the user actually runs finalizes an abort as a successful result and signals it only
// in the output's first line - see `.omo/evidence/subagents-tab-cleanup/F3-manual-qa.md` section 5.
["completed with the long-running abort line", "cancelled", part(completed("Task aborted.\nRan for 12s."))],
["completed with the bare abort line", "cancelled", part(completed("Aborted"))],
["completed mentioning abort mid-line", "completed", part(completed("Successfully handled an abort case"))],
["completed with abort after the first line", "completed", part(completed("Task completed in 4s.\nAborted"))],
["error refined by state.metadata.interrupted", "cancelled", part(failed("boom", { interrupted: true }))],
["error refined by part.metadata.interrupted", "cancelled", part(failed("boom"), { interrupted: true })],
["error carrying the abort sentinel", "cancelled", part(failed("Tool execution aborted"))],
["error carrying the abort sentinel padded", "cancelled", part(failed(" Tool execution aborted "))],
["error with any other message", "error", part(failed("ENOENT"))],
["error with a non-boolean interrupted marker", "error", part(failed("boom", { interrupted: "true" }))],
["error with an empty message", "error", part(failed(""))],
["running wire status", "running", part({ status: "running", input, time: { start: 1 } })],
["pending wire status", "running", part(pending())],
]

test.each(cases)("%s maps to %p", (_name, expected, fixture) => {
expect(taskStatus(fixture)).toBe(expected)
})

test("an unmapped wire status falls back to running instead of throwing", () => {
const unmapped = part({ status: "queued", input, time } as unknown as ToolState)
expect(() => taskStatus(unmapped)).not.toThrow()
expect(taskStatus(unmapped)).toBe("running")
})

test("a state with no metadata carrier at all does not throw", () => {
expect(taskStatus(part(pending()))).toBe("running")
})
})

describe("taskMetadata", () => {
test("prefers the state carrier over the part carrier", () => {
expect(taskMetadata(part(failed("boom", { sessionId: "ses_state" }), { sessionId: "ses_part" }), "sessionId")).toBe(
"ses_state",
)
})

test("falls back to the part carrier when the state has no metadata", () => {
expect(taskMetadata(part(pending(), { sessionId: "ses_part" }), "sessionId")).toBe("ses_part")
})

test("is undefined when neither carrier holds the key", () => {
expect(taskMetadata(part(pending()), "sessionId")).toBeUndefined()
})
})

describe("taskSessionID", () => {
test("reads the lowercase-d spelling", () => {
expect(taskSessionID(part(pending(), { sessionId: "ses_child" }))).toBe("ses_child")
})

test("reads the uppercase-D spelling", () => {
expect(taskSessionID(part(pending(), { sessionID: "ses_child" }))).toBe("ses_child")
})

test("ignores a blank id so a pending part never joins on an empty key", () => {
expect(taskSessionID(part(pending(), { sessionId: " " }))).toBeUndefined()
expect(taskSessionID(part(pending()))).toBeUndefined()
})
})

describe("text", () => {
const cases: [unknown, string | undefined][] = [
["ses_child", "ses_child"],
[" padded ", "padded"],
["", undefined],
[" ", undefined],
[undefined, undefined],
[null, undefined],
[42, undefined],
[true, undefined],
[{}, undefined],
]

test.each(cases)("text(%p) is %p", (value, expected) => {
expect(text(value)).toBe(expected)
})
})
236 changes: 236 additions & 0 deletions packages/app/src/components/session/session-agents-tab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import { createMemo, For, Show, createSignal } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
import { useSubAgents } from "@/context/sub-agents"
import { useSessionLayout } from "@/pages/session/session-layout"
import { sessionHref, requireServerKey } from "@/utils/session-route"
import { Icon } from "@opencode-ai/ui/icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
import type { SessionStatus, ToolPart } from "@opencode-ai/sdk/v2/client"
import { createSessionContextFormatter } from "./session-context-format"

const COLLAPSE_THRESHOLD = 5

type DerivedStatus = SessionStatus["type"] | "cancelled" | "completed" | "error"

function AgentStatusIcon(props: { status: DerivedStatus }) {
return (
<Show
when={props.status === "busy"}
fallback={
<Icon
name="subagent"
size="small"
classList={{
"shrink-0": true,
"text-v2-state-fg-danger": props.status === "cancelled" || props.status === "error",
}}
/>
}
>
<SessionProgressIndicatorV2 class="size-3.5 shrink-0" />
</Show>
)
}

function statusLabel(status: DerivedStatus, t: (key: string) => string): string {
if (status === "busy") return t("session.agents.status.busy")
if (status === "retry") return t("session.agents.status.retry")
if (status === "cancelled") return t("session.agents.status.cancelled")
if (status === "completed") return t("session.agents.status.completed")
if (status === "error") return t("session.agents.status.error")
return t("session.agents.status.idle")
}

function formatTime(ts: number, locale: string): string {
return new Date(ts).toLocaleString(locale, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}

// Ported from the CLI footer's subagent reader, the authoritative source for a subagent's terminal
// state: packages/opencode/src/cli/cmd/run/subagent-data.ts:125-132,291-331.
export type TaskStatus = "running" | "completed" | "cancelled" | "error"

export function text(value: unknown): string | undefined {
if (typeof value !== "string") return undefined
return value.trim() || undefined
}

// `part.state.metadata` and `part.metadata` are separate carriers, and a pending state has neither.
export function taskMetadata(part: ToolPart, key: string) {
return ("metadata" in part.state ? part.state.metadata?.[key] : undefined) ?? part.metadata?.[key]
}

export function taskSessionID(part: ToolPart) {
return text(taskMetadata(part, "sessionId")) ?? text(taskMetadata(part, "sessionID"))
}

export function taskStatus(part: ToolPart): TaskStatus {
if (part.state.status === "completed") {
// A `task` tool that swallows the abort finalizes a normal successful result, so the cancellation
// survives only as the first line of the free-text output.
const firstLine = text(part.state.output)?.split("\n")[0]?.trim() ?? ""
if (firstLine === "Aborted" || firstLine.startsWith("Task aborted")) return "cancelled"
return "completed"
}

if (part.state.status === "error") {
// Cancellation is never a wire status, only a refinement of `error`.
const interrupted = taskMetadata(part, "interrupted") === true
if (interrupted || text(part.state.error) === "Tool execution aborted") return "cancelled"
return "error"
}

// `pending` and `running` both fall through here - there is no separate pending bucket.
return "running"
}

export function SessionAgentsTab() {
const sync = useSync()
const language = useLanguage()
const navigate = useNavigate()
const { params } = useSessionLayout()
const { children, totalCost } = useSubAgents()
const [expanded, setExpanded] = createSignal(false)

const formatter = createMemo(() => createSessionContextFormatter(language.intl()))

const visibleChildren = createMemo(() => {
const list = children()
if (expanded() || list.length <= COLLAPSE_THRESHOLD) return list
return list.slice(0, COLLAPSE_THRESHOLD)
})

const hasMore = createMemo(() => children().length > COLLAPSE_THRESHOLD && !expanded())

// The parent session's task tool parts are the only authoritative source for a child's terminal
// state, and they are already live in the store, so this joins without issuing any request.
const taskParts = createMemo(() => {
const parentID = params.id
if (!parentID) return new Map<string, ToolPart>()
const parts = (sync().data.message[parentID] ?? []).flatMap((message) => sync().data.part[message.id] ?? [])
return new Map(
parts
.filter((part): part is ToolPart => part.type === "tool" && part.tool === "task")
// A pending task part carries no metadata yet, so it has no child sessionID to join on.
.flatMap((part) => {
const childID = taskSessionID(part)
return childID ? [[childID, part] as const] : []
}),
)
})

const deriveStatus = (sessionID: string): DerivedStatus => {
const task = taskParts().get(sessionID)
const derived = task ? taskStatus(task) : undefined
if (derived && derived !== "running") return derived
const status = sync().data.session_status[sessionID]
if (status?.type === "busy") return "busy"
if (status?.type === "retry") return "retry"
return "idle"
}

const navigateToSession = (sessionID: string) => {
const serverKey = params.serverKey
if (!serverKey) return
navigate(sessionHref(requireServerKey(serverKey), sessionID))
}

return (
<ScrollView class="h-full">
<div class="px-6 pt-4 pb-10 flex flex-col gap-6">
<Show
when={children().length > 0}
fallback={<div class="text-12-regular text-text-weak">{language.t("session.agents.empty")}</div>}
>
<div class="flex flex-col gap-1">
<div class="text-12-regular text-text-weak">{language.t("session.agents.costTotal")}</div>
<div class="text-12-medium text-text-strong">{formatter().cost(totalCost())}</div>
</div>

<div class="flex flex-col gap-1">
<div class="text-12-regular text-text-weak">
{language.t("session.agents.costSubagents")} ({children().length})
</div>

<div class="flex flex-col gap-1">
<For each={visibleChildren()}>
{(session) => {
const status = createMemo(() => deriveStatus(session.id))
const tokenTotal = createMemo(() => {
const tokens = session.tokens
return tokens ? tokens.input + tokens.output + tokens.reasoning + tokens.cache.read : 0
})
const tokenLabel = createMemo(() => formatter().tokens(tokenTotal()))
return (
<button
type="button"
class="flex items-center gap-3 w-full rounded-md px-3 py-2 text-left hover:bg-surface-hover hover:cursor-pointer transition-colors"
onClick={() => navigateToSession(session.id)}
>
<AgentStatusIcon status={status()} />
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
<div class="flex items-start justify-between gap-2">
<span class="text-12-medium text-text-strong truncate min-w-0">
{session.title || session.id}
</span>
<Show when={session.model}>
{(model) => (
<span class="text-12-regular text-text-weak shrink-0 truncate max-w-full ml-2">
{model().providerID}/{model().id}
</span>
)}
</Show>
</div>
<div class="text-12-regular text-text-weak flex justify-between gap-2">
<div class="flex items-center gap-1.5 min-w-0 flex-wrap">
<span>{statusLabel(status(), language.t)}</span>
<Show when={tokenTotal() > 0}>
<span class="text-text-faint">·</span>
<span class="text-text-faint">
{tokenLabel()} {language.t("session.agents.tokens")}
</span>
</Show>
<span class="text-text-faint">·</span>
<span class="text-text-faint">
{formatTime(session.time.created, language.intl())}
<Show when={session.time.updated > session.time.created + 60_000}>
{" → "}
{formatTime(session.time.updated, language.intl())}
</Show>
</span>
</div>
<Show when={session.cost != null}>
<span class="text-12-regular text-text-weak whitespace-nowrap">
{formatter().cost(session.cost ?? 0)}
</span>
</Show>
</div>
</div>
</button>
)
}}
</For>
</div>

<Show when={hasMore()}>
<button
type="button"
class="text-12-regular text-text-weak hover:text-text-strong transition-colors text-left px-3 py-1"
onClick={() => setExpanded(true)}
>
{language.t("session.agents.showAll", { count: children().length })}
</button>
</Show>
</div>
</Show>
</div>
</ScrollView>
)
}
Loading
Loading