From 295ec7dbd9c81dd7562e026a49ed41246013e566 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 19 Oct 2026 13:06:16 -0400 Subject: [PATCH] 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. --- .../e2e/regression/timeline-stream.spec.ts | 100 +++++++++++++++++- packages/app/e2e/utils/timeline.ts | 8 ++ packages/session-ui/src/timeline/detail.ts | 11 ++ .../session-ui/src/timeline/projection.ts | 10 +- .../src/timeline/rows-current.test.ts | 63 +++++++++++ .../src/timeline/session-timeline-row.tsx | 46 ++++++-- .../session-ui/src/tools/tool-renderer.tsx | 2 +- 7 files changed, 226 insertions(+), 14 deletions(-) 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()