From 4c50829da052280f94fe90a640ab39808317cfe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 19 Jul 2026 18:19:32 +0200 Subject: [PATCH] fix(ui): defer session virtualization for closed left panel (#612) ## Summary - do not mount SessionList while the temporary left drawer is floating and closed - keep the session-list error state mutually exclusive with virtualized rows - register focused visibility-policy tests in the PR workflow ## Root cause SUID constructs temporary Drawer children while open is false. This occurs both after restarting with a persisted closed panel and when narrowing the window into mobile mode, which forces the left panel to become unpinned and closed. SessionSidebar then mounted the virtua Virtualizer in a detached staging document. virtua resolves ResizeObserver through ownerDocument.defaultView, which is null for that document. The failure is timing-dependent: if session hydration publishes rows while that closed mobile Drawer is detached, the synchronous render exception escapes through setSessionPage and is caught by fetchSessions as if the successful API request had failed. Opening the panel later therefore reveals an empty list or the misleading Unable to load sessions error. If hydration finishes under a different drawer lifecycle, the bug does not appear. Because the error UI and virtualized rows were both mounted, Retry cleared the error and immediately hit the same poisoned lifecycle again. ## Behavior Session fetching and startup restore continue while the panel is closed. The virtualized DOM is created only after the panel is open or pinned. Genuine list errors dispose the rows; Retry can then mount a clean virtualizer after succeeding. ## Reproduction 1. Narrow the window until CodeNomad enters mobile mode and the left panel can no longer remain pinned. 2. Leave the sessions panel closed while sessions hydrate, or restart in that state. 3. Open the left panel. 4. Before this fix, the list may be empty or show Unable to load sessions with a ResizeObserver null error. ## Validation - 19 focused session visibility, tree, and pagination tests - UI TypeScript typecheck - production Vite build - full Windows Tauri release build - NSIS installer bundle - regression test included in PR CI --- .github/workflows/pr-build.yml | 1 + .../instance/shell/SessionSidebar.tsx | 33 ++++++++++--------- .../session-list-visibility.test.ts | 29 ++++++++++++++++ .../src/components/session-list-visibility.ts | 15 +++++++++ packages/ui/src/components/session-list.tsx | 28 ++++++++++++++-- 5 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 packages/ui/src/components/session-list-visibility.test.ts create mode 100644 packages/ui/src/components/session-list-visibility.ts diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index a50c3f5e..aa599e28 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -104,6 +104,7 @@ jobs: - name: Test changed runnable UI behavior run: >- node --import tsx --test + packages/ui/src/components/session-list-visibility.test.ts packages/ui/src/lib/hooks/use-app-session-capture.test.ts packages/ui/src/lib/trailing-resync.test.ts packages/ui/src/stores/abort-created-workspace-cleanup.test.ts diff --git a/packages/ui/src/components/instance/shell/SessionSidebar.tsx b/packages/ui/src/components/instance/shell/SessionSidebar.tsx index 38628e80..fa80c1db 100644 --- a/packages/ui/src/components/instance/shell/SessionSidebar.tsx +++ b/packages/ui/src/components/instance/shell/SessionSidebar.tsx @@ -18,6 +18,7 @@ import AgentSelector from "../../agent-selector" import ModelSelector from "../../model-selector" import ThinkingSelector from "../../thinking-selector" import { getLogger } from "../../../lib/logger" +import { shouldMountSessionList } from "../../session-list-visibility" const log = getLogger("session") @@ -130,21 +131,23 @@ const SessionSidebar: Component = (props) => (
- { - const result = props.onNewSession() - if (result instanceof Promise) { - void result.catch((error) => log.error("Failed to create session:", error)) - } - }} - enableFilterBar={props.showSearch()} - showHeader={false} - showFooter={false} - /> + + { + const result = props.onNewSession() + if (result instanceof Promise) { + void result.catch((error) => log.error("Failed to create session:", error)) + } + }} + enableFilterBar={props.showSearch()} + showHeader={false} + showFooter={false} + /> +
{ + it("does not mount inside a closed floating drawer", () => { + assert.equal(shouldMountSessionList("floating-closed"), false) + assert.equal(shouldMountSessionList("floating-open"), true) + assert.equal(shouldMountSessionList("pinned"), true) + }) + + it("keeps the error state exclusive from session rows", () => { + assert.equal(shouldRenderSessionRows(true, true), false) + assert.equal(shouldRenderSessionRows(false, true), true) + assert.equal(shouldRenderSessionRows(false, false), false) + }) + + it("waits for the drawer viewport to enter a live window", () => { + const viewport = (isConnected: boolean, defaultView: unknown) => ({ + isConnected, + ownerDocument: { defaultView }, + }) as Pick + + assert.equal(isSessionListViewportAttached(viewport(true, null)), false) + assert.equal(isSessionListViewportAttached(viewport(false, {})), false) + assert.equal(isSessionListViewportAttached(viewport(true, {})), true) + }) +}) diff --git a/packages/ui/src/components/session-list-visibility.ts b/packages/ui/src/components/session-list-visibility.ts new file mode 100644 index 00000000..a7826eaf --- /dev/null +++ b/packages/ui/src/components/session-list-visibility.ts @@ -0,0 +1,15 @@ +import type { DrawerViewState } from "./instance/shell/types" + +export function shouldMountSessionList(drawerState: DrawerViewState): boolean { + return drawerState !== "floating-closed" +} + +export function isSessionListViewportAttached( + viewport: Pick, +): boolean { + return viewport.isConnected && Boolean(viewport.ownerDocument.defaultView) +} + +export function shouldRenderSessionRows(hasError: boolean, hasContent: boolean): boolean { + return !hasError && hasContent +} diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index dde63d0a..db154943 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -37,6 +37,7 @@ import { collectSessionThreadIds, findSessionThread, flattenVisibleSessionThread import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" import { useConfig } from "../stores/preferences" +import { isSessionListViewportAttached, shouldRenderSessionRows } from "./session-list-visibility" const log = getLogger("session") @@ -71,8 +72,28 @@ const SessionList: Component = (props) => { const [reloadingSessionIds, setReloadingSessionIds] = createSignal>(new Set()) const [now, setNow] = createSignal(Date.now()) const [listEl, setListEl] = createSignal() + const [listViewportAttached, setListViewportAttached] = createSignal(false) const [virtualizerHandle, setVirtualizerHandle] = createSignal() const [focusedSessionId, setFocusedSessionId] = createSignal() + let attachmentFrame: number | undefined + + const setListElement = (element: HTMLDivElement) => { + setListEl(element) + const detectAttachment = () => { + if (isSessionListViewportAttached(element)) { + attachmentFrame = undefined + setListViewportAttached(true) + return + } + setListViewportAttached(false) + if (typeof requestAnimationFrame !== "undefined") attachmentFrame = requestAnimationFrame(detectAttachment) + } + detectAttachment() + } + + onCleanup(() => { + if (attachmentFrame !== undefined) cancelAnimationFrame(attachmentFrame) + }) createEffect(() => { if (typeof window === "undefined") return @@ -843,7 +864,7 @@ const SessionList: Component = (props) => {
{ const target = event.target if (!(target instanceof Element)) return @@ -875,7 +896,10 @@ const SessionList: Component = (props) => {
- 0 || hasMore() || isFetchingSessions()}> + 0 || hasMore() || isFetchingSessions()), + )}>
0}>