Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion apps/webview/e2e/browser-smoke.e2e.mts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,65 @@ async function verifyLongThreadVirtualization(page: Page): Promise<void> {
{ 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;
Expand Down
11 changes: 4 additions & 7 deletions packages/client/workbench/src/surface/use-workbench-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 6 additions & 4 deletions packages/presentation/ui/src/chat/conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,14 @@ export function ConversationContent<T>({
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]);
Expand Down