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()