fix(app): stabilize session navigation (#42477)

This commit is contained in:
Luke Parker 2026-08-14 14:37:56 +10:00 committed by GitHub
parent 72d37cda81
commit d698746951
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 97 additions and 24 deletions

View file

@ -27,6 +27,30 @@ test("navigates to a subagent child session missing from the session list", asyn
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
})
test("keeps the parent visible while child lineage resolves", async ({ page }) => {
await setup(page)
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()
},
)
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await requested.promise
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
() => release.resolve(),
)
await expectSessionTitle(page, taskDescription)
})
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
const events: EventPayload[] = []
await setup(page, () => events.splice(0, 1))

View file

@ -32,6 +32,11 @@ export function DirectoryDataProvider(
if (props.server) return sessionHref(props.server, sessionID)
return `/${slug()}/session/${sessionID}`
}
const navigateToSession = async (sessionID: string) => {
const session = serverSync.session
await Promise.allSettled([session.lineage.resolve(sessionID), session.sync(sessionID)])
navigate(href(sessionID))
}
createEffect(() => {
// A draft lives at /new-session?draftId=… and has no directory segment to normalize.
@ -61,7 +66,7 @@ export function DirectoryDataProvider(
data={sync().data}
directory={directory}
sessionID={params.id}
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
onNavigateToSession={navigateToSession}
onSessionHref={href}
>
<LocalProvider>{props.children}</LocalProvider>

View file

@ -1,9 +1,8 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { skipToken, useQuery } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, createRoot, type JSX, startTransition } from "solid-js"
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
import { produce } from "solid-js/store"
import { useCommand } from "@/context/command"
import {
@ -113,26 +112,7 @@ export function createHomeSessionsController(home: HomeController) {
const key = `${ServerConnection.key(conn)}\0${record.session.id}`
if (prefetched.has(key)) return
prefetched.add(key)
createRoot((dispose) => {
try {
void ctx.sync.session
.sync(record.session.id)
.then(() =>
Promise.all(
(ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) =>
(ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => {
if (part.type !== "text" || !part.text) return []
return preloadMarkdown(part.text, part.id)
}),
),
),
)
.catch(() => {})
.finally(dispose)
} catch {
dispose()
}
})
void untrack(() => ctx.sync.session.sync(record.session.id)).catch(() => {})
})
})
@ -199,6 +179,7 @@ export function createHomeSessionsController(home: HomeController) {
const directory = project?.worktree ?? session.location.directory
const ctx = home.server.focusedContext()
if (!ctx) return
ctx.sync.session.remember(session)
ctx.projects.open(directory)
if (options?.background) {
tabs.addSessionTab({ server: connKey, sessionId: session.id })

View file

@ -0,0 +1,43 @@
import { expect, test } from "bun:test"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { createRoot } from "solid-js"
test("restores bottom anchoring when Suspense reattaches the scroll viewport", async () => {
const main = document.createElement("main")
const surface = document.createElement("div")
const viewport = document.createElement("div")
const content = document.createElement("div")
surface.append(viewport)
viewport.append(content)
main.append(surface)
document.body.append(main)
let scrollTop = 200
Object.defineProperties(viewport, {
clientHeight: { value: 100 },
scrollHeight: { value: 300 },
scrollTop: {
get: () => scrollTop,
set: (value: number) => {
scrollTop = Math.min(value, 200)
},
},
})
const dispose = createRoot((dispose) => {
const scroll = createAutoScroll({ working: () => true })
scroll.scrollRef(viewport)
scroll.contentRef(content)
return dispose
})
surface.remove()
viewport.scrollTop = 0
main.append(surface)
await Promise.resolve()
expect(viewport.scrollTop).toBe(200)
dispose()
main.remove()
})

View file

@ -162,7 +162,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
spellcheck={state.mode === "normal"}
// @ts-expect-error
autocomplete="off"
class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
style={{
"unicode-bidi": state.mode === "normal" ? "plaintext" : undefined,

View file

@ -186,6 +186,26 @@ export function createAutoScroll(options: AutoScrollOptions) {
},
)
createEffect(() => {
const el = store.scrollRef
if (!el) return
const root = el.closest("main")
if (!root) return
let connected = el.isConnected
const contains = (node: Node) => node === el || (node instanceof Element && node.contains(el))
const observer = new MutationObserver((records) => {
records.forEach((record) => {
if ([...record.removedNodes].some(contains)) connected = false
if (!record.addedNodes.length || ![...record.addedNodes].some(contains)) return
const reattached = !connected
connected = true
if (reattached) scrollToBottom(false)
})
})
observer.observe(root, { childList: true })
onCleanup(() => observer.disconnect())
})
createEffect(
on(options.working, (working: boolean) => {
settling = false