fix(app): preserve subagent tab lineage (#44404)

This commit is contained in:
Brendan Allan 2026-08-23 22:18:39 +08:00 committed by GitHub
parent 1def4aa35a
commit 4c786f0ff8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 242 additions and 28 deletions

View file

@ -26,6 +26,32 @@ test("navigates to a subagent child session missing from the session list", asyn
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
})
test("shows parent lineage while the child timeline loads", async ({ page }) => {
await setup(page)
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(
(url) =>
url.pathname === `/api/session/${childID}/message` &&
url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
async (route) => {
requested.resolve()
await release.promise
await route.fallback()
},
)
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
expect(page.locator('[data-slot="session-title-parent"]')).toHaveText(parentTitle),
expect(page.locator('[data-slot="session-title-child"]')).toHaveText(childTitle),
]).finally(() => release.resolve())
await expectSessionTitle(page, taskDescription)
})
test("keeps the parent visible while the child session resolves", async ({ page }) => {
await setup(page)
const requested = Promise.withResolvers<void>()
@ -50,6 +76,44 @@ test("keeps the parent visible while the child session resolves", async ({ page
await expectSessionTitle(page, taskDescription)
})
test("keeps the parent tab selected while a loaded child session resolves", async ({ page }) => {
await setup(page)
await openChildFromParent(page)
await expectSessionTitle(page, taskDescription)
await page.goBack()
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)])
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === `/api/session/${childID}` && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
async (route) => {
requested.resolve()
await release.promise
await route.fallback()
},
)
const parentTab = page.locator("[data-titlebar-tab-slot]", {
has: page.locator('[data-slot="tab-title"]', { hasText: parentTitle }),
})
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
expect(parentTab).toHaveAttribute("data-active", "true"),
expect(page.locator('[data-slot="session-title-parent"]')).toHaveText(parentTitle),
]).finally(() => release.resolve())
await expectSessionTitle(page, taskDescription)
const home = page.getByRole("button", { name: "Home" })
await home.click()
await expect(page).toHaveURL("/")
const childTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(childID)}"]`)
await expect(childTab).toHaveCount(1)
await childTab.click()
await Promise.all([expect(page).toHaveURL(sessionHref(childID)), expectSessionTitle(page, taskDescription)])
})
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
const events: OpenCodeEvent[] = []
await setup(page, () => events.splice(0, 1))

View file

