Compare commits

...

1 Commits

Author SHA1 Message Date
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 9a146b0d34 feat(app): add child agent session picker 2026-06-27 00:44:00 +00:00
11 changed files with 444 additions and 3 deletions
@@ -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}`
}
+3 -1
View File
@@ -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<string, unknown>
}
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) => {
+8
View File
@@ -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?",
+26
View File
@@ -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
},
@@ -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 (
<DockPrompt
kind="question"
onKeyDown={onKeyDown}
header={
<div class="flex min-w-0 flex-1 items-center justify-between gap-3">
<div data-slot="question-header-title">{language.t("session.child.picker.title")}</div>
<div class="shrink-0 text-12-regular text-text-weak">{props.sessions.length}</div>
</div>
}
footer={
<>
<Button variant="ghost" size="large" onClick={props.onClose} aria-keyshortcuts="Escape">
{language.t("common.dismiss")}
</Button>
<div data-slot="question-footer-actions" class="text-12-regular text-text-weak">
{language.t("session.child.picker.hint")}
</div>
</>
}
>
<div data-slot="question-text">{language.t("session.child.picker.description")}</div>
<div
data-slot="question-options"
role="listbox"
aria-label={language.t("session.child.picker.title")}
class="max-h-64"
>
<For each={props.sessions}>
{(session, index) => {
const running = () => props.working(session.id)
return (
<button
ref={(el) => (refs[index()] = el)}
type="button"
role="option"
data-slot="question-option"
data-picked={session.id === props.activeID}
aria-selected={session.id === props.activeID}
onFocus={() => setStore("active", index())}
onClick={() => props.onSelect(session.id)}
>
<span
classList={{
"mt-1.5 size-2 shrink-0 rounded-full": true,
"bg-icon-success-base": running(),
"bg-icon-weak-base": !running(),
}}
aria-hidden="true"
/>
<span data-slot="question-option-main">
<span data-slot="option-label" class="flex items-center justify-between gap-3">
<span class="truncate">@{session.agent ?? language.t("session.child.picker.agentFallback")}</span>
<span
classList={{
"shrink-0 text-12-regular": true,
"text-text-base": running(),
"text-text-weak": !running(),
}}
>
{running()
? language.t("session.child.picker.running")
: language.t("session.child.picker.complete")}
</span>
</span>
<span data-slot="option-description" class="line-clamp-2">
{childSessionTitle(session)}
</span>
</span>
<Icon name="chevron-right" size="small" class="mt-1 shrink-0 text-icon-weak" aria-hidden="true" />
</button>
)
}}
</For>
</div>
</DockPrompt>
)
}
@@ -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")
})
})
@@ -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
}
@@ -36,6 +36,8 @@ export function createSessionComposerRegionController(input: {
revert: Accessor<SessionComposerRevertDock | undefined>
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,
@@ -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: {
)}
</Show>
<Show when={controller.state.childSessionPicker()}>
<SessionChildSessionPicker
sessions={controller.state.childSessions()}
activeID={controller.parentID() ? controller.sessionID() : undefined}
working={controller.state.childSessionWorking}
onSelect={controller.onChildSessionSelect}
onClose={controller.onChildSessionsClose}
/>
</Show>
<Show when={controller.showComposer()}>
<Show when={controller.dock()}>
<div
@@ -9,6 +9,7 @@ import { usePermission } from "@/context/permission"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
import { listChildSessions } from "./session-child-sessions"
export const todoState = (input: {
count: number
@@ -60,10 +61,18 @@ export function createSessionComposerController(options?: { closeMs?: number | (
)
const live = createMemo(() => 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
@@ -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"),