From b31f54eefa66a9f07f6cc112223f100cbed073f5 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sun, 2 Aug 2026 05:08:36 +0800 Subject: [PATCH] fix(chat): render selected conversation on first paint --- apps/webview/e2e/browser-smoke.e2e.mts | 60 ++++++++++++++++++- .../src/surface/use-workbench-sessions.ts | 11 ++-- .../presentation/ui/src/chat/conversation.tsx | 10 ++-- 3 files changed, 69 insertions(+), 12 deletions(-) diff --git a/apps/webview/e2e/browser-smoke.e2e.mts b/apps/webview/e2e/browser-smoke.e2e.mts index 8c478c8a..09da32d6 100644 --- a/apps/webview/e2e/browser-smoke.e2e.mts +++ b/apps/webview/e2e/browser-smoke.e2e.mts @@ -96,7 +96,65 @@ async function verifyLongThreadVirtualization(page: Page): Promise { { flags: RE_LONG_THREAD_TURN.flags, source: RE_LONG_THREAD_TURN.source }, ); - await page.locator('[data-thread-title]', { hasText: longThreadTitle }).click(); + const firstPaint = await page.evaluate( + ({ lastTurn, title }) => + new Promise<{ + bottomGap: number | null; + conversationTitle: string | null; + tailMounted: boolean; + }>((resolve, reject) => { + const frameId = requestAnimationFrame(() => { + try { + const thread = [...document.querySelectorAll('[data-thread-title]')].find((element) => + element.textContent.includes(title), + ); + if (!(thread instanceof HTMLElement)) throw new Error(`Missing thread: ${title}`); + thread.click(); + queueMicrotask(() => { + try { + const conversationTitle = + document.querySelector('[data-conversation-title]')?.textContent ?? null; + const scroll = document.querySelector('[role="log"]')?.firstElementChild; + const content = scroll?.firstElementChild; + if (!(scroll instanceof HTMLElement) || !(content instanceof HTMLElement)) { + throw new TypeError('Missing long-thread scroll surface'); + } + + const observer = new IntersectionObserver( + ([entry]) => { + observer.disconnect(); + resolve({ + bottomGap: entry.rootBounds + ? entry.boundingClientRect.bottom - entry.rootBounds.bottom + : null, + conversationTitle, + tailMounted: content.textContent.includes(`Turn ${lastTurn} —`), + }); + }, + { root: scroll }, + ); + observer.observe(content); + } catch (error) { + reject(new Error('Could not observe first conversation paint', { cause: error })); + } + }); + } catch (error) { + reject(new Error('Could not select the long thread', { cause: error })); + } + }); + void frameId; + }), + { lastTurn: longThreadTurns, title: longThreadTitle }, + ); + assert.ok( + firstPaint.conversationTitle?.includes(longThreadTitle), + `First paint still showed ${JSON.stringify(firstPaint.conversationTitle)}`, + ); + assert.ok(firstPaint.tailMounted, 'First paint did not show the long-thread tail'); + assert.ok( + firstPaint.bottomGap !== null && Math.abs(firstPaint.bottomGap) <= 2, + `First paint opened ${String(firstPaint.bottomGap)}px above bottom`, + ); await page.locator('[data-conversation-title]', { hasText: longThreadTitle }).waitFor(); await page.waitForFunction((lastTurn) => { const scroll = document.querySelector('[role="log"]')?.firstElementChild; diff --git a/packages/client/workbench/src/surface/use-workbench-sessions.ts b/packages/client/workbench/src/surface/use-workbench-sessions.ts index f9ae5ea7..f36dfbfd 100644 --- a/packages/client/workbench/src/surface/use-workbench-sessions.ts +++ b/packages/client/workbench/src/surface/use-workbench-sessions.ts @@ -17,7 +17,7 @@ import { withoutAutomationSessions } from '@linkcode/ui'; import { toastManager } from 'coss-ui/components/toast'; import { noop } from 'foxact/noop'; import { useEffect } from 'foxact/use-abortable-effect'; -import { useDeferredValue, useMemo, useRef } from 'react'; +import { useMemo, useRef } from 'react'; import { useTranslations } from 'use-intl'; import { captureProductEvent } from '../analytics/product-analytics'; import type { NavLocation } from '../navigation/history'; @@ -94,18 +94,15 @@ export function useWorkbenchSessions(onError: (err: unknown) => void): Workbench // Session page: every thread stays one click away in the sidebar, so we skip straight to the // new-thread draft instead of auto-opening an arbitrary recent session (an all-automation list // has nothing to open either; an explicit automation selection resolves against the full list). - // Deferred for the render path only: the outgoing thread stays painted while the incoming tree - // renders, instead of flashing through an empty surface. Mutations use the live store values. - const deferredSelectedId = useDeferredValue(selectedId); - const draft = explicitDraft ?? (deferredSelectedId === null ? LANDING_DRAFT : null); + const draft = explicitDraft ?? (selectedId === null ? LANDING_DRAFT : null); const active = useMemo(() => { if (draft) return null; // Only an explicit selection resolves a conversation. One absent from the loaded list must NOT // fall back to a different thread (wrong conversation); hold null while the effect below // refreshes the list so a click-through to a not-yet-listed session resolves. - return sessionById(sessions, deferredSelectedId); - }, [draft, deferredSelectedId, sessions]); + return sessionById(sessions, selectedId); + }, [draft, selectedId, sessions]); const activeId = active?.sessionId ?? null; const recordNavigation = useNavigationHistoryStore((state) => state.record); diff --git a/packages/presentation/ui/src/chat/conversation.tsx b/packages/presentation/ui/src/chat/conversation.tsx index 7a2e44da..61812450 100644 --- a/packages/presentation/ui/src/chat/conversation.tsx +++ b/packages/presentation/ui/src/chat/conversation.tsx @@ -78,12 +78,14 @@ export function ConversationContent({ return; } - // The library's instant path still waits for rAF; snap before paint while virtua settles. - const snapToBottom = (): void => { - if (state.isAtBottom) scroll.scrollTop = scroll.scrollHeight; + // Keep the DOM and virtua's external-scroll offset aligned while rows settle before paint. + const snapToBottom = (syncVirtualizer = false): void => { + if (!state.isAtBottom) return; + scroll.scrollTop = scroll.scrollHeight; + if (syncVirtualizer) scroll.dispatchEvent(new Event('scroll')); }; snapToBottom(); - const observer = new ResizeObserver(snapToBottom); + const observer = new ResizeObserver(() => snapToBottom(true)); observer.observe(content); return () => observer.disconnect(); }, [contentRef, reduceMotion, scrollRef, smoothConversationScrolling, state]);