From 9a146b0d34d5fef25d4402951ec449506c111fe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=93=9B=F0=9D=93=B2=F0=9D=93=BD=F0=9D=93=BD?= =?UTF-8?q?=F0=9D=93=B5=F0=9D=93=AE=20=F0=9D=93=95=F0=9D=93=BB=F0=9D=93=AA?= =?UTF-8?q?=F0=9D=93=B7=F0=9D=93=B4?= Date: Sat, 27 Jun 2026 00:44:00 +0000 Subject: [PATCH] feat(app): add child agent session picker --- .../regression/child-session-picker.spec.ts | 125 +++++++++++++++ packages/app/e2e/utils/mock-server.ts | 4 +- packages/app/src/i18n/en.ts | 8 + packages/app/src/pages/session.tsx | 26 +++ .../composer/session-child-session-picker.tsx | 148 ++++++++++++++++++ .../composer/session-child-sessions.test.ts | 53 +++++++ .../composer/session-child-sessions.ts | 24 +++ .../session-composer-region-controller.ts | 7 +- .../composer/session-composer-region.tsx | 11 ++ .../composer/session-composer-state.ts | 30 +++- .../pages/session/use-session-commands.tsx | 11 ++ 11 files changed, 444 insertions(+), 3 deletions(-) create mode 100644 packages/app/e2e/regression/child-session-picker.spec.ts create mode 100644 packages/app/src/pages/session/composer/session-child-session-picker.tsx create mode 100644 packages/app/src/pages/session/composer/session-child-sessions.test.ts create mode 100644 packages/app/src/pages/session/composer/session-child-sessions.ts diff --git a/packages/app/e2e/regression/child-session-picker.spec.ts b/packages/app/e2e/regression/child-session-picker.spec.ts new file mode 100644 index 0000000000..8ebf4cf540 --- /dev/null +++ b/packages/app/e2e/regression/child-session-picker.spec.ts @@ -0,0 +1,125 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/ChildSessionPicker" +const projectID = "proj_child_session_picker" +const rootID = "ses_root" +const newestRunningID = "ses_new_running" +const olderRunningID = "ses_old_running" +const newestIdleID = "ses_new_idle" + +test.use({ viewport: { width: 1280, height: 800 }, reducedMotion: "reduce" }) + +test("opens from the hotkey and navigates running child agents first", async ({ page }) => { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "child-session-picker", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { + "claude-opus-4-6": { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + limit: { context: 200_000 }, + }, + }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + }, + sessions: [ + session(rootID, "Coordinate the release", 1), + session(olderRunningID, "Review accessibility (@review subagent)", 2, "review"), + session(newestRunningID, "Trace the session state (@explore subagent)", 4, "explore"), + session(newestIdleID, "Summarize the API (@docs subagent)", 5, "docs"), + ], + statuses: { + [olderRunningID]: { type: "busy" }, + [newestRunningID]: { type: "busy" }, + [newestIdleID]: { type: "idle" }, + }, + pageMessages: () => ({ items: [] }), + }) + await configurePage(page) + + await page.goto(sessionHref(rootID)) + await expectSessionTitle(page, "Coordinate the release") + const input = page.getByRole("textbox", { name: /Ask anything/ }) + await expect(input).toBeVisible() + + await page.keyboard.press("Control+Shift+ArrowDown") + await expect(page.getByText("Child agents", { exact: true })).toBeVisible() + await expect(input).toBeHidden() + + const options = page.getByRole("option") + await expect(options).toHaveCount(3) + await expect(options.nth(0)).toContainText("@explore") + await expect(options.nth(1)).toContainText("@review") + await expect(options.nth(2)).toContainText("@docs") + await expect(options.nth(0)).toBeFocused() + + await page.keyboard.press("Escape") + await expect(page.getByText("Child agents", { exact: true })).toBeHidden() + await expect(input).toBeFocused() + + await page.keyboard.press("Control+Shift+ArrowDown") + await expect(options.nth(0)).toBeFocused() + await page.keyboard.press("ArrowDown") + await expect(options.nth(1)).toBeFocused() + await page.keyboard.press("Enter") + await expect(page).toHaveURL(sessionHref(olderRunningID)) + await expectSessionTitle(page, "Review accessibility") +}) + +function session(id: string, title: string, updated: number, agent?: string) { + return { + id, + slug: id, + projectID, + directory, + parentID: id === rootID ? undefined : rootID, + title, + agent, + version: "dev", + time: { created: updated, updated }, + } +} + +async function configurePage(page: Page) { + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + await page.addInitScript( + ({ directory, dirBase64, server, sessionID }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.global.dat:tabs", + JSON.stringify([{ type: "session", server, dirBase64, sessionId: sessionID }]), + ) + }, + { directory, dirBase64: base64Encode(directory), server, sessionID: rootID }, + ) +} + +function sessionHref(sessionID: string) { + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + return `/server/${base64Encode(server)}/session/${sessionID}` +} diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 875c3b7a96..ee02d75262 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -1,7 +1,7 @@ import type { Page, Route } from "@playwright/test" const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"]) -const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"]) +const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp"]) export interface MockServerConfig { provider: unknown @@ -17,6 +17,7 @@ export interface MockServerConfig { todos?: (sessionID: string) => unknown[] permissions?: unknown[] | (() => unknown[]) questions?: unknown[] | (() => unknown[]) + statuses?: Record } export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { @@ -36,6 +37,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { "/agent": [{ name: "build", mode: "primary" }], "/vcs": { branch: "main", default_branch: "main" }, "/session": config.sessions, + "/session/status": config.statuses ?? {}, } await page.route("**/*", async (route) => { diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 3872d01ae7..0158c6cd6c 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -46,6 +46,8 @@ export const dict = { "command.language.set": "Use language: {{language}}", "command.session.new": "New session", + "command.session.child": "View child agents", + "command.session.child.description": "Switch to a child agent session", "command.file.open": "Open file", "command.tab.close": "Close tab", "command.context.addSelection": "Add selection to context", @@ -242,6 +244,12 @@ export const dict = { "prompt.mode.shell.exit": "esc to exit", "session.child.promptDisabled": "Subagent sessions cannot be prompted.", "session.child.backToParent": "Back to main session.", + "session.child.picker.title": "Child agents", + "session.child.picker.description": "Choose an agent to view its session.", + "session.child.picker.hint": "Use ↑↓ to navigate · Enter to open", + "session.child.picker.agentFallback": "agent", + "session.child.picker.running": "Running", + "session.child.picker.complete": "Complete", "prompt.example.1": "Fix a TODO in the codebase", "prompt.example.2": "What is the tech stack of this project?", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 1c9391e0fd..3b54df3cbd 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -787,6 +787,20 @@ export default function Page() { inputRef?.focus() } + const closeChildSessions = () => { + composer.closeChildSessions() + if (isChildSession()) return + requestAnimationFrame(() => inputRef?.focus()) + } + + const toggleChildSessions = () => { + if (composer.childSessionPicker()) { + closeChildSessions() + return + } + composer.openChildSessions() + } + useComposerCommands() useSettingsCommand() useSessionCommands({ @@ -794,6 +808,9 @@ export default function Page() { setActiveMessage, focusInput, review: reviewTab, + childSessions: () => composer.childSessions().length, + childSessionsBlocked: composer.blocked, + toggleChildSessions, }) const openReviewFile = createOpenReviewFile({ @@ -1619,6 +1636,15 @@ export default function Page() { : legacySessionHref(sdk().directory, id), ) }, + onChildSessionSelect: (id) => { + composer.closeChildSessions() + navigate( + params.serverKey + ? sessionHref(requireServerKey(params.serverKey), id) + : legacySessionHref(sdk().directory, id), + ) + }, + onChildSessionsClose: closeChildSessions, setPromptRef: (el) => { inputRef = el }, diff --git a/packages/app/src/pages/session/composer/session-child-session-picker.tsx b/packages/app/src/pages/session/composer/session-child-session-picker.tsx new file mode 100644 index 0000000000..3414514864 --- /dev/null +++ b/packages/app/src/pages/session/composer/session-child-session-picker.tsx @@ -0,0 +1,148 @@ +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt" +import type { Session } from "@opencode-ai/sdk/v2" +import { For, onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { useLanguage } from "@/context/language" +import { childSessionTitle } from "./session-child-sessions" + +export function SessionChildSessionPicker(props: { + sessions: Session[] + activeID?: string + working: (sessionID: string) => boolean + onSelect: (sessionID: string) => void + onClose: () => void +}) { + const language = useLanguage() + const [store, setStore] = createStore({ + active: Math.max( + 0, + props.sessions.findIndex((session) => session.id === props.activeID), + ), + }) + const refs: HTMLButtonElement[] = [] + let frame: number | undefined + + const focus = (index: number) => { + const total = props.sessions.length + if (total === 0) return + const next = (index + total) % total + setStore("active", next) + refs[next]?.focus() + refs[next]?.scrollIntoView({ block: "nearest" }) + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault() + event.stopPropagation() + props.onClose() + return + } + if (event.key === "ArrowDown") { + event.preventDefault() + focus(store.active + 1) + return + } + if (event.key === "ArrowUp") { + event.preventDefault() + focus(store.active - 1) + return + } + if (event.key === "Home") { + event.preventDefault() + focus(0) + return + } + if (event.key === "End") { + event.preventDefault() + focus(props.sessions.length - 1) + } + } + + onMount(() => { + frame = requestAnimationFrame(() => focus(store.active)) + }) + onCleanup(() => { + if (frame !== undefined) cancelAnimationFrame(frame) + }) + + return ( + +
{language.t("session.child.picker.title")}
+
{props.sessions.length}
+ + } + footer={ + <> + +
+ {language.t("session.child.picker.hint")} +
+ + } + > +
{language.t("session.child.picker.description")}
+
+ + {(session, index) => { + const running = () => props.working(session.id) + return ( + + ) + }} + +
+
+ ) +} diff --git a/packages/app/src/pages/session/composer/session-child-sessions.test.ts b/packages/app/src/pages/session/composer/session-child-sessions.test.ts new file mode 100644 index 0000000000..dfdc886820 --- /dev/null +++ b/packages/app/src/pages/session/composer/session-child-sessions.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import type { Session } from "@opencode-ai/sdk/v2" +import { childSessionTitle, listChildSessions } from "./session-child-sessions" + +const session = (input: { id: string; parentID?: string; updated?: number; title?: string; agent?: string }) => + ({ + id: input.id, + parentID: input.parentID, + title: input.title ?? input.id, + agent: input.agent, + time: { created: 1, updated: input.updated ?? 1 }, + }) as Session + +describe("child session picker", () => { + test("lists siblings from a root or child session", () => { + const sessions = [ + session({ id: "root" }), + session({ id: "first", parentID: "root" }), + session({ id: "second", parentID: "root" }), + session({ id: "nested", parentID: "first" }), + ] + + expect(listChildSessions({ sessions, currentID: "root", working: () => false }).map((item) => item.id)).toEqual([ + "second", + "first", + ]) + expect(listChildSessions({ sessions, currentID: "first", working: () => false }).map((item) => item.id)).toEqual([ + "second", + "first", + ]) + }) + + test("sorts running sessions first and newest sessions within each group", () => { + const sessions = [ + session({ id: "root" }), + session({ id: "old-idle", parentID: "root", updated: 1 }), + session({ id: "new-idle", parentID: "root", updated: 4 }), + session({ id: "old-running", parentID: "root", updated: 2 }), + session({ id: "new-running", parentID: "root", updated: 3 }), + ] + const running = new Set(["old-running", "new-running"]) + + expect( + listChildSessions({ sessions, currentID: "root", working: (id) => running.has(id) }).map((item) => item.id), + ).toEqual(["new-running", "old-running", "new-idle", "old-idle"]) + }) + + test("removes the generated subagent suffix from descriptions", () => { + expect( + childSessionTitle(session({ id: "child", title: "Inspect the composer (@explore subagent)", agent: "explore" })), + ).toBe("Inspect the composer") + }) +}) diff --git a/packages/app/src/pages/session/composer/session-child-sessions.ts b/packages/app/src/pages/session/composer/session-child-sessions.ts new file mode 100644 index 0000000000..7342131818 --- /dev/null +++ b/packages/app/src/pages/session/composer/session-child-sessions.ts @@ -0,0 +1,24 @@ +import type { Session } from "@opencode-ai/sdk/v2" + +export function listChildSessions(input: { + sessions: Session[] + currentID?: string + working: (sessionID: string) => boolean +}) { + const current = input.currentID ? input.sessions.find((session) => session.id === input.currentID) : undefined + const parentID = current?.parentID ?? current?.id + if (!parentID) return [] + + return input.sessions + .filter((session) => session.parentID === parentID) + .toSorted((a, b) => { + const running = Number(input.working(b.id)) - Number(input.working(a.id)) + if (running !== 0) return running + if (a.time.updated !== b.time.updated) return b.time.updated - a.time.updated + return b.id.localeCompare(a.id) + }) +} + +export function childSessionTitle(session: Session) { + return session.title.replace(/\s+\(@[^)]+ subagent\)$/, "") || session.title +} diff --git a/packages/app/src/pages/session/composer/session-composer-region-controller.ts b/packages/app/src/pages/session/composer/session-composer-region-controller.ts index 4073f1c191..546913d61c 100644 --- a/packages/app/src/pages/session/composer/session-composer-region-controller.ts +++ b/packages/app/src/pages/session/composer/session-composer-region-controller.ts @@ -36,6 +36,8 @@ export function createSessionComposerRegionController(input: { revert: Accessor onResponseSubmit: () => void openParent: () => void + onChildSessionSelect: (sessionID: string) => void + onChildSessionsClose: () => void setPromptRef: (el: HTMLDivElement) => void setDockRef: (el: HTMLDivElement) => void }) { @@ -127,11 +129,14 @@ export function createSessionComposerRegionController(input: { revert: input.revert, onResponseSubmit: input.onResponseSubmit, openParent: input.openParent, + onChildSessionSelect: input.onChildSessionSelect, + onChildSessionsClose: input.onChildSessionsClose, setPromptRef: input.setPromptRef, setDockRef: input.setDockRef, + sessionID: input.sessionID, parentID, child: () => !!parentID(), - showComposer: () => !input.state.blocked() || !!parentID(), + showComposer: () => (!input.state.blocked() || !!parentID()) && !input.state.childSessionPicker(), handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt, promptReady: () => input.prompt.ready() || promptReady(), dock: () => (store.ready && input.state.dock()) || value() > 0.001, diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 84cd4dc691..cea850e60d 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -7,6 +7,7 @@ import { SessionFollowupDock } from "@/pages/session/composer/session-followup-d import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock" import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" import type { SessionComposerRegionController } from "./session-composer-region-controller" +import { SessionChildSessionPicker } from "./session-child-session-picker" export function SessionComposerRegion(props: { controller: SessionComposerRegionController @@ -59,6 +60,16 @@ export function SessionComposerRegion(props: { )} + + + +
sync().data.session_working(params.id ?? "") || blocked()) + const childSessions = createMemo(() => + listChildSessions({ + sessions: sync().data.session, + currentID: params.id, + working: sync().data.session_working.bind(sync().data), + }), + ) const [store, setStore] = createStore({ sessionID: params.id, responding: undefined as string | undefined, + childSessionPicker: false, dock: todos().length > 0 && !done() && live(), closing: false, opening: false, @@ -75,6 +84,11 @@ export function createSessionComposerController(options?: { closeMs?: number | ( return store.responding === perm.id }) + createEffect(() => { + if (!blocked()) return + setStore("childSessionPicker", false) + }) + const decide = (response: "once" | "always" | "reject") => { const perm = permissionRequest() if (!perm) return @@ -133,7 +147,13 @@ export function createSessionComposerController(options?: { closeMs?: number | ( if (!previous || previous[0] !== id) { if (timer) window.clearTimeout(timer) timer = undefined - setStore({ sessionID: id, dock: todoDockAtBoundary(next), closing: false, opening: false }) + setStore({ + sessionID: id, + childSessionPicker: false, + dock: todoDockAtBoundary(next), + closing: false, + opening: false, + }) if (next === "clear") clear() return } @@ -191,6 +211,14 @@ export function createSessionComposerController(options?: { closeMs?: number | ( permissionRequest, permissionResponding, decide, + childSessions, + childSessionWorking: (sessionID: string) => sync().data.session_working(sessionID), + childSessionPicker: () => store.childSessionPicker && !blocked() && childSessions().length > 0, + openChildSessions: () => { + if (blocked() || childSessions().length === 0) return + setStore("childSessionPicker", true) + }, + closeChildSessions: () => setStore("childSessionPicker", false), todos, dock: () => store.sessionID === params.id diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index fd2e29d31c..3beeb2741d 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -27,6 +27,9 @@ export type SessionCommandContext = { setActiveMessage: (message: UserMessage | undefined) => void focusInput: () => void review?: () => boolean + childSessions: () => number + childSessionsBlocked: () => boolean + toggleChildSessions: () => void } const withCategory = (category: string) => { @@ -419,6 +422,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => { navigate(`/${params.dir}/session`) }, }), + sessionCommand({ + id: "session.child.first", + title: language.t("command.session.child"), + description: language.t("command.session.child.description"), + keybind: "mod+shift+arrowdown", + disabled: actions.childSessions() === 0 || actions.childSessionsBlocked(), + onSelect: actions.toggleChildSessions, + }), sessionCommand({ id: "session.undo", title: language.t("command.session.undo"),