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
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/web-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"remark-rehype": "^11.1.2",
"remark-stringify": "^11.0.0",
"semver": "7.7.4",
"tabbable": "^6.5.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"yaml": "^2.8.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,41 @@ to content, it stops being a reservation and becomes the compensation engine
that was removed in "remove synthetic tail-space scrolling" — do not rebuild
that under a new name.

## Opening Reveal

The opening transcript uses opacity and `aria-hidden` until its existing reveal
condition settles. `FlowChatOpeningBoundary` provides a local pointer shield,
non-passive scroll interception, and focus guards that skip the transcript in
both Tab directions. Programmatic focus is returned outside the transcript with
`preventScroll`; React capture handlers also block descendant portal activation.
The sidebar and composer remain available. Focus candidate checks run only in
response to interaction and prune opening transcript subtrees before measuring.

Avoid inherited `visibility` and whole-transcript `inert` transitions here: a
visibility reveal measured 260.9ms of style work, and a staged opacity/inert
trace isolated 177.6ms to removing inert (aria-hidden 0.2ms, opacity 0.4ms).
The boundary replacement still needs runtime performance and keyboard/reader
validation. It does not reproduce inert's browser find-in-page exclusion. A new
auto-opening portal must coordinate its presentation with opening; ancestor
aria-hidden and the pointer shield cannot hide a portalled surface. Removing a
geometry read alone only moves pending style/layout work. Read-only probes now
measure before and after the actual React reveal commit. A staged diagnostic
measured 176.7ms and 177.7ms of style work when removing the before/after focus
guards. Both guards now remain mounted after reveal with `tabIndex=-1` and focus
redirection disabled, preserving transcript sibling structure. The intrusive
staged diagnostic has been removed; the saving from stable guards still needs
runtime confirmation using the read-only reveal probes.

## Transcript Width

The scroller's `--_flow-chat-top-mask-start` is registered as non-inheriting:
only its own edge mask consumes the value. An opening trace placed a 562.3ms
forced style/layout read immediately after `data-scroll-at-start` changed this
variable. The registration isolates that state change from message descendants;
A same-session follow-up reduced the maximum follow height read from 562.3ms
to 3.6ms and advanced the second opening frame callback from 1803.3ms to
1197.7ms. Initial row measurement and reveal-related style/layout stalls remain.

The main transcript scroller uses `overflow-y: scroll` together with
`scrollbar-gutter: stable`. Its native scrollbar space must remain present when
collapsing content removes vertical overflow, including desktop WebViews where
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,20 @@ each missing what the other had.
| `modelRoundItemMemo.test.ts` | settled rows refresh continuation labels and tool grouping hints without invalidating equivalent hints |
| `flowChatTailFollow.test.ts` | the three-quarter reservation and `hold-tail` geometry |
| `flowChatCollapseMotion.test.ts` | collapse does not move earlier content |
| `useFlowChatFollowOutput.test.tsx` | one-shot new-Turn reveal, frame loop, blank crossing, resize realign |
| `useFlowChatFollowOutput.test.tsx` | one-shot new-Turn reveal, frame loop, blank crossing, resize realign, opening readback publication and ownership/lifecycle gates |
| `../../tool-cards/useToolCardHeightContract.test.tsx` | tool cards reflow rather than compensate |
| `flowChatHistoryBoundary.test.ts` | the screenful lead, and the latch's own predicate |
| `flowChatLiveTailWindow.test.ts` | "does the transcript still reach the newest Turn" |
| `flowChatViewportAnchor.test.ts` | anchor geometry and the DOM contract |
| `useFlowChatViewportAnchor.test.tsx` | capture, restore, carry, the settle window |
| `VirtualMessageList.session-boundary.test.tsx` | prepend compensation, the ask, navigation-target current Turn with gesture/follow/session handoff, and search placement only outside the readable viewport |
| `FlowChatOpeningBoundary.test.tsx` | opening-only activation/scroll isolation, bidirectional focus skipping, programmatic focus return, and reveal cleanup; DOM contracts only |
| `ModernFlowChatContainer.history-state.test.tsx` | history presentation and the submission event |
| `flowChatViewportOwnership.test.ts` | the priority order, preemption, expiry |
| `../../../infrastructure/diagnostics/flowChatViewportDiagnostics.test.ts` | coalescing, placement sampling, the switch |
| `useFlowChatVirtualizer.test.ts` | the offsets-and-positions boundary |
| `useFlowChatVirtualizer.measurement.test.tsx` | `measureRenderedItems` against a real virtualizer |
| `useFlowChatVirtualizer.initial-window.test.tsx` | tail-first window, empty hydration, head default, one-time seed, user-scroll takeover, opening readbacks before native events, suspended viewport, measured-window reconciliation with delayed scroll/scroll-end delivery; supplied DOM geometry, not performance validation |
| `useFlowChatVirtualizer.aim.test.tsx` | the re-aim, and giving it up on takeover |
| `VirtualMessageList.layout.test.ts` | the item-height estimate and the spacer |
| `FlowChatTurnRail.test.tsx` | single-marker emphasis, neighboring hover fan, independent keyboard focus, reduced motion, and rail navigation |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,49 @@ than its content's.

## What Belongs to the Virtualizer

On a tail-following open, the virtualizer seeds its initial offset at the last
item's estimated start. A desktop trace previously mounted rows 0..13 before
moving to 22..33, with 372.3ms charged to the first head-row measurement. The
seed selects a tail window without first mounting the head; real heights and
the existing follow owner still determine the settled position. This is a
one-time seed, not an ongoing tail lock. Initial empty hydration waits for items
before consuming it. History-window presentation and saved reading-position
restoration retain the default initial window. Tests cover window selection
using the real virtualizer with supplied DOM geometry. A same-session desktop
retest started at rows 27..33: rowRef total fell from 377.3ms to 4.2ms and the
post-reveal probe completed at 806.7ms instead of 1540.3ms. This is a single-trace
comparison, not paint timing or remote validation. The remaining tail-window
contraction led to the measurement reconciliation described below.

Opening measurement reconciliation now runs after a row size enters TanStack's
cache and before the queued render chooses its next window. Only an active,
unsuspended, still-opening transcript whose current owner is `follow-output`
asks the existing follow scheduler to reconcile. The offset observer then
publishes the actual scroll position without a synchronous React flush. No
displacement permission is broadened, and historical reading, user takeover,
and post-reveal streaming keep their existing rules. A pending debounced native
scroll-end sample must not overwrite this publication with its older offset.
The motivating trace measured a 729px shrink of rows 22..26 followed by window
contraction/remount and 113.8ms of removal-related style work. Tests reproduce
the contraction with reconciliation disabled and retain the same row nodes
with it enabled, including a delayed native scroll event and scroll-end timer.
A same-session desktop retest kept rows 22..33 mounted: row cleanup calls fell
from five to zero, and the post-reveal probe completed at 596.4ms instead of
786.8ms. This single-trace comparison does not establish paint timing or remote
behavior; other main-thread stalls remain.

Opening follow corrections also publish their immediate `scrollTop` readback
through `syncViewportOffset`, including a target that is already reached. The
list connects the follow callback to this adapter method; follow never imports
the virtualizer. Only active, unsuspended opening follow with viewport ownership
publishes, and refused writes publish nothing. Equal offsets do not notify React.
This lets range selection proceed before the native scroll event without adding
a synchronous flush or clearing measured sizes. Measurement reconciliation uses
the same observer channel, with its pending flag cleared before calling follow
to avoid recursive correction. Native events remain enabled. Tests withhold them
and check window expansion, node retention, stale scroll-end delivery and user
takeover; runtime savings and remote behavior still require separate validation.

FlowChat virtualizes with **TanStack Virtual**, behind `useFlowChatVirtualizer.ts`.
Nothing else imports it. The rest of FlowChat asks for offsets in scroller
coordinates and gets them back; there is no index space of the virtualizer's own
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// @vitest-environment jsdom
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FlowChatOpeningBoundary } from './FlowChatOpeningBoundary';
import { Portal } from '@openbitfun/ui';

globalThis.IS_REACT_ACT_ENVIRONMENT = true;

describe('FlowChat opening interaction boundary', () => {
let host: HTMLDivElement;
let root: Root;
const action = vi.fn();
const focused = vi.fn();
function render(opening = true) {
act(() => root.render(<>
<button data-before>Before</button>
<FlowChatOpeningBoundary opening={opening} data-transcript>
<button data-inside onClick={action} onFocus={focused}>Message action</button>
</FlowChatOpeningBoundary>
<button disabled>Disabled</button>
<div hidden><button>Hidden</button></div>
<div aria-hidden="true"><button>Accessibility hidden</button></div>
<input data-after />
</>));
}
const element = (selector: string) => host.querySelector<HTMLElement>(selector)!;
function tab(shiftKey: boolean, destination: string) {
document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Tab', shiftKey, bubbles: true, cancelable: true,
}));
// jsdom does not implement the browser's default Tab traversal.
element(destination).focus();
}
beforeEach(() => {
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
action.mockReset();
focused.mockReset();
// Supply boxes solely for tabbable's visibility filter, not layout validation.
vi.spyOn(HTMLElement.prototype, 'getClientRects').mockReturnValue([new DOMRect(0, 0, 1, 1)] as unknown as DOMRectList);
});
afterEach(() => {
act(() => root.unmount());
host.remove();
vi.restoreAllMocks();
});

it('skips the transcript in both Tab directions without focusing message controls', () => {
render();
element('[data-before]').focus();
tab(false, '[data-flowchat-opening-guard="before"]');
expect(document.activeElement).toBe(element('[data-after]'));
tab(true, '[data-flowchat-opening-guard="after"]');
expect(document.activeElement).toBe(element('[data-before]'));
expect(focused).not.toHaveBeenCalled();
});

it('redirects a positive-tabindex control that bypasses a guard', () => {
render();
element('[data-inside]').tabIndex = 1;
element('[data-before]').focus();
tab(false, '[data-inside]');
expect(document.activeElement).toBe(element('[data-after]'));
expect(focused).not.toHaveBeenCalled();
});

it('returns programmatic focus to the outside control without scrolling', () => {
render();
const before = element('[data-before]');
before.focus();
const focus = vi.spyOn(before, 'focus');
element('[data-inside]').focus();
expect(document.activeElement).toBe(before);
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
expect(focused).not.toHaveBeenCalled();
});

it('does not scan transcript descendants when choosing an outside focus target', () => {
render();
element('[data-before]').focus();
const read = vi.fn();
Object.defineProperty(element('[data-inside]'), 'getClientRects', { value: read });
tab(false, '[data-flowchat-opening-guard="before"]');
expect(read).not.toHaveBeenCalled();
});

it('blocks activation and native scroll gestures only inside the opening region', () => {
render();
element('[data-inside]').click();
expect(action).not.toHaveBeenCalled();
for (const type of ['wheel', 'touchmove', 'contextmenu', 'pointerdown', 'selectstart']) {
const event = new Event(type, { bubbles: true, cancelable: true });
element('[data-inside]').dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
const outside = new Event(type, { bubbles: true, cancelable: true });
element('[data-after]').dispatchEvent(outside);
expect(outside.defaultPrevented).toBe(false);
}
});

it('releases focus, activation and scroll protection after reveal', () => {
render();
const guards = [...host.querySelectorAll<HTMLElement>('[data-flowchat-opening-guard]')];
element('[data-after]').focus();
render(false);
expect(document.activeElement).toBe(element('[data-after]'));
element('[data-inside]').focus();
element('[data-inside]').click();
expect(focused).toHaveBeenCalledTimes(1);
expect(action).toHaveBeenCalledTimes(1);
const event = new Event('wheel', { bubbles: true, cancelable: true });
element('[data-inside]').dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect([...host.querySelectorAll('[data-flowchat-opening-guard]')]).toEqual(guards);
for (const guard of guards) {
expect(guard.tabIndex).toBe(-1);
guard.focus();
expect(document.activeElement).toBe(guard);
}
expect(element('[data-transcript]').hasAttribute('aria-hidden')).toBe(false);
});

it('blocks React-owned portal activation and focus while opening', () => {
act(() => root.render(<>
<button data-before>Before</button>
<FlowChatOpeningBoundary opening>
<Portal><button data-portal onClick={action} onFocus={focused}>Portal action</button></Portal>
</FlowChatOpeningBoundary>
</>));
const before = element('[data-before]');
before.focus();
const portal = document.querySelector<HTMLButtonElement>('[data-portal]')!;
portal.click();
portal.focus();
expect(action).not.toHaveBeenCalled();
expect(focused).not.toHaveBeenCalled();
expect(document.activeElement).toBe(before);
});

it('does not retain autofocus inside the initial commit', () => {
act(() => root.render(<FlowChatOpeningBoundary opening>
<input data-inside autoFocus onFocus={focused} />
</FlowChatOpeningBoundary>));
expect(document.activeElement).not.toBe(element('[data-inside]'));
expect(focused).not.toHaveBeenCalled();
});

it('cleans up native interception on unmount', () => {
render();
const inside = element('[data-inside]');
act(() => root.render(null));
const event = new Event('wheel', { bubbles: true, cancelable: true });
inside.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
});
});
Loading
Loading