mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
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:
@@ -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, {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user