Compare commits

...
Author SHA1 Message Date
Brendonovich 604d881aa4 fix(app): link background subagents to their sessions 2026-09-05 07:42:36 +00:00
3 changed files with 107 additions and 18 deletions
@@ -1,5 +1,5 @@
import { base64Encode } from "@opencode-ai/util/encode"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { OpenCodeEvent, SessionMessageAssistantTool, SessionMessageInfo } from "@opencode-ai/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,59 @@ 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)
})
}
test("shows parent lineage while the child timeline loads", async ({ page }) => {
await setup(page)
const requested = Promise.withResolvers<void>()
@@ -126,7 +179,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 +197,7 @@ 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 } = {}) {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -169,9 +222,9 @@ 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) : [] }),
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 +273,7 @@ function childSession() {
return session(childID, childTitle, 1700000001000, { parentID })
}
function parentMessages(): SessionMessageInfo[] {
function parentMessages(background = false): SessionMessageInfo[] {
const userID = "msg_user_0001"
const assistantID = "msg_assistant_0001"
return [
@@ -249,9 +302,25 @@ 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,
]
: []),
],
},
]
@@ -1,3 +1,4 @@
import { DataProvider } from "@opencode-ai/session-ui/context"
import { BackgroundMoveHint, BackgroundWorkSummary } from "./message-timeline"
const tasks = [
@@ -30,7 +31,13 @@ export const InlineMoveHint = {
export const SummaryPanelEntry = {
render: () => (
<div class="w-[280px] rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
<BackgroundWorkSummary tasks={tasks} />
<DataProvider
data={{ session: [], session_status: {}, session_diff: {} }}
directory="/project"
onSessionHref={(id) => `#${id}`}
>
<BackgroundWorkSummary tasks={tasks} />
</DataProvider>
</div>
),
}
@@ -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-ai/session-ui/actions"
import { useData } from "@opencode-ai/session-ui/context"
import { Button } from "@opencode-ai/ui/button"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Icon } from "@opencode-ai/ui/icon"
@@ -16,7 +18,7 @@ import { getFilename } from "@opencode-ai/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-ai/session-ui/timeline/projection"
import { createSessionTimelineRowRenderer } from "@opencode-ai/session-ui/timeline/row"
@@ -71,6 +73,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<HTMLButtonElement>()
const tasks = createMemo<BackgroundTask[]>((previous = []) => (props.tasks.length > 0 ? props.tasks : previous))
@@ -107,10 +110,7 @@ export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?:
}}
aria-label={language.plural("session.background.tasksRunning", tasks().length)}
>
<Icon
name="outline-arrow-to-corner-top-right"
class="shrink-0 text-v2-icon-icon-muted"
/>
<Icon name="outline-arrow-to-corner-top-right" class="shrink-0 text-v2-icon-icon-muted" />
<TextShimmer
as="span"
text={language.plural("session.background.tasksRunning", tasks().length)}
@@ -126,13 +126,26 @@ export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?:
>
<For each={tasks().slice(0, 10)}>
{(task) => (
<div
<Dynamic
component={task.type === "subagent" ? "a" : "div"}
data-component="session-background-list-item"
class="flex h-7 min-w-0 items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-none tracking-[-0.04px]"
class="flex h-7 min-w-0 items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px]"
classList={{
"hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none":
task.type === "subagent",
}}
href={task.type === "subagent" ? data.sessionHref?.(task.id) : undefined}
onClick={(event: MouseEvent) => {
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)
}}
>
<span class="shrink-0 text-v2-text-text-base">{taskType(task)}</span>
<span class="min-w-0 flex-1 truncate text-v2-text-text-faint">{task.label}</span>
</div>
</Dynamic>
)}
</For>
</Popover.Content>
@@ -386,8 +399,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