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