@ -204,7 +204,7 @@ export function ActiveSessionComposerRegion(props: {
}) {
const region = createSessionComposerRegionController({
state: props.model.region.state,
sessionID: () => props.session.identity.params.id,
parentID: props.session.data.parentID,
centered: props.model.region.centered,
onResponseSubmit: props.onResponseSubmit,
openParent: props.model.region.openParent,

View file

@ -1,21 +1,15 @@
import { type Accessor, createMemo } from "solid-js"
import { useData } from "@/runtime/server/current"
import type { Accessor } from "solid-js"
import type { SessionRequestModel } from "../requests/model"
export function createSessionComposerRegionController(input: {
state: SessionRequestModel
sessionID: Accessor<string | undefined>
parentID: Accessor<string | undefined>
centered: Accessor<boolean>
onResponseSubmit: () => void
openParent: () => void
setPromptRef: (el: HTMLDivElement) => void
setDockRef: (el: HTMLDivElement) => void
}) {
const data = useData()
const parentID = createMemo(() => {
const id = input.sessionID()
return id ? data.session.get(id)?.parentID : undefined
})
return {
state: input.state,
centered: input.centered,
@ -23,9 +17,9 @@ export function createSessionComposerRegionController(input: {
openParent: input.openParent,
setPromptRef: input.setPromptRef,
setDockRef: input.setDockRef,
parentID,
child: () => !!parentID(),
showComposer: () => !input.state.blocked() || !!parentID(),
parentID: input.parentID,
child: () => !!input.parentID(),
showComposer: () => !input.state.blocked() || !!input.parentID(),
}
}

View file

@ -15,6 +15,8 @@ import {
} from "./session-domain"
import { useSessionLayout } from "./session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useTabs } from "@/shell/tabs/tabs"
import { useServer } from "@/runtime/server/current"
const emptyMessages: SessionMessageInfo[] = []
const emptyUserMessages: SessionMessageUser[] = []
@ -23,6 +25,8 @@ const idle = { type: "idle" as const }
export function useSessionModel() {
const file = useFile()
const data = useData()
const server = useServer()
const shellTabs = useTabs()
const layout = useSessionLayout()
const location = useWorkspaceLocation()
const isDesktop = createMediaQuery("(min-width: 768px)")
@ -31,7 +35,16 @@ export function useSessionModel() {
const id = sessionID()
return id ? data.session.get(id) : undefined
})
const parentID = createMemo(() => info()?.parentID)
const parentID = createMemo(() => {
const current = info()?.parentID
if (current) return current
const id = sessionID()
if (!id) return
const tab = shellTabs.store.find(
(item) => item.type === "session" && item.server === server.key && item.routeSessionId === id,
)
return tab?.type === "session" ? (tab.routeParentId ?? tab.sessionId) : undefined
})
const parent = createMemo(() => {
const id = parentID()
return id ? data.session.get(id) : undefined

View file

@ -1,6 +1,7 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { Icon } from "@opencode-ai/ui/icon"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import { useNavigate } from "@solidjs/router"
import { createMemo, Show, type ParentProps } from "solid-js"
import { useServer } from "@/runtime/server/current"
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
@ -9,6 +10,7 @@ import { tabKey, useTabs } from "@/shell/tabs/tabs"
import { useSettings } from "@/settings/model"
import { pathKey } from "@/workspaces/path-key"
import { isWorkspaceDirectory } from "@/workspaces/paths"
import { sessionHref } from "@/shell/routes/session"
import { sessionTitle } from "./title"
export function SessionTitleHeader(props: ParentProps) {
@ -26,11 +28,36 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
const server = useServer()
const tabs = useTabs()
const settings = useSettings()
const info = createMemo(
() => tabs.info[tabKey({ type: "session", server: server.key, sessionId: props.sessionID })],
const navigate = useNavigate()
const tab = createMemo(() =>
tabs.store.find(
(item) =>
item.type === "session" &&
item.server === server.key &&
(item.sessionId === props.sessionID || item.routeSessionId === props.sessionID),
),
)
const info = createMemo(() => {
const current = tab()
return current ? tabs.info[tabKey(current)] : undefined
})
const parentID = createMemo(() => {
if (props.session?.parentID) return props.session.parentID
const current = tab()
if (current?.type !== "session" || current.routeSessionId !== props.sessionID) return
return current.routeParentId ?? current.sessionId
})
const parent = createMemo(() => {
const id = parentID()
return id ? server.ctx.data.session.get(id) : undefined
})
const parentTitle = createMemo(() => {
const id = parentID()
const current = tab()
return sessionTitle(parent()?.title ?? (current?.type === "session" && current.sessionId === id ? info()?.title : undefined))
})
const directory = createMemo(() => props.session?.location.directory ?? info()?.directory)
const title = createMemo(() => sessionTitle(props.session?.title ?? info()?.title))
const title = createMemo(() => sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)))
const project = createMemo(() => {
const projects = server.ctx.projects.list()
if (props.session) return projectForSession(props.session, projects)
@ -44,9 +71,16 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
const showProjectIcon = () =>
import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon() && !!directory()
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), directory() ?? ""))
const navigateParent = () => {
const id = parentID()
const current = tab()
if (!id || current?.type !== "session") return
tabs.rememberSessionRoute(current, id, parent()?.parentID)
navigate(sessionHref(server.key, id))
}
return (
<Show when={title() || showProjectIcon()}>
<Show when={title() || parentTitle() || showProjectIcon()}>
<SessionTitleHeader>
<div class="flex h-12 w-full items-center justify-between gap-2">
<div class="flex min-w-0 flex-1 items-center gap-1">
@ -69,9 +103,32 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
/>
</Show>
</span>
<Show when={parentTitle()}>
{(value) => (
<button
type="button"
data-slot="session-title-parent"
dir="auto"
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={navigateParent}
>
{value()}
</button>
)}
</Show>
<Show when={parentTitle() && title()}>
<span
data-slot="session-title-separator"
class="-translate-y-[0.5px] pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
</span>
</Show>
<Show when={title()}>
{(value) => (
<h1
data-slot={parentID() ? "session-title-child" : undefined}
dir="auto"
class="w-fit truncate rounded-[6px] px-2 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
>

View file

@ -5,6 +5,7 @@ import { LocalProvider } from "@/providers/models/selection"
import type { ServerConnection } from "@/runtime/server/registry"
import { sessionHref } from "@/shell/routes/session"
import { useData } from "@/runtime/server/current"
import { useTabs } from "@/shell/tabs/tabs"
export function SessionUIProvider(
props: ParentProps<{
@ -15,9 +16,17 @@ export function SessionUIProvider(
const navigate = useNavigate()
const params = useParams()
const data = useData()
const tabs = useTabs()
const directory = () => props.directory
const href = (sessionID: string) => sessionHref(props.server, sessionID)
const navigateToSession = async (sessionID: string) => {
const tab = tabs.store.find(
(item) =>
item.type === "session" &&
item.server === props.server &&
(item.sessionId === params.id || item.routeSessionId === params.id),
)
if (tab?.type === "session") tabs.rememberSessionRoute(tab, sessionID, params.id)
await data.session.sync(sessionID).catch(() => undefined)
navigate(href(sessionID))
}

View file

@ -7,8 +7,25 @@ export function migrateTabs(value: unknown): Tab[] {
if (!tab || typeof tab !== "object") return []
if (!("server" in tab) || typeof tab.server !== "string") return []
const server = tab.server as ServerConnection.Key
if (tab.type === "session" && typeof tab.sessionId === "string") {
return [{ type: tab.type, server, sessionId: tab.sessionId }]
if (
tab.type === "session" &&
typeof tab.sessionId === "string" &&
(tab.routeSessionId === undefined || typeof tab.routeSessionId === "string") &&
(tab.routeParentId === undefined || typeof tab.routeParentId === "string")
) {
return [
{
type: tab.type,
server,
sessionId: tab.sessionId,
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
? {
routeSessionId: tab.routeSessionId,
...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}),
}
: {}),
},
]
}
if (
tab.type === "draft" &&

View file

@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import type { SessionTab, Tab } from "./tabs"
import { tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { migrateTabs } from "./migration"
import type { ServerConnection } from "@/runtime/server/registry"
@ -27,6 +27,24 @@ describe("tab migration", () => {
expect(migrateTabs(null)).toEqual([])
expect(migrateTabs({})).toEqual([])
})
test("preserves the active child route", () => {
expect(migrateTabs([{ ...sessionTab("root"), routeSessionId: "child", routeParentId: "parent" }])).toEqual([
{ ...sessionTab("root"), routeSessionId: "child", routeParentId: "parent" },
])
})
test("drops an invalid child route", () => {
expect(migrateTabs([{ ...sessionTab("parent"), routeSessionId: 1 }])).toEqual([])
})
})
test("session tab identity stays rooted while its href follows the child route", () => {
const parent = sessionTab("parent")
const child = { ...parent, routeSessionId: "child" }
expect(tabKey(child)).toBe(tabKey(parent))
expect(tabHref(child)).toContain("/session/child")
})
describe("tab memory", () => {

View file

@ -19,6 +19,8 @@ export type SessionTab = {
type: "session"
server: ServerConnection.Key
sessionId: string
routeSessionId?: string
routeParentId?: string
}
export type DraftTab = {
@ -43,12 +45,18 @@ type RecentTab = {
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
export const tabHref = (tab: Tab) =>
tab.type === "draft" ? draftHref(tab.draftID) : sessionHref(tab.server, tab.sessionId)
tab.type === "draft" ? draftHref(tab.draftID) : sessionHref(tab.server, tab.routeSessionId ?? tab.sessionId)
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
export const tabKey = (tab: Tab) =>
tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${sessionHref(tab.server, tab.sessionId)}`
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: SessionInfo) {
return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
return tabs.some(
(tab) =>
tab.type === "session" &&
tab.server === server &&
(tab.sessionId === session.id || tab.routeSessionId === session.id),
)
}
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
@ -284,7 +292,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
removeSessionTab(input: Omit<SessionTab, "type">) {
updateClosed((stack) => removeClosedTabs(stack, input.server, [input.sessionId]))
const index = store.findIndex(
(tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId,
(tab) =>
tab.type === "session" &&
tab.server === input.server &&
(tab.sessionId === input.sessionId || tab.routeSessionId === input.sessionId),
)
if (index !== -1) removeTab(index)
},
@ -360,6 +371,18 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const key = tabKey(tab)
if (recentKey() !== key) setRecentKey(key)
},
rememberSessionRoute(tab: SessionTab, sessionId: string, parentId?: string) {
const index = store.findIndex((item) => tabKey(item) === tabKey(tab))
if (index === -1) return
setStore(
index,
produce((item) => {
if (item.type !== "session") return
item.routeSessionId = sessionId === item.sessionId ? undefined : sessionId
item.routeParentId = sessionId === item.sessionId ? undefined : parentId
}),
)
},
toggleHome(input: { home: boolean; current?: Tab }) {
if (input.home) {
const tab = store.find((tab) => tabKey(tab) === recentKey())

View file

@ -151,15 +151,24 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const tabs = useTabs()
const tabsStore = tabs.store
const tabsStoreActions = tabs
const [session] = createResource(
const [loadedSession] = createResource(
() => {
const route = layout.route()
if (route.type !== "session") return undefined
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
return conn ? { route, ctx: global.ensureServerCtx(conn) } : undefined
},
({ route, sdk }) => sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}),
({ route, ctx }) => ctx.sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}),
)
const session = createMemo(() => {
const route = layout.route()
if (route.type !== "session") return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
const cached = conn ? global.ensureServerCtx(conn).data.session.get(route.sessionId) : undefined
if (cached) return cached
const loaded = loadedSession()
return loaded?.id === route.sessionId ? loaded : undefined
})
const matchRoute = (route: LayoutRoute) => {
if (route.type === "home") return
@ -169,7 +178,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
if (route.type === "session") {
const main = tabsStore.find(
(item) =>
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
item.type === "session" &&
item.server === route.server &&
(item.sessionId === route.sessionId || item.routeSessionId === route.sessionId),
)
if (main) return main
const s = session()
@ -190,6 +201,14 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
if (!tabs.ready()) return
const tab = currentTab()
if (tab) {
const current = session()
if (
route.type === "session" &&
tab.type === "session" &&
(route.sessionId === tab.sessionId || current?.id === route.sessionId)
) {
tabs.rememberSessionRoute(tab, route.sessionId, current?.parentID)
}
tabs.remember(tab)
return
}