diff --git a/packages/app/e2e/regression/timeline-stream.spec.ts b/packages/app/e2e/regression/timeline-stream.spec.ts
index 9ffd5c5f263..164d4d0cf1e 100644
--- a/packages/app/e2e/regression/timeline-stream.spec.ts
+++ b/packages/app/e2e/regression/timeline-stream.spec.ts
@@ -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: 'done',
+ 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, {
diff --git a/packages/app/e2e/utils/timeline.ts b/packages/app/e2e/utils/timeline.ts
index 560beefdbb5..8a28bb2e632 100644
--- a/packages/app/e2e/utils/timeline.ts
+++ b/packages/app/e2e/utils/timeline.ts
@@ -354,6 +354,14 @@ export function event(
return makeEvent(type, data)
}
+export function inboxEnqueued(data: Extract["data"]) {
+ return makeEvent("session.inbox.enqueued", data)
+}
+
+export function inboxDelivered(data: Extract["data"]) {
+ return makeEvent("session.inbox.delivered", data)
+}
+
export function compactionStarted(data: Extract["data"]) {
return makeEvent("session.compaction.started", data)
}
diff --git a/packages/session-ui/src/timeline/detail.ts b/packages/session-ui/src/timeline/detail.ts
index 668a699fcd4..712c5a4d0a1 100644
--- a/packages/session-ui/src/timeline/detail.ts
+++ b/packages/session-ui/src/timeline/detail.ts
@@ -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"
diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts
index a00f79d4592..a684daa4a91 100644
--- a/packages/session-ui/src/timeline/projection.ts
+++ b/packages/session-ui/src/timeline/projection.ts
@@ -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 }))
diff --git a/packages/session-ui/src/timeline/rows-current.test.ts b/packages/session-ui/src/timeline/rows-current.test.ts
index 7192179193c..9965fe5a259 100644
--- a/packages/session-ui/src/timeline/rows-current.test.ts
+++ b/packages/session-ui/src/timeline/rows-current.test.ts
@@ -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"],
diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx
index 901c6bd22be..27fcc6a19eb 100644
--- a/packages/session-ui/src/timeline/session-timeline-row.tsx
+++ b/packages/session-ui/src/timeline/session-timeline-row.tsx
@@ -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
type FramedTimelineRow = Exclude
+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: {
+
+ {(outcome) => (
+
+ )}
+
{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"
diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx
index 5e8db5e4208..9c9dbc123f7 100644
--- a/packages/session-ui/src/tools/tool-renderer.tsx
+++ b/packages/session-ui/src/tools/tool-renderer.tsx
@@ -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()