mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-10 15:19:19 +03:00
fix(app): link background subagents to their sessions (#47455)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
d39290fbb9
commit
f1ce69d2ce
@@ -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<void>()
|
||||
@@ -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,
|
||||
]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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: () => (
|
||||
<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/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<HTMLButtonElement>()
|
||||
const tasks = createMemo<BackgroundTask[]>((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)}
|
||||
>
|
||||
<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)}
|
||||
@@ -125,13 +125,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>
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Show when={compaction()}>
|
||||
@@ -410,9 +438,16 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
<Show
|
||||
when={content().items?.length}
|
||||
fallback={
|
||||
<div
|
||||
<Dynamic
|
||||
component={href() ? "a" : "div"}
|
||||
data-slot="session-timeline-notice"
|
||||
class={`w-full truncate ${props.grouped ? "py-1" : "pt-3 pb-1"} text-13-regular leading-text-compact text-text-weak ${inset()}`}
|
||||
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() }}
|
||||
href={href()}
|
||||
role={clickable() && !href() ? "link" : undefined}
|
||||
tabIndex={clickable() && !href() ? 0 : undefined}
|
||||
onClick={navigate}
|
||||
onKeyDown={navigateKey}
|
||||
>
|
||||
<bdi
|
||||
dir="auto"
|
||||
@@ -429,7 +464,7 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Dynamic>
|
||||
}
|
||||
>
|
||||
<div data-slot="session-timeline-notice" class={`w-full py-1 ${inset()}`}>
|
||||
|
||||
Reference in New Issue
Block a user