fix(session-ui): show subagent and shell completions as their own rows

Completion notices for background subagents and shells were merged into the
neighbouring collapsed tool groups on the Compact and Detailed presets, so
they were never visible. They now render as separate rows at the point where
the work finished, starting at the column edge with an outcome icon and
colour (finished, failed, cancelled), the capitalized agent name shared with
the subagent card, and click-through to the child session. Subagent outcomes
follow the Subagents setting; shell outcomes follow Notices.

Timeline benchmark unchanged.
This commit is contained in:
Dax Raad
2026-10-19 13:06:16 -04:00
Unverified
parent 776186fda2
commit 295ec7dbd9
7 changed files with 226 additions and 14 deletions
@@ -11,6 +11,8 @@ import {
compactionStarted,
completedAssistantInfo,
directory,
inboxDelivered,
inboxEnqueued,
messageUpdated,
partUpdated,
reasoningPart,
@@ -75,7 +77,7 @@ test.describe("static projection", () => {
const notices = page.locator('[data-slot="session-timeline-notice"]')
await expect(notices).toHaveCount(4)
await expect(notices.nth(0)).toHaveText(/^Agent changed\s*Explore$/)
await expect(notices.nth(1)).toContainText("explore finished · Search code")
await expect(notices.nth(1)).toContainText("Explore finished · Search code")
await expect(notices.nth(2)).toContainText("Continuing after restart")
await expect(notices.nth(3)).toContainText("Skill · Review")
await expect(notices).toHaveClass([/text-text-weak/, /text-text-weak/, /text-text-weak/, /text-text-weak/])
@@ -548,6 +550,102 @@ for (const shellDefault of ["collapsed", "expanded"] as const) {
})
}
test("shows a background subagent's outcome as its own row where it finished", async ({ page }) => {
const childID = "ses_background_outcome"
const after = assistantMessage([], { id: "msg_after", completed: false })
const timeline = await setupTimeline(page, {
sessionMessages: [
user,
{
id: "msg_launch",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call_background",
name: "subagent",
state: {
status: "completed",
input: { description: "Map the inbox", agent: "explore", background: true },
content: [{ type: "text", text: "working" }],
metadata: { sessionID: childID, status: "running" },
},
time: { created: 2, completed: 3 },
},
{
type: "tool",
id: "call_read_before",
name: "read",
state: {
status: "completed",
input: { filePath: "README.md" },
content: [{ type: "text", text: "ok" }],
metadata: {},
},
time: { created: 2, completed: 3 },
},
],
time: { created: 2, completed: 3 },
},
],
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Map the inbox" })],
})
await expect(page.getByText(/^Used\s*1\s*Read$/)).toBeVisible()
await timeline.send([
inboxEnqueued({
sessionID,
inboxID: "msg_outcome",
item: {
type: "synthetic",
payload: {
text: '<subagent state="completed">done</subagent>',
description: "Map the inbox",
metadata: { source: "subagent", childID, agent: "explore", state: "completed" },
},
delivery: "steer",
},
}),
inboxDelivered({ sessionID, inboxID: "msg_outcome" }),
stepStarted(after),
...partUpdated({ ...toolPart("call_read_after", "read", "completed", { filePath: "AGENTS.md" }), messageID: after.id }),
messageUpdated(completedAssistantInfo(after)),
])
// The outcome sits between the work before and after it instead of folding into either group.
const notice = page.locator('[data-slot="session-timeline-notice"]')
await expect(page.getByText(/^Used\s*1\s*Read$/).or(notice)).toHaveText([
/^Used\s*1\s*Read$/,
/^Explore finished · Map the inbox$/,
/^Used\s*1\s*Read$/,
])
await expect(notice).toHaveAttribute("data-outcome", "completed")
// The outcome icon starts at the column edge like the subagent card, in a 16px box centered on the text line.
await expect
.poll(async () => {
const [card, icon, label] = await Promise.all([
page.locator('[data-component="task-tool-card"]').boundingBox(),
notice.locator('[data-slot="icon-svg"]').boundingBox(),
notice.locator(":scope > bdi").boundingBox(),
])
return {
inset: icon!.x - card!.x,
size: [icon!.width, icon!.height],
gap: label!.x - icon!.x - icon!.width,
centered: Math.abs(icon!.y + icon!.height / 2 - (label!.y + label!.height / 2)) <= 0.5,
}
})
.toEqual({ inset: 0, size: [16, 16], gap: 8, centered: true })
await notice.click()
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
})
test.describe("Working", () => {
test("shows Working between busy and reasoning states", async ({ page }) => {
const timeline = await setupTimeline(page, {
+8
View File
@@ -354,6 +354,14 @@ export function event(
return makeEvent(type, data)
}
export function inboxEnqueued(data: Extract<OpenCodeEvent, { type: "session.inbox.enqueued" }>["data"]) {
return makeEvent("session.inbox.enqueued", data)
}
export function inboxDelivered(data: Extract<OpenCodeEvent, { type: "session.inbox.delivered" }>["data"]) {
return makeEvent("session.inbox.delivered", data)
}
export function compactionStarted(data: Extract<OpenCodeEvent, { type: "session.compaction.started" }>["data"]) {
return makeEvent("session.compaction.started", data)
}
@@ -114,6 +114,17 @@ export function timelineCategory(
return "tools"
}
// Like the TUI, background work reports its outcome as its own row where it finished, never folded into a group.
// A subagent's outcome follows the subagent setting, so it shows wherever its launch does.
export function timelineNoticePlacement(message: SessionMessageInfo, detail: TimelineDetail): TimelinePlacement {
const source = message.type === "synthetic" ? message.metadata?.source : undefined
const placement = source === "subagent" ? detail.subagents.placement : detail.notices.placement
if (placement === "hidden") return timelineNoticeRequired(message) ? "separate" : "hidden"
return source === "subagent" || source === "shell" ? "separate" : placement
}
export function timelineNoticeRequired(message: SessionMessageInfo) {
if (message.type === "compaction") return message.status !== "completed"
@@ -17,7 +17,7 @@ import {
currentToolHasLoadedFiles,
} from "../message/current-tool-state"
import { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } from "./timeline-row"
import { timelineCategory, timelineNoticeRequired, type TimelineDetail } from "./detail"
import { timelineCategory, timelineNoticePlacement, timelineNoticeRequired, type TimelineDetail } from "./detail"
export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
@@ -371,8 +371,7 @@ export namespace Timeline {
!(detail?.commentary.placement === "hidden" && commentary.has(content)) &&
isRenderable(content, showReasoning, detail)
const visibleNotice = (message: Notice) =>
!detail || detail.notices.placement !== "hidden" || timelineNoticeRequired(message)
const visibleNotice = (message: Notice) => !detail || timelineNoticePlacement(message, detail) !== "hidden"
const visibleTurns = detail
? turns.filter((turn) => {
@@ -459,7 +458,8 @@ export namespace Timeline {
messages.flatMap((message) =>
message.type === "compaction" ||
message.type === "model-switched" ||
message.type === "location-switched"
message.type === "location-switched" ||
(message.type === "synthetic" && timelineNoticePlacement(message, detail) === "separate")
? [message.id]
: [],
),
@@ -619,7 +619,7 @@ export namespace Timeline {
return
case "notice":
if (detail?.notices.placement === "hidden" && !timelineNoticeRequired(entry.message)) return
if (detail && timelineNoticePlacement(entry.message, detail) === "hidden") return
appendAssistantSegment(assistantSegment)
assistantSegment = []
rows.push(new TimelineRow.Notice({ userMessageID: turnID, messageID: entry.message.id }))
@@ -554,6 +554,69 @@ describe("current session timeline rows", () => {
])
})
test.each([
["everything", ["msg_agent", "msg_shell"]],
["detailed", ["msg_agent", "msg_shell"]],
["compact", ["msg_agent", "msg_shell"]],
["quiet", ["msg_agent"]],
["text-only", []],
] as const)("shows background outcomes as their own rows where they finished: %s", (id, notices) => {
const step = (messageID: string, created: number) =>
({
id: messageID,
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [storyTool(`${messageID}_read`, "read", "completed", {})],
time: { created, completed: created },
}) satisfies SessionMessageInfo
const source: SessionMessageInfo[] = [
{ id: "msg_user", type: "user", text: "work", time: { created: 1 } },
step("msg_before", 2),
{
id: "msg_agent",
type: "synthetic",
text: "done",
description: "Map the inbox",
metadata: { source: "subagent", childID: "ses_child", agent: "explore", state: "completed" },
time: { created: 3 },
},
{
id: "msg_shell",
type: "synthetic",
text: "done",
description: "bun test",
metadata: { source: "shell", shellID: "sh_1", state: "completed", exit: 0 },
time: { created: 4 },
},
step("msg_after", 5),
]
const rows = Timeline.constructSessionMessageRows(
source,
false,
{ type: "idle" },
undefined,
false,
false,
undefined,
timelinePresets.find((preset) => preset.id === id)!.value,
).rows
const keys = rows.map(TimelineRow.key)
expect(keys.filter((key) => key.startsWith("notice:"))).toEqual(notices.map((notice) => `notice:${notice}`))
if (id === "compact")
expect(keys).toEqual([
"user-message:msg_user",
"assistant-part:context:context:msg_before:msg_before_read",
"notice:msg_agent",
"notice:msg_shell",
"assistant-part:context:context:msg_after:msg_after_read",
])
})
test.each([
["grouped", "assistant-part:context:context:msg_a1:msg_a1:text:0"],
["hidden", "assistant-part:context:context:msg_a1:tool_read"],
@@ -8,6 +8,7 @@ import { useI18n } from "@opencode/ui/context/i18n"
import { Option, Predicate, Schema } from "effect"
import { For, Show, createEffect, createMemo, type Accessor, type JSX } from "solid-js"
import { DiffChanges } from "@opencode/ui/diff-changes"
import { Icon } from "@opencode/ui/icon"
import { Dynamic } from "solid-js/web"
import type { SessionUserActions, SessionUserAttachmentReference, SessionUserComment } from "../actions"
import { useData } from "../context"
@@ -26,7 +27,7 @@ import {
SessionCompactionMessage,
SessionCompactionQueued,
} from "../message/message-content"
import { ToolRegistry, type ContextGroupPart } from "../tools/tool-renderer"
import { taskAgentName, ToolRegistry, type ContextGroupPart } from "../tools/tool-renderer"
import { GenericTool } from "../components/basic-tool"
import { SessionRetry } from "../components/session-retry"
import { SessionError } from "../components/session-error"
@@ -52,6 +53,8 @@ type Projection = ReturnType<typeof createReactiveTimelineProjection>
type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, TimelineRow.TurnGap>
type Outcome = "completed" | "error" | "cancelled"
export type SessionUserPresentation = {
displayText?: string
comments?: SessionUserComment[]
@@ -465,15 +468,16 @@ export function createSessionTimelineRowRenderer(input: {
const actor =
source === "shell"
? i18n.t("ui.tool.shell")
: Predicate.isString(agent)
? agent
: i18n.t("ui.tool.agent.default")
: ((Predicate.isString(agent) ? taskAgentName(agent, data.store.agent) : undefined) ??
i18n.t("ui.tool.agent.default"))
const state = message.metadata?.state
const outcome: Outcome = state === "error" || state === "cancelled" ? state : "completed"
return {
label: i18n.t(outcomeNoticeKey(Predicate.isString(state) ? state : undefined), { actor }),
label: i18n.t(outcomeNoticeKey(outcome), { actor }),
data: message.description,
outcome,
}
}
@@ -615,17 +619,27 @@ export function createSessionTimelineRowRenderer(input: {
<Dynamic
component={href() ? "a" : "div"}
data-slot="session-timeline-notice"
data-outcome={content().outcome}
class={`block w-full truncate ${props.grouped ? "py-1" : "pt-3 pb-1"} text-13-regular leading-text-compact text-text-weak ${inset()}`}
classList={{ "cursor-pointer": clickable() }}
classList={{ "cursor-pointer hover:text-v2-text-text-base": clickable() }}
href={href()}
role={clickable() && !href() ? "link" : undefined}
tabIndex={clickable() && !href() ? 0 : undefined}
onClick={navigate}
onKeyDown={navigateKey}
>
<Show when={content().outcome}>
{(outcome) => (
<Icon
name={outcomeNoticeIcon(outcome())}
size="normal"
class={`inline-block align-top me-2 ${outcomeNoticeColor(outcome())}`}
/>
)}
</Show>
<bdi
dir="auto"
class="font-[530]"
class={`font-[530] ${outcomeNoticeColor(content().outcome)}`}
classList={{ "text-v2-text-text-faint": message()?.type === "agent-switched" }}
>
{content().label}
@@ -1045,6 +1059,24 @@ function capitalizeAgent(agent: string) {
return agent.slice(0, 1).toUpperCase() + agent.slice(1)
}
function outcomeNoticeIcon(outcome: Outcome) {
if (outcome === "error") return "warning"
if (outcome === "cancelled") return "circle-ban-sign"
return "circle-check"
}
function outcomeNoticeColor(outcome: Outcome | undefined) {
if (!outcome) return ""
if (outcome === "error") return "text-v2-state-fg-danger"
if (outcome === "cancelled") return "text-v2-state-fg-warning"
return "text-v2-state-fg-info"
}
function outcomeNoticeKey(state: string | undefined) {
if (state === "error") return "ui.sessionTimeline.notice.failed"
@@ -229,7 +229,7 @@ function displayDirectory(path: string | undefined) {
import { resolveFileDiff } from "../components/session-diff"
function taskAgentName(raw: string | undefined, list?: readonly { name: string }[]) {
export function taskAgentName(raw: string | undefined, list?: readonly { name: string }[]) {
if (!raw) return undefined
const key = raw.toLowerCase()