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
This commit is contained in:
Pascal André 2026-07-19 18:19:32 +02:00 committed by GitHub
parent 24a26807e0
commit 4c50829da0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 89 additions and 17 deletions

View file

@ -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

View file

@ -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<SessionSidebarProps> = (props) => (
</div>
<div class="session-sidebar flex flex-col flex-1 min-h-0">
<SessionList
instanceId={props.instanceId}
threads={props.threads()}
activeSessionId={props.activeSessionId()}
onSelect={props.onSelectSession}
onNew={() => {
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}
/>
<Show when={shouldMountSessionList(props.drawerState())}>
<SessionList
instanceId={props.instanceId}
threads={props.threads()}
activeSessionId={props.activeSessionId()}
onSelect={props.onSelectSession}
onNew={() => {
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}
/>
</Show>
<div class="session-sidebar-separator" />
<Show

View file

@ -0,0 +1,29 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { isSessionListViewportAttached, shouldMountSessionList, shouldRenderSessionRows } from "./session-list-visibility"
describe("session list visibility", () => {
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<HTMLElement, "isConnected" | "ownerDocument">
assert.equal(isSessionListViewportAttached(viewport(true, null)), false)
assert.equal(isSessionListViewportAttached(viewport(false, {})), false)
assert.equal(isSessionListViewportAttached(viewport(true, {})), true)
})
})

View file

@ -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<HTMLElement, "isConnected" | "ownerDocument">,
): boolean {
return viewport.isConnected && Boolean(viewport.ownerDocument.defaultView)
}
export function shouldRenderSessionRows(hasError: boolean, hasContent: boolean): boolean {
return !hasError && hasContent
}

View file

@ -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<SessionListProps> = (props) => {
const [reloadingSessionIds, setReloadingSessionIds] = createSignal<Set<string>>(new Set())
const [now, setNow] = createSignal(Date.now())
const [listEl, setListEl] = createSignal<HTMLDivElement>()
const [listViewportAttached, setListViewportAttached] = createSignal(false)
const [virtualizerHandle, setVirtualizerHandle] = createSignal<VirtualizerHandle>()
const [focusedSessionId, setFocusedSessionId] = createSignal<string>()
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<SessionListProps> = (props) => {
<div
class="session-list flex-1 overflow-y-auto"
ref={setListEl}
ref={setListElement}
onFocusIn={(event) => {
const target = event.target
if (!(target instanceof Element)) return
@ -875,7 +896,10 @@ const SessionList: Component<SessionListProps> = (props) => {
</div>
</Show>
<Show when={visibleProjection().ids.length > 0 || hasMore() || isFetchingSessions()}>
<Show when={shouldRenderSessionRows(
Boolean(sessionListError()),
listViewportAttached() && (visibleProjection().ids.length > 0 || hasMore() || isFetchingSessions()),
)}>
<div class="session-section">
<Show when={visibleProjection().ids.length > 0}>
<Virtualizer