Skip to content

Commit 4c50829

Browse files
authored
fix(ui): defer session virtualization for closed left panel (NeuralNomadsAI#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
1 parent 24a2680 commit 4c50829

5 files changed

Lines changed: 89 additions & 17 deletions

File tree

.github/workflows/pr-build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ jobs:
104104
- name: Test changed runnable UI behavior
105105
run: >-
106106
node --import tsx --test
107+
packages/ui/src/components/session-list-visibility.test.ts
107108
packages/ui/src/lib/hooks/use-app-session-capture.test.ts
108109
packages/ui/src/lib/trailing-resync.test.ts
109110
packages/ui/src/stores/abort-created-workspace-cleanup.test.ts

packages/ui/src/components/instance/shell/SessionSidebar.tsx

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import AgentSelector from "../../agent-selector"
1818
import ModelSelector from "../../model-selector"
1919
import ThinkingSelector from "../../thinking-selector"
2020
import { getLogger } from "../../../lib/logger"
21+
import { shouldMountSessionList } from "../../session-list-visibility"
2122

2223
const log = getLogger("session")
2324

@@ -130,21 +131,23 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
130131
</div>
131132

132133
<div class="session-sidebar flex flex-col flex-1 min-h-0">
133-
<SessionList
134-
instanceId={props.instanceId}
135-
threads={props.threads()}
136-
activeSessionId={props.activeSessionId()}
137-
onSelect={props.onSelectSession}
138-
onNew={() => {
139-
const result = props.onNewSession()
140-
if (result instanceof Promise) {
141-
void result.catch((error) => log.error("Failed to create session:", error))
142-
}
143-
}}
144-
enableFilterBar={props.showSearch()}
145-
showHeader={false}
146-
showFooter={false}
147-
/>
134+
<Show when={shouldMountSessionList(props.drawerState())}>
135+
<SessionList
136+
instanceId={props.instanceId}
137+
threads={props.threads()}
138+
activeSessionId={props.activeSessionId()}
139+
onSelect={props.onSelectSession}
140+
onNew={() => {
141+
const result = props.onNewSession()
142+
if (result instanceof Promise) {
143+
void result.catch((error) => log.error("Failed to create session:", error))
144+
}
145+
}}
146+
enableFilterBar={props.showSearch()}
147+
showHeader={false}
148+
showFooter={false}
149+
/>
150+
</Show>
148151

149152
<div class="session-sidebar-separator" />
150153
<Show
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import assert from "node:assert/strict"
2+
import { describe, it } from "node:test"
3+
4+
import { isSessionListViewportAttached, shouldMountSessionList, shouldRenderSessionRows } from "./session-list-visibility"
5+
6+
describe("session list visibility", () => {
7+
it("does not mount inside a closed floating drawer", () => {
8+
assert.equal(shouldMountSessionList("floating-closed"), false)
9+
assert.equal(shouldMountSessionList("floating-open"), true)
10+
assert.equal(shouldMountSessionList("pinned"), true)
11+
})
12+
13+
it("keeps the error state exclusive from session rows", () => {
14+
assert.equal(shouldRenderSessionRows(true, true), false)
15+
assert.equal(shouldRenderSessionRows(false, true), true)
16+
assert.equal(shouldRenderSessionRows(false, false), false)
17+
})
18+
19+
it("waits for the drawer viewport to enter a live window", () => {
20+
const viewport = (isConnected: boolean, defaultView: unknown) => ({
21+
isConnected,
22+
ownerDocument: { defaultView },
23+
}) as Pick<HTMLElement, "isConnected" | "ownerDocument">
24+
25+
assert.equal(isSessionListViewportAttached(viewport(true, null)), false)
26+
assert.equal(isSessionListViewportAttached(viewport(false, {})), false)
27+
assert.equal(isSessionListViewportAttached(viewport(true, {})), true)
28+
})
29+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { DrawerViewState } from "./instance/shell/types"
2+
3+
export function shouldMountSessionList(drawerState: DrawerViewState): boolean {
4+
return drawerState !== "floating-closed"
5+
}
6+
7+
export function isSessionListViewportAttached(
8+
viewport: Pick<HTMLElement, "isConnected" | "ownerDocument">,
9+
): boolean {
10+
return viewport.isConnected && Boolean(viewport.ownerDocument.defaultView)
11+
}
12+
13+
export function shouldRenderSessionRows(hasError: boolean, hasContent: boolean): boolean {
14+
return !hasError && hasContent
15+
}

packages/ui/src/components/session-list.tsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { collectSessionThreadIds, findSessionThread, flattenVisibleSessionThread
3737
import { getLogger } from "../lib/logger"
3838
import { copyToClipboard } from "../lib/clipboard"
3939
import { useConfig } from "../stores/preferences"
40+
import { isSessionListViewportAttached, shouldRenderSessionRows } from "./session-list-visibility"
4041
const log = getLogger("session")
4142

4243

@@ -71,8 +72,28 @@ const SessionList: Component<SessionListProps> = (props) => {
7172
const [reloadingSessionIds, setReloadingSessionIds] = createSignal<Set<string>>(new Set())
7273
const [now, setNow] = createSignal(Date.now())
7374
const [listEl, setListEl] = createSignal<HTMLDivElement>()
75+
const [listViewportAttached, setListViewportAttached] = createSignal(false)
7476
const [virtualizerHandle, setVirtualizerHandle] = createSignal<VirtualizerHandle>()
7577
const [focusedSessionId, setFocusedSessionId] = createSignal<string>()
78+
let attachmentFrame: number | undefined
79+
80+
const setListElement = (element: HTMLDivElement) => {
81+
setListEl(element)
82+
const detectAttachment = () => {
83+
if (isSessionListViewportAttached(element)) {
84+
attachmentFrame = undefined
85+
setListViewportAttached(true)
86+
return
87+
}
88+
setListViewportAttached(false)
89+
if (typeof requestAnimationFrame !== "undefined") attachmentFrame = requestAnimationFrame(detectAttachment)
90+
}
91+
detectAttachment()
92+
}
93+
94+
onCleanup(() => {
95+
if (attachmentFrame !== undefined) cancelAnimationFrame(attachmentFrame)
96+
})
7697

7798
createEffect(() => {
7899
if (typeof window === "undefined") return
@@ -843,7 +864,7 @@ const SessionList: Component<SessionListProps> = (props) => {
843864

844865
<div
845866
class="session-list flex-1 overflow-y-auto"
846-
ref={setListEl}
867+
ref={setListElement}
847868
onFocusIn={(event) => {
848869
const target = event.target
849870
if (!(target instanceof Element)) return
@@ -875,7 +896,10 @@ const SessionList: Component<SessionListProps> = (props) => {
875896
</div>
876897
</Show>
877898

878-
<Show when={visibleProjection().ids.length > 0 || hasMore() || isFetchingSessions()}>
899+
<Show when={shouldRenderSessionRows(
900+
Boolean(sessionListError()),
901+
listViewportAttached() && (visibleProjection().ids.length > 0 || hasMore() || isFetchingSessions()),
902+
)}>
879903
<div class="session-section">
880904
<Show when={visibleProjection().ids.length > 0}>
881905
<Virtualizer

0 commit comments

Comments
 (0)