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
2 changes: 1 addition & 1 deletion src/web-ui/src/flow_chat/components/btw/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ UI state on each tab switch. Closing a tab releases its wrapper and view state.
For transcript windowing, scrolling, or review integration changes, run:

```bash
pnpm --dir src/web-ui run test:run src/flow_chat/components/btw/useBtwSessionState.test.tsx src/flow_chat/components/btw/useBtwPanelViewport.test.tsx src/flow_chat/components/btw/BtwVirtualSessionList.test.tsx src/flow_chat/components/btw/BtwSessionPanel.review-action.test.tsx src/flow_chat/components/btw/BtwSessionPanelLayout.test.ts
pnpm --dir src/web-ui run test:run src/flow_chat/components/btw/btwTailFollow.test.ts src/flow_chat/components/btw/useBtwSessionState.test.tsx src/flow_chat/components/btw/useBtwPanelViewport.test.tsx src/flow_chat/components/btw/BtwVirtualSessionList.test.tsx src/flow_chat/components/btw/BtwSessionPanel.review-action.test.tsx src/flow_chat/components/btw/BtwSessionPanelLayout.test.ts
```

Native WebView2 checks remain manual: long subagent transcripts, streaming tail
Expand Down
32 changes: 7 additions & 25 deletions src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
import { FlowChatManager } from '../../services/FlowChatManager';
import { useSessionCompletionReceipt } from '../../hooks/useSessionCompletionReceipt';
import { isImeOwnedKeyboardEvent } from '@/shared/utils/ime';
import { bindBtwTailFollow } from './btwTailFollow';

function findReviewChildByRequestId(
parentSessionId: string | null | undefined,
Expand Down Expand Up @@ -290,35 +291,15 @@ const BtwSessionPanelContent: React.FC<BtwSessionPanelProps & { viewState: BtwPa
if (!container) return;
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
setShowScrollToBottom(distanceFromBottom > 120);
if (distanceFromBottom < 80 && !viewState.restoring) {
shouldAutoScrollRef.current = true;
}
viewState.followTail = shouldAutoScrollRef.current;
}, [viewState]);
}, []);

useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
const handleWheel = (e: WheelEvent) => {
if (e.deltaY < 0) {
shouldAutoScrollRef.current = false;
viewState.followTail = false;
} else if (e.deltaY > 0) {
const { scrollTop, scrollHeight, clientHeight } = container;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
if (distanceFromBottom < 100) {
shouldAutoScrollRef.current = true;
}
}
};

container.addEventListener('wheel', handleWheel, { passive: true });
container.addEventListener('scroll', updateScrollAffordance, { passive: true });
updateScrollAffordance();
return () => {
container.removeEventListener('wheel', handleWheel);
container.removeEventListener('scroll', updateScrollAffordance);
};
return bindBtwTailFollow(container, (following) => {
shouldAutoScrollRef.current = following;
viewState.followTail = following;
}, updateScrollAffordance);
}, [updateScrollAffordance, viewState]);

useEffect(() => {
Expand Down Expand Up @@ -386,6 +367,7 @@ const BtwSessionPanelContent: React.FC<BtwSessionPanelProps & { viewState: BtwPa
onTabOpen: handleTabOpen,
sessionId: childSessionId,
activeSessionOverride: childSession ?? null,
allowUserMessageRollback: false,
allowUserMessageEdit: false,
allowTranscriptExport: viewKind !== 'review-check',
onExploreGroupToggle,
Expand Down
110 changes: 110 additions & 0 deletions src/web-ui/src/flow_chat/components/btw/btwTailFollow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { bindBtwTailFollow } from './btwTailFollow';

describe('embedded panel tail-follow intent', () => {
let scroller: HTMLDivElement;
let following: boolean;
let dispose: () => void;
const wheel = (deltaY: number) => scroller.dispatchEvent(new WheelEvent('wheel', { deltaY }));
const scroll = (top: number) => {
scroller.scrollTop = top;
scroller.dispatchEvent(new Event('scroll'));
};
beforeEach(() => {
vi.useFakeTimers();
scroller = document.createElement('div');
document.body.append(scroller);
Object.defineProperties(scroller, {
clientHeight: { value: 500 },
scrollHeight: { value: 1500, configurable: true },
clientWidth: { value: 500 },
});
scroller.scrollTop = 1000;
following = true;
dispose = bindBtwTailFollow(scroller, value => { following = value; }, vi.fn());
});
afterEach(() => { dispose(); scroller.remove(); vi.useRealTimers(); });

it('keeps small upward gestures detached even inside the old 80px threshold', () => {
wheel(-20);
scroll(980);
expect(following).toBe(false);
// A measurement correction back to the tail is not downward user intent.
scroll(1000);
expect(following).toBe(false);
});

it('requires downward movement to the actual tail, not proximity', () => {
wheel(-60); scroll(940);
wheel(20); scroll(960);
expect(following).toBe(false);
wheel(40);
expect(following).toBe(false);
scroll(999);
expect(following).toBe(true);
});

it('does not resume when streamed content moves the tail away', () => {
wheel(-60); scroll(940); wheel(60);
Object.defineProperty(scroller, 'scrollHeight', { value: 1600 });
scroll(1000);
expect(following).toBe(false);
});

it.each(['scrollend', 'timeout'])('expires downward intent on %s', end => {
wheel(-60); scroll(940); wheel(20); scroll(960);
if (end === 'scrollend') scroller.dispatchEvent(new Event('scrollend'));
else vi.advanceTimersByTime(181);
scroll(1000);
expect(following).toBe(false);
});

it('replaces pending downward intent when the user reverses direction', () => {
wheel(-60); scroll(940); wheel(20); scroll(960); wheel(-10);
scroll(1000);
expect(following).toBe(false);
});

it('supports keyboard scrolling but leaves nested controls alone', () => {
scroller.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
scroll(980);
expect(following).toBe(false);
const input = document.createElement('input');
scroller.append(input);
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
scroll(1000);
expect(following).toBe(false);
scroll(980);
scroller.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' }));
scroll(1000);
expect(following).toBe(true);
});

it('supports touch direction followed by actual scrolling', () => {
const touch = (type: string, clientY: number) => scroller.dispatchEvent(
new TouchEvent(type, { touches: [{ clientY } as Touch] }),
);
touch('touchstart', 100); touch('touchmove', 120); scroll(980);
expect(following).toBe(false);
touch('touchmove', 100); scroll(1000);
expect(following).toBe(true);
});

it('detaches a scrollbar drag and resumes only when dragged down to the tail', () => {
scroller.dispatchEvent(new MouseEvent('pointerdown', { clientX: 505, button: 0 }));
scroll(980);
expect(following).toBe(false);
scroll(1000);
expect(following).toBe(true);
document.dispatchEvent(new Event('pointerup'));
});

it('removes listeners and the expiry timer on disposal', () => {
wheel(-20); scroll(980); wheel(20);
dispose();
expect(vi.getTimerCount()).toBe(0);
scroll(1000);
expect(following).toBe(false);
});
});
102 changes: 102 additions & 0 deletions src/web-ui/src/flow_chat/components/btw/btwTailFollow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/** Bind user intent separately from scroll geometry and programmatic writes. */
export function bindBtwTailFollow(
scroller: HTMLElement,
setFollowing: (following: boolean) => void,
updateAffordance: () => void,
) {
let direction = 0;
let previousTop = scroller.scrollTop;
let timer: ReturnType<typeof setTimeout> | undefined;
let draggingScrollbar = false;
let touchY: number | undefined;
const clearIntent = () => {
direction = 0;
clearTimeout(timer);
};
// Fallback for hosts without scrollend; renew while the gesture is moving.
const expireIntent = () => {
clearTimeout(timer);
timer = setTimeout(clearIntent, 180);
};
const intend = (next: number) => {
direction = next;
previousTop = scroller.scrollTop;
if (next < 0) setFollowing(false);
expireIntent();
};
const wheel = (event: WheelEvent) => {
if (!event.ctrlKey && event.deltaY !== 0) intend(Math.sign(event.deltaY));
};
const scroll = () => {
const top = scroller.scrollTop;
const movement = top - previousTop;
if (draggingScrollbar && movement !== 0) {
direction = Math.sign(movement);
if (direction < 0) setFollowing(false);
}
if (direction > 0 && movement > 0 &&
scroller.scrollHeight - top - scroller.clientHeight <= 2) {
setFollowing(true);
clearIntent();
} else if (direction !== 0) {
expireIntent();
}
previousTop = top;
updateAffordance();
};
const keydown = (event: KeyboardEvent) => {
// Nested controls own their keyboard behavior.
if (event.target !== scroller || event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return;
if (['ArrowUp', 'PageUp', 'Home'].includes(event.key) || (event.key === ' ' && event.shiftKey)) intend(-1);
else if (['ArrowDown', 'PageDown', 'End', ' '].includes(event.key)) intend(1);
};
const touchstart = (event: TouchEvent) => {
clearIntent();
touchY = event.touches[0]?.clientY;
};
const touchmove = (event: TouchEvent) => {
const nextY = event.touches[0]?.clientY;
if (nextY !== undefined && touchY !== undefined && nextY !== touchY) intend(Math.sign(touchY - nextY));
touchY = nextY;
};
const touchend = () => { touchY = undefined; expireIntent(); };
const pointerdown = (event: PointerEvent) => {
if (event.target !== scroller || event.button !== 0) return;
const bounds = scroller.getBoundingClientRect();
const contentLeft = bounds.left + scroller.clientLeft;
// Only the native vertical scrollbar gutter, not clicks in the transcript.
draggingScrollbar = event.clientX < contentLeft || event.clientX >= contentLeft + scroller.clientWidth;
if (draggingScrollbar) {
clearIntent();
previousTop = scroller.scrollTop;
setFollowing(false);
}
};
const pointerup = () => { draggingScrollbar = false; clearIntent(); };
scroller.addEventListener('wheel', wheel, { passive: true });
scroller.addEventListener('scroll', scroll, { passive: true });
scroller.addEventListener('scrollend', clearIntent);
scroller.addEventListener('keydown', keydown);
scroller.addEventListener('touchstart', touchstart, { passive: true });
scroller.addEventListener('touchmove', touchmove, { passive: true });
scroller.addEventListener('touchend', touchend);
scroller.addEventListener('touchcancel', touchend);
scroller.addEventListener('pointerdown', pointerdown);
scroller.ownerDocument.addEventListener('pointerup', pointerup);
scroller.ownerDocument.addEventListener('pointercancel', pointerup);
updateAffordance();
return () => {
clearIntent();
scroller.removeEventListener('wheel', wheel);
scroller.removeEventListener('scroll', scroll);
scroller.removeEventListener('scrollend', clearIntent);
scroller.removeEventListener('keydown', keydown);
scroller.removeEventListener('touchstart', touchstart);
scroller.removeEventListener('touchmove', touchmove);
scroller.removeEventListener('touchend', touchend);
scroller.removeEventListener('touchcancel', touchend);
scroller.removeEventListener('pointerdown', pointerdown);
scroller.ownerDocument.removeEventListener('pointerup', pointerup);
scroller.ownerDocument.removeEventListener('pointercancel', pointerup);
};
}
Loading