From d698746951183e9229ee3d25bb2286eb7afc1c51 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:37:56 +1000 Subject: [PATCH] fix(app): stabilize session navigation (#42477) --- .../subagent-child-navigation.spec.ts | 24 +++++++++++ packages/app/src/pages/directory-layout.tsx | 7 ++- .../pages/home/home-sessions-controller.tsx | 25 ++--------- packages/app/test-browser/auto-scroll.test.ts | 43 +++++++++++++++++++ .../src/v2/components/prompt-input/index.tsx | 2 +- packages/ui/src/hooks/create-auto-scroll.tsx | 20 +++++++++ 6 files changed, 97 insertions(+), 24 deletions(-) create mode 100644 packages/app/test-browser/auto-scroll.test.ts diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index 019cc156eca..ee3e108a524 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -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() + const release = Promise.withResolvers() + 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)) diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index c359ee7681e..6ccca5f53bc 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -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} > {props.children} diff --git a/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app/src/pages/home/home-sessions-controller.tsx index e29d6442622..94a5e483c30 100644 --- a/packages/app/src/pages/home/home-sessions-controller.tsx +++ b/packages/app/src/pages/home/home-sessions-controller.tsx @@ -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 }) diff --git a/packages/app/test-browser/auto-scroll.test.ts b/packages/app/test-browser/auto-scroll.test.ts new file mode 100644 index 00000000000..d0a5f60bd5f --- /dev/null +++ b/packages/app/test-browser/auto-scroll.test.ts @@ -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() +}) diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx index 21c3a39c2f6..15c5ca9d75a 100644 --- a/packages/session-ui/src/v2/components/prompt-input/index.tsx +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -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, diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx index cb76ca82897..6d5cf295e21 100644 --- a/packages/ui/src/hooks/create-auto-scroll.tsx +++ b/packages/ui/src/hooks/create-auto-scroll.tsx @@ -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