From f1ce69d2ceae4ed7e39c49210ae9264cbf86ffec Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:39:22 +0800 Subject: [PATCH] fix(app): link background subagents to their sessions (#47455) Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com> --- .../subagent-child-navigation.spec.ts | 134 ++++++++++++++++-- .../timeline/background-work.stories.tsx | 9 +- .../src/session/timeline/message-timeline.tsx | 31 ++-- .../src/timeline/session-timeline-row.tsx | 41 +++++- 4 files changed, 194 insertions(+), 21 deletions(-) diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index e25465810a5..5a24413ff13 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -1,5 +1,5 @@ import { base64Encode } from "@opencode/util/encode" -import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise" +import type { OpenCodeEvent, SessionMessageAssistantTool, SessionMessageInfo } from "@opencode/client/promise" import { expect, test, type Page } from "@playwright/test" import { currentSession, mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" @@ -35,6 +35,91 @@ test("returns to the parent session with Escape", async ({ page }) => { await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]) }) +for (const input of ["click", "keyboard", "narrow", "modified", "middle"] as const) { + test(`opens a background subagent from Session details with ${input}`, async ({ page, context }) => { + if (input === "narrow") await page.setViewportSize({ width: 390, height: 844 }) + await setup(page, { background: true }) + await page.goto(sessionHref(parentID)) + await expectSessionTitle(page, parentTitle) + if (input === "narrow") { + await page + .locator('[data-slot="session-mobile-view-navigation"]') + .getByRole("button", { name: "More options", exact: true }) + .click() + await page.getByRole("menuitem", { name: "Session details", exact: true }).click() + } + if (input !== "narrow") await page.getByRole("button", { name: "Session details", exact: true }).click() + const summary = page.getByRole("button", { name: "2 background tasks running", exact: true }) + await summary.click() + const list = page.locator('[data-component="session-background-list"]') + const link = list.getByRole("link", { name: `Explore ${taskDescription}`, exact: true }) + await expect(link).toHaveAttribute("href", sessionHref(childID)) + await expect(list.getByRole("link")).toHaveCount(1) + await expect(list.getByText("sleep 120", { exact: true })).toBeVisible() + + if (input === "modified" || input === "middle") { + const opened = context.waitForEvent("page") + await link.click(input === "middle" ? { button: "middle" } : { modifiers: ["ControlOrMeta"] }) + const child = await opened + await expect(child).toHaveURL(sessionHref(childID)) + await expect(page).toHaveURL(sessionHref(parentID)) + await child.close() + return + } + + if (input === "keyboard") { + await expect(link).toBeFocused() + await page.keyboard.press("Escape") + await expect(list).toBeHidden() + await expect(summary).toBeFocused() + await page.keyboard.press("Enter") + await expect(link).toBeFocused() + await page.keyboard.press("Enter") + } + if (input !== "keyboard") await link.click() + + await expect(page).toHaveURL(sessionHref(childID)) + await expectSessionTitle(page, input === "narrow" ? childTitle : taskDescription) + await expect(list).toBeHidden() + await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(1) + await page.keyboard.press("Escape") + await expect(page).toHaveURL(sessionHref(parentID)) + await expectSessionTitle(page, parentTitle) + }) +} + +for (const input of ["click", "keyboard", "modified", "middle"] as const) { + test(`opens a finished background subagent from the timeline with ${input}`, async ({ page, context }) => { + await setup(page, { completion: true }) + await page.goto(sessionHref(parentID)) + await expectSessionTitle(page, parentTitle) + await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click() + + const link = page.getByRole("link", { name: `explore finished ยท ${taskDescription}`, exact: true }) + await expect(link).toHaveAttribute("href", sessionHref(childID)) + + if (input === "modified" || input === "middle") { + const opened = context.waitForEvent("page") + await link.click(input === "middle" ? { button: "middle" } : { modifiers: ["ControlOrMeta"] }) + const child = await opened + await expect(child).toHaveURL(sessionHref(childID)) + await expect(page).toHaveURL(sessionHref(parentID)) + await child.close() + return + } + + if (input === "keyboard") await link.press("Enter") + if (input === "click") await link.click() + + await expect(page).toHaveURL(sessionHref(childID)) + await expectSessionTitle(page, taskDescription) + await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(1) + await page.keyboard.press("Escape") + await expect(page).toHaveURL(sessionHref(parentID)) + await expectSessionTitle(page, parentTitle) + }) +} + test("shows parent lineage while the child timeline loads", async ({ page }) => { await setup(page) const requested = Promise.withResolvers() @@ -126,7 +211,7 @@ test("keeps the parent tab selected while a loaded child session resolves", asyn test("shows the not found fallback when the viewed session is deleted", async ({ page }) => { const events: OpenCodeEvent[] = [] - await setup(page, () => events.splice(0, 1)) + await setup(page, { events: () => events.splice(0, 1) }) await openChildFromParent(page) await expectSessionTitle(page, taskDescription) @@ -144,7 +229,10 @@ test("shows the not found fallback when the viewed session is deleted", async ({ await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0) }) -async function setup(page: Page, events?: () => OpenCodeEvent[]) { +async function setup( + page: Page, + input: { events?: () => OpenCodeEvent[]; background?: boolean; completion?: boolean } = {}, +) { await mockOpenCodeServer(page, { directory, project: { @@ -169,9 +257,11 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) { default: { providerID: "opencode", modelID: "claude-opus-4-6" }, }, sessions: [session(parentID, parentTitle, 1700000000000), childSession()], - pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }), - events, - eventRetry: events ? 16 : undefined, + pageMessages: (sessionID) => ({ + items: sessionID === parentID ? parentMessages(input.background, input.completion) : [], + }), + events: input.events, + eventRetry: input.events ? 16 : undefined, }) // The child session resolves by ID but is absent from the session list, // matching a subagent session that has not been loaded into the list cache yet. @@ -220,7 +310,7 @@ function childSession() { return session(childID, childTitle, 1700000001000, { parentID }) } -function parentMessages(): SessionMessageInfo[] { +function parentMessages(background = false, completion = false): SessionMessageInfo[] { const userID = "msg_user_0001" const assistantID = "msg_assistant_0001" return [ @@ -249,11 +339,39 @@ function parentMessages(): SessionMessageInfo[] { status: "completed", input: { description: taskDescription, agent: "explore", prompt: "Inspect the delegated work." }, content: [{ type: "text", text: "Subagent finished" }], - metadata: { sessionID: childID }, + metadata: { sessionID: childID, ...(background ? { status: "running" } : {}) }, }, }, + ...(background + ? [ + { + type: "tool", + id: "call_shell_background", + name: "shell", + time: { created: 1700000001000, completed: 1700000002000 }, + state: { + status: "completed", + input: { command: "sleep 120" }, + content: [{ type: "text", text: "Running in background" }], + metadata: { shellID: "shell_background", status: "running" }, + }, + } satisfies SessionMessageAssistantTool, + ] + : []), ], }, + ...(completion + ? [ + { + id: "msg_subagent_completion", + type: "synthetic", + text: "Subagent completed", + description: taskDescription, + metadata: { source: "subagent", childID, agent: "explore", state: "completed" }, + time: { created: 1700000003000 }, + } satisfies SessionMessageInfo, + ] + : []), ] } diff --git a/packages/app/src/session/timeline/background-work.stories.tsx b/packages/app/src/session/timeline/background-work.stories.tsx index 5d21f87e75b..ec83e3e005f 100644 --- a/packages/app/src/session/timeline/background-work.stories.tsx +++ b/packages/app/src/session/timeline/background-work.stories.tsx @@ -1,3 +1,4 @@ +import { DataProvider } from "@opencode/session-ui/context" import { BackgroundMoveHint, BackgroundWorkSummary } from "./message-timeline" const tasks = [ @@ -30,7 +31,13 @@ export const InlineMoveHint = { export const SummaryPanelEntry = { render: () => (
- + `#${id}`} + > + +
), } diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index 823373de1ce..463c5bfd168 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -1,7 +1,9 @@ import { createEffect, createMemo, createSignal, For, on, onCleanup, Show, type Accessor, type JSX } from "solid-js" import { createStore } from "solid-js/store" +import { Dynamic } from "solid-js/web" import { createAnimatedPresence } from "@/runtime/animated-presence" import type { SessionUserActions } from "@opencode/session-ui/actions" +import { useData } from "@opencode/session-ui/context" import { Button } from "@opencode/ui/button" import { DiffChanges } from "@opencode/ui/diff-changes" import { Icon } from "@opencode/ui/icon" @@ -16,7 +18,7 @@ import { getFilename } from "@opencode/util/path" import { Popover } from "@kobalte/core/popover" import { SessionContextUsage } from "@/session/timeline/session-context-usage" import { useLanguage } from "@/runtime/i18n/language" -import { useData, useServer } from "@/runtime/server/current" +import { useServer } from "@/runtime/server/current" import { useWorkspaceLocation } from "@/workspaces/location" import { Timeline, TimelineRow } from "@opencode/session-ui/timeline/projection" import { createSessionTimelineRowRenderer } from "@opencode/session-ui/timeline/row" @@ -70,6 +72,7 @@ export function BackgroundMoveHint(props: { keybind?: string[]; onMove?: () => v export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: boolean }) { const language = useLanguage() + const data = useData() const [open, setOpen] = createSignal(false) const [triggerRef, setTriggerRef] = createSignal() const tasks = createMemo((previous = []) => (props.tasks.length > 0 ? props.tasks : previous)) @@ -106,10 +109,7 @@ export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: }} aria-label={language.plural("session.background.tasksRunning", tasks().length)} > - + {(task) => ( -
{ + if (task.type !== "subagent" || !data.navigateToSession) return + if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return + event.preventDefault() + setOpen(false) + data.navigateToSession(task.id) + }} > {taskType(task)} {task.label} -
+ )}
@@ -389,8 +402,8 @@ function MessageTimelineView( }, ) { const language = useLanguage() - const data = useData() const server = useServer() + const data = server.ctx.data const settings = useSettings() const sdk = useWorkspaceLocation() const sessionID = props.data.sessionID diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index 7bd3cccd173..1a15d2b9913 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -7,6 +7,7 @@ import type { import { useI18n } from "@opencode/ui/context/i18n" import { Tooltip } from "@opencode/ui/tooltip" import { For, Show, createMemo, type Accessor, type JSX } from "solid-js" +import { Dynamic } from "solid-js/web" import type { SessionUserActions, SessionUserComment } from "../actions" import { useData } from "../context" import { TimelineSeparator } from "../components/timeline-separator" @@ -388,6 +389,33 @@ export function createSessionTimelineRowRenderer(input: { const value = message() return value ? notice(value) : undefined }) + const childID = createMemo(() => { + const value = message() + if (value?.type !== "synthetic" || value.metadata?.source !== "subagent") return + const id = value.metadata.childID + if (typeof id === "string" && id) return id + }) + const href = createMemo(() => { + const id = childID() + if (id) return data.sessionHref?.(id) + }) + const clickable = createMemo(() => !!(childID() && (data.navigateToSession || href()))) + const open = () => { + const id = childID() + if (id) data.navigateToSession?.(id) + } + const navigate = (event: MouseEvent) => { + if (!childID() || !data.navigateToSession) return + if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return + event.preventDefault() + open() + } + const navigateKey = (event: KeyboardEvent) => { + if (!clickable() || href()) return + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + open() + } return ( <> @@ -410,9 +438,16 @@ export function createSessionTimelineRowRenderer(input: { )} - + } >