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
84 changes: 75 additions & 9 deletions apps/webview/e2e/browser-smoke.e2e.mts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ const daemonDir = fileURLToPath(new URL('../../daemon', import.meta.url));
const viteCli = fileURLToPath(new URL('../../bin/vite.js', import.meta.resolve('vite')));
const mockThreadTitle = 'Wire the workbench to the daemon';
const longThreadTitle = 'Long thread · navigation testbed';
const longThreadTurns = 48;
const maxMountedRows = 10;
const RE_LONG_THREAD_TURN = /Turn (\d+) —/g;

interface ViteServer {
child: ChildProcess;
Expand Down Expand Up @@ -65,17 +68,52 @@ async function sendPrompt(page: Page, prompt: string, appErrors: string[]): Prom
}

async function verifyLongThreadVirtualization(page: Page): Promise<void> {
await page.evaluate(
({ flags, source }) => {
const host = document.querySelector('main');
if (!host) throw new Error('Missing workbench main');

const turnPattern = new RegExp(source, flags);
const mountedTurns = new Set<number>();
const collect = (node: Node): void => {
for (const match of (node.textContent ?? '').matchAll(turnPattern)) {
mountedTurns.add(Number(match[1]));
}
};
const process = (records: MutationRecord[]): void => {
for (const record of records) {
for (const node of record.addedNodes) collect(node);
}
};
const observer = new MutationObserver(process);
observer.observe(host, { childList: true, subtree: true });
Reflect.set(window, '__longThreadMountProbe', () => {
process(observer.takeRecords());
observer.disconnect();
return [...mountedTurns];
});
},
{ flags: RE_LONG_THREAD_TURN.flags, source: RE_LONG_THREAD_TURN.source },
);

await page.locator('[data-thread-title]', { hasText: longThreadTitle }).click();
await page.locator('[data-conversation-title]', { hasText: longThreadTitle }).waitFor();
await page.waitForFunction(() => {
await page.waitForFunction((lastTurn) => {
const scroll = document.querySelector('[role="log"]')?.firstElementChild;
const virtualizer = scroll?.firstElementChild?.firstElementChild;
return (
scroll instanceof HTMLElement &&
scroll.scrollHeight > scroll.clientHeight &&
(virtualizer?.childElementCount ?? Number.POSITIVE_INFINITY) < 10
(virtualizer?.childElementCount ?? Number.POSITIVE_INFINITY) < 10 &&
virtualizer?.textContent.includes(`Turn ${lastTurn} —`)
);
});
}, longThreadTurns);
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);

const metrics = await page.getByRole('log').evaluate((root) => {
const scroll = root.firstElementChild as HTMLElement;
Expand All @@ -85,11 +123,28 @@ async function verifyLongThreadVirtualization(page: Page): Promise<void> {
mountedRows: virtualizer?.childElementCount,
};
});
const mountedDuringSwitch = await page.evaluate(() => {
const finish = Reflect.get(window, '__longThreadMountProbe') as undefined | (() => number[]);
if (!finish) throw new Error('Missing long-thread mount probe');
Reflect.deleteProperty(window, '__longThreadMountProbe');
return finish();
});
assert.ok(
metrics.bottomDifference <= 2,
`Long thread opened ${metrics.bottomDifference}px above bottom`,
);
assert.ok((metrics.mountedRows ?? 0) < 10, `Long thread mounted ${metrics.mountedRows} rows`);
assert.ok(
(metrics.mountedRows ?? 0) < maxMountedRows,
`Long thread mounted ${metrics.mountedRows} rows`,
);
assert.ok(
mountedDuringSwitch.includes(longThreadTurns),
'Long thread tail was not observed during the switch',
);
assert.ok(
mountedDuringSwitch.length < maxMountedRows,
`Long thread mounted ${mountedDuringSwitch.length} distinct turns while switching`,
);
}

async function main(): Promise<void> {
Expand Down Expand Up @@ -162,20 +217,31 @@ async function verifyMockEntry(browser: Browser): Promise<void> {
const appErrors: string[] = [];
const page = await browser.newPage();
monitorApplicationErrors(page, server.origin, appErrors);
// This boundary verifies wire prompt/reload recovery, not animation timing: the product's
// reduce-motion fallback collapses the title animations so a throttled headless tab cannot
// hold the assertions below open across animation frames.
await page.addInitScript(() => {
if (localStorage.getItem('linkcode.workbench.appearance:v2') !== null) return;
localStorage.setItem(
'linkcode.workbench.appearance:v1',
JSON.stringify({ state: { reduceMotion: true }, version: 0 }),
'linkcode.workbench.appearance:v2',
JSON.stringify({
state: { reduceMotion: false, smoothConversationScrolling: false },
version: 0,
}),
);
});
await page.goto(server.origin, { waitUntil: 'domcontentloaded' });
await page.locator('#root > *').waitFor();

await page.getByRole('link', { name: 'Open settings' }).click();
await page.waitForURL(`${server.origin}/settings`);
await page.getByRole('link', { name: 'Appearance' }).click();
await page.waitForURL(`${server.origin}/settings/appearance`);
const smoothConversationSwitch = page.getByRole('switch', {
name: 'Smooth conversation follow',
});
assert.equal(await smoothConversationSwitch.getAttribute('aria-checked'), 'false');
await smoothConversationSwitch.click();
assert.equal(await smoothConversationSwitch.getAttribute('aria-checked'), 'true');
await smoothConversationSwitch.click();
assert.equal(await smoothConversationSwitch.getAttribute('aria-checked'), 'false');
await page.getByRole('link', { name: 'Back' }).click();
await page.waitForURL(`${server.origin}/`);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,19 @@ export function AppearanceRenderPrefsProvider({
children,
}: React.PropsWithChildren): React.ReactNode {
const reduceMotion = useAppearancePrefsStore((state) => state.reduceMotion);
const smoothConversationScrolling = useAppearancePrefsStore(
(state) => state.smoothConversationScrolling,
);
const codeThemeLight = useAppearancePrefsStore((state) => state.codeThemeLight);
const codeThemeDark = useAppearancePrefsStore((state) => state.codeThemeDark);
return (
<RenderPrefsProvider prefs={{ reduceMotion, codeTheme: [codeThemeLight, codeThemeDark] }}>
<RenderPrefsProvider
prefs={{
reduceMotion,
smoothConversationScrolling,
codeTheme: [codeThemeLight, codeThemeDark],
}}
>
{children}
</RenderPrefsProvider>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ export function AppearanceSettingsContainer({
const setTextSize = useAppearancePrefsStore((state) => state.setTextSize);
const reduceMotion = useAppearancePrefsStore((state) => state.reduceMotion);
const setReduceMotion = useAppearancePrefsStore((state) => state.setReduceMotion);
const smoothConversationScrolling = useAppearancePrefsStore(
(state) => state.smoothConversationScrolling,
);
const setSmoothConversationScrolling = useAppearancePrefsStore(
(state) => state.setSmoothConversationScrolling,
);
const codeThemeLight = useAppearancePrefsStore((state) => state.codeThemeLight);
const setCodeThemeLight = useAppearancePrefsStore((state) => state.setCodeThemeLight);
const codeThemeDark = useAppearancePrefsStore((state) => state.codeThemeDark);
Expand All @@ -42,6 +48,8 @@ export function AppearanceSettingsContainer({
onTextSizeChange={setTextSize}
reduceMotion={reduceMotion}
onReduceMotionChange={setReduceMotion}
smoothConversationScrolling={smoothConversationScrolling}
onSmoothConversationScrollingChange={setSmoothConversationScrolling}
codeThemeLight={codeThemeLight}
onCodeThemeLightChange={setCodeThemeLight}
codeThemeDark={codeThemeDark}
Expand Down
10 changes: 9 additions & 1 deletion packages/client/workbench/src/settings/appearance-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const PersistedAppearanceSchema = z
.object({
textSize: TextSizeSchema,
reduceMotion: z.boolean(),
smoothConversationScrolling: z.boolean(),
codeThemeLight: z.enum(CODE_THEME_LIGHT_IDS),
codeThemeDark: z.enum(CODE_THEME_DARK_IDS),
uiFont: z.string(),
Expand All @@ -38,6 +39,8 @@ export interface AppearancePrefsState {
textSize: TextSize;
/** When on, the UI suppresses non-essential motion (transitions, spinners, the streaming shimmer). */
reduceMotion: boolean;
/** Whether content growth follows the conversation bottom with animation. */
smoothConversationScrolling: boolean;
/** Shiki theme for chat code blocks under a light background. */
codeThemeLight: CodeThemeLightId;
/** Shiki theme for chat code blocks under a dark background. */
Expand All @@ -52,6 +55,7 @@ export interface AppearancePrefsState {
listDensity: ListDensity;
setTextSize: (textSize: TextSize) => void;
setReduceMotion: (reduceMotion: boolean) => void;
setSmoothConversationScrolling: (smoothConversationScrolling: boolean) => void;
setCodeThemeLight: (codeThemeLight: CodeThemeLightId) => void;
setCodeThemeDark: (codeThemeDark: CodeThemeDarkId) => void;
setUiFont: (uiFont: string) => void;
Expand All @@ -65,6 +69,7 @@ export const useAppearancePrefsStore = create<AppearancePrefsState>()(
(set) => ({
textSize: 'default',
reduceMotion: false,
smoothConversationScrolling: false,
codeThemeLight: 'github-light',
codeThemeDark: 'github-dark',
uiFont: '',
Expand All @@ -73,6 +78,8 @@ export const useAppearancePrefsStore = create<AppearancePrefsState>()(
listDensity: 'comfortable',
setTextSize: (textSize) => set({ textSize }),
setReduceMotion: (reduceMotion) => set({ reduceMotion }),
setSmoothConversationScrolling: (smoothConversationScrolling) =>
set({ smoothConversationScrolling }),
setCodeThemeLight: (codeThemeLight) => set({ codeThemeLight }),
setCodeThemeDark: (codeThemeDark) => set({ codeThemeDark }),
setUiFont: (uiFont) => set({ uiFont }),
Expand All @@ -81,11 +88,12 @@ export const useAppearancePrefsStore = create<AppearancePrefsState>()(
setListDensity: (listDensity) => set({ listDensity }),
}),
{
name: 'linkcode.workbench.appearance:v1',
name: 'linkcode.workbench.appearance:v2',
schema: PersistedAppearanceSchema,
partialize: (state) => ({
textSize: state.textSize,
reduceMotion: state.reduceMotion,
smoothConversationScrolling: state.smoothConversationScrolling,
codeThemeLight: state.codeThemeLight,
codeThemeDark: state.codeThemeDark,
uiFont: state.uiFont,
Expand Down
1 change: 1 addition & 0 deletions packages/client/workbench/src/settings/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export function useSettingsSearchKeywords(): SettingsSearchKeywords {
t('appearance.listDensityComfortable'),
t('appearance.listDensityCompact'),
t('appearance.reduceMotion'),
t('appearance.smoothConversationScrolling'),
t('appearance.codeThemeLight'),
t('appearance.codeThemeDark'),
t('appearance.uiFont'),
Expand Down
3 changes: 3 additions & 0 deletions packages/presentation/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,9 @@ export const en = {
textSizeLarge: 'Large',
reduceMotion: 'Reduce motion',
reduceMotionHint: 'Minimize animations and transitions.',
smoothConversationScrolling: 'Smooth conversation follow',
smoothConversationScrollingHint:
'Animate following content updates to the bottom. Reduce motion always jumps directly.',
listDensity: 'List density',
listDensityHint: 'Row height for long lists such as the thread sidebar and history.',
listDensityComfortable: 'Comfortable',
Expand Down
2 changes: 2 additions & 0 deletions packages/presentation/i18n/src/locales/zh-cn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,8 @@ export const zhCN = {
textSizeLarge: '大',
reduceMotion: '减弱动效',
reduceMotionHint: '尽量减少动画与过渡效果。',
smoothConversationScrolling: '平滑跟随对话',
smoothConversationScrollingHint: '内容更新时平滑跟随到底部;开启「减弱动效」时始终直接定位。',
listDensity: '列表密度',
listDensityHint: '会话侧栏与历史等长列表的行高。',
listDensityComfortable: '舒适',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,25 @@ it('ignores a stale asynchronous highlight after the theme changes', () => {
const code = '<main>Hello</main>';
const { container, rerender } = render(
<RenderPrefsProvider
prefs={{ reduceMotion: false, codeTheme: ['github-light', 'github-dark'] }}
prefs={{
reduceMotion: false,
smoothConversationScrolling: false,
codeTheme: ['github-light', 'github-dark'],
}}
>
<HighlightedCode code={code} language="html" />
</RenderPrefsProvider>,
);
const staleCallback = mocks.callbacks.get(code);

rerender(
<RenderPrefsProvider prefs={{ reduceMotion: false, codeTheme: ['min-light', 'min-dark'] }}>
<RenderPrefsProvider
prefs={{
reduceMotion: false,
smoothConversationScrolling: false,
codeTheme: ['min-light', 'min-dark'],
}}
>
<HighlightedCode code={code} language="html" />
</RenderPrefsProvider>,
);
Expand Down
31 changes: 29 additions & 2 deletions packages/presentation/ui/src/chat/conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,20 @@ import {
EmptyMedia,
EmptyTitle,
} from 'coss-ui/components/empty';
import { useLayoutEffect } from 'foxact/use-isomorphic-layout-effect';
import { ArrowDownIcon } from 'lucide-react';
import { useCallback } from 'react';
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
import type { VirtualizerHandle } from 'virtua';
import { Virtualizer } from 'virtua';
import { cn } from '../lib/cn';
import { useRenderPrefs } from '../render-prefs';

export type ConversationProps = React.ComponentProps<typeof StickToBottom>;

export function Conversation({ className, ...props }: ConversationProps): React.ReactNode {
const { reduceMotion, smoothConversationScrolling } = useRenderPrefs();

return (
<StickToBottom
// Named container: viewport-pinned overlays (the minimap) size themselves against the pane,
Expand All @@ -24,7 +28,7 @@ export function Conversation({ className, ...props }: ConversationProps): React.
// Instant initial positioning: animating from the top would page the whole virtualized
// history through the viewport.
initial="instant"
resize="smooth"
resize={smoothConversationScrolling && !reduceMotion ? 'smooth' : 'instant'}
role="log"
{...props}
/>
Expand Down Expand Up @@ -60,7 +64,30 @@ export function ConversationContent<T>({
virtualizerRef,
onScroll,
}: ConversationContentProps<T>): React.ReactNode {
const { scrollRef } = useStickToBottomContext();
const { contentRef, scrollRef, state } = useStickToBottomContext();
const { reduceMotion, smoothConversationScrolling } = useRenderPrefs();
useLayoutEffect(() => {
const content = contentRef.current;
const scroll = scrollRef.current;
if (
!content ||
!scroll ||
typeof ResizeObserver === 'undefined' ||
(smoothConversationScrolling && !reduceMotion)
) {
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;
};
snapToBottom();
const observer = new ResizeObserver(snapToBottom);
observer.observe(content);
return () => observer.disconnect();
}, [contentRef, reduceMotion, scrollRef, smoothConversationScrolling, state]);

return (
<StickToBottom.Content
className={cn('mx-auto max-w-3xl px-7 pb-6', className)}
Expand Down
3 changes: 3 additions & 0 deletions packages/presentation/ui/src/render-prefs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@ import { DEFAULT_CODE_THEME } from './code-themes';
export interface RenderPrefs {
/** When true, skip non-essential JS-driven motion. */
reduceMotion: boolean;
/** Whether conversation content growth follows the bottom with animation. */
smoothConversationScrolling: boolean;
/** [light, dark] shiki themes for chat code blocks. */
codeTheme: CodeThemePair;
}

const DEFAULT_RENDER_PREFS: RenderPrefs = {
reduceMotion: false,
smoothConversationScrolling: false,
codeTheme: DEFAULT_CODE_THEME,
};

Expand Down
Loading