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 scripts/theme-color-governance-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"max": 0
},
"surfaceTokenRenames.registeredUnique": {
"max": 8
"max": 7
},
"surfaceTokenRenames.activeUnique": {
"max": 0
Expand Down
6 changes: 0 additions & 6 deletions scripts/theme-css-var-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -259,12 +259,6 @@ export const SURFACE_TOKEN_RENAME_CONTRACTS = [
owner: 'src/web-ui/src/component-library/components/FlowChatCards/SnapshotCard',
reason: 'Snapshot operation color is a card-local role and should not look like a reusable operation namespace for other surfaces.',
},
{
key: '--delay',
canonical: '--flowchat-scroll-anchor-delay',
owner: 'src/web-ui/src/flow_chat/components/modern/ScrollAnchor',
reason: 'ScrollAnchor animation delay is a Flow Chat runtime input; the generic key is too easy to collide with unrelated animation code.',
},
{
key: '--um-failed-fs',
canonical: '--user-message-failed-font-size',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,9 +275,10 @@ identity. It retries the active generation against real DOM geometry. RAF
retries remain as a bounded fallback for browsers that coalesce range updates.
When the static initial-history renderer hands off to Virtuoso with a pending
target, that target becomes Virtuoso's `initialTopMostItemIndex`; the normal pin
resolver then performs exact alignment after mount. The right-side
`ScrollAnchor` must bind to the actual scroller element, because the stable ref
object does not change identity when its `.current` node is replaced.
resolver then performs exact alignment after mount. The left-side
`FlowChatTurnRail` is mounted outside the scroller and delegates navigation to
the same container-owned turn-pin request, so it does not need to rebind across
renderer handoffs or write the FlowChat viewport directly.

Mounting an already-streaming session is not a new-turn event. Session entry
resumes tail follow directly, while sticky pinning remains reserved for a new
Expand Down Expand Up @@ -366,10 +367,10 @@ This path is safer than doing nothing, but it is more likely to show visible mov
## C. Static Initial-History Turn Navigation

The initial-history path renders a bounded static window plus estimated leading
and trailing spacers before handing the complete projection to Virtuoso. Header
turn selection and the right-side scroll anchor may target a turn outside that
window. The list first materializes a new window around the target and then
issues the requested scroll.
and trailing spacers before handing the complete projection to Virtuoso. The
left-side turn rail may target a turn outside that window. The list first
materializes a new window around the target and then issues the requested
scroll.

A smooth scroll does not update `scrollTop` synchronously. The window swap can
therefore emit a scroll event at the old, browser-clamped physical bottom before
Expand All @@ -391,8 +392,8 @@ second viewport writer.

## D. Arbitrary Turn Navigation Through Virtuoso

Header turn selection and the right-side scroll anchor use the same top-aligned
pin transaction:
The left-side turn rail delegates to the container-owned top-aligned pin
transaction:

1. record generation, session, target turn, behavior, and pin mode
2. exit tail follow without removing established physical range
Expand All @@ -410,6 +411,19 @@ Do not clear the previous pin/footer range in step 2. The target may be outside
the current Virtuoso range, and removing the footer first lets the browser clamp
the old position to the physical bottom before materialization succeeds.

The turn rail is an independent overlay surface. Its height is bounded to 60%
of the FlowChat content area; overflow scrolls only the rail, and keeping the
current marker visible may update only the rail list's `scrollTop`. Rail wheel,
keyboard, hover, and tooltip behavior must never become another writer for the
outer FlowChat viewport.

The existing visible-turn DOM measurement also collects every distinct turn
whose rendered items intersect the readable viewport. The first intersecting
turn remains the semantic current turn, while every intersecting turn marker
uses the same rail emphasis. Publish a new ordered `visibleTurnIds` snapshot
only when membership or order changes so ordinary scroll frames do not cause
redundant rail renders.

## Why Transition Tracking Exists

User-initiated expand/collapse still uses animated layout properties such as:
Expand Down
99 changes: 0 additions & 99 deletions src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss
Original file line number Diff line number Diff line change
Expand Up @@ -55,28 +55,12 @@
}
}

&__turn-nav {
position: relative;
display: flex;
align-items: center;
gap: 0;
}

&__background-activity-nav {
position: relative;
display: flex;
align-items: center;
}

&__turn-nav-button {
flex: 0 0 auto;

&:not(:disabled):hover,
&--active {
background: color-mix(in srgb, var(--element-bg-soft) 82%, transparent);
}
}

&__background-activity-nav-button {
flex: 0 0 auto;

Expand Down Expand Up @@ -240,24 +224,6 @@
}
}

&__turn-list-panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: min(360px, calc(100vw - 32px));
max-height: min(420px, calc(100vh - 96px));
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--border-base);
border-radius: $size-radius-lg;
background: color-mix(in srgb, var(--color-bg-elevated) 94%, transparent);
box-shadow: var(--shadow-lg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
z-index: 30;
}

&__background-activity-panel {
position: absolute;
top: calc(100% + 8px);
Expand All @@ -276,17 +242,6 @@
z-index: 30;
}

&__turn-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: $size-gap-2;
padding: $size-gap-2 $size-gap-3;
border-bottom: 1px solid var(--border-base);
font-size: var(--flowchat-font-size-xs);
color: var(--color-text-secondary);
}

&__background-activity-panel-header {
display: flex;
align-items: center;
Expand All @@ -298,15 +253,6 @@
color: var(--color-text-secondary);
}

&__turn-list {
display: flex;
flex-direction: column;
overflow-y: auto;
padding: $size-gap-1;
max-height: calc(7 * 40px);
scrollbar-gutter: stable;
}

&__background-activity-list {
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -369,51 +315,6 @@
}
}

&__turn-list-item {
display: flex;
align-items: center;
gap: $size-gap-2;
width: 100%;
padding: $size-gap-2;
border: 0;
border-radius: $size-radius-base;
background: transparent;
color: var(--color-text-primary);
min-height: 40px;
text-align: left;
cursor: pointer;
transition: background $motion-base $easing-standard;

&:hover {
background: color-mix(in srgb, var(--element-bg-soft) 88%, transparent);
}

&--active {
background: color-mix(in srgb, var(--color-accent-500) 12%, var(--element-bg-soft));
}
}

&__turn-list-badge {
flex: 0 0 auto;
padding: 2px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--element-bg-soft) 82%, transparent);
color: var(--color-text-secondary);
font-size: var(--flowchat-font-size-xs);
line-height: 1;
white-space: nowrap;
}

&__turn-list-title {
min-width: 0;
flex: 1;
font-size: var(--flowchat-font-size-sm);
color: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

&__background-activity-list-item {
display: flex;
flex-direction: column;
Expand Down
81 changes: 5 additions & 76 deletions src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,6 @@ function createProps(overrides: Partial<FlowChatHeaderProps> = {}): FlowChatHead
totalTurns: 2,
currentUserMessage: 'First prompt',
visible: true,
turns: [
{ turnId: 'turn-1', turnIndex: 1, title: 'First prompt' },
{ turnId: 'turn-2', turnIndex: 2, title: 'Second prompt' },
],
onJumpToTurn: vi.fn(),
...overrides,
};
}
Expand Down Expand Up @@ -130,80 +125,14 @@ describe('FlowChatHeader', () => {
expect(header?.style.getPropertyValue('--flowchat-header-side-width')).toBe('196px');
});

it('closes the turn list as soon as a different turn selection is accepted', () => {
const onJumpToTurn = vi.fn(() => true);
const initialProps = createProps({ onJumpToTurn });

act(() => {
root.render(<FlowChatHeader {...initialProps} />);
});

const turnListButton = container.querySelector<HTMLButtonElement>('[data-testid="flowchat-header-turn-list"]');
expect(turnListButton).not.toBeNull();

act(() => {
turnListButton?.click();
});

expect(container.querySelector('[role="dialog"]')).not.toBeNull();

const turnItems = Array.from(container.querySelectorAll<HTMLButtonElement>('.flowchat-header__turn-list-item'));
expect(turnItems).toHaveLength(2);

act(() => {
turnItems[1]?.click();
});

expect(onJumpToTurn).toHaveBeenCalledWith('turn-2');
expect(container.querySelector('[role="dialog"]')).toBeNull();

act(() => {
root.render(<FlowChatHeader {...initialProps} currentTurn={2} />);
});

expect(container.querySelector('[role="dialog"]')).toBeNull();
});

it('closes the turn list and notifies the container when selecting the current turn', () => {
const onJumpToTurn = vi.fn(() => true);

it('omits the list, previous-turn, and next-turn navigation controls', () => {
act(() => {
root.render(<FlowChatHeader {...createProps({ onJumpToTurn })} />);
});

const turnListButton = container.querySelector<HTMLButtonElement>('[data-testid="flowchat-header-turn-list"]');
act(() => {
turnListButton?.click();
});

const currentTurnItem = container.querySelector<HTMLButtonElement>('.flowchat-header__turn-list-item');
act(() => {
currentTurnItem?.click();
});

expect(onJumpToTurn).toHaveBeenCalledWith('turn-1');
expect(container.querySelector('[role="dialog"]')).toBeNull();
});

it('keeps the turn list open when the container rejects the selection', () => {
const onJumpToTurn = vi.fn(() => false);

act(() => {
root.render(<FlowChatHeader {...createProps({ onJumpToTurn })} />);
});

const turnListButton = container.querySelector<HTMLButtonElement>('[data-testid="flowchat-header-turn-list"]');
act(() => {
turnListButton?.click();
});

const turnItems = Array.from(container.querySelectorAll<HTMLButtonElement>('.flowchat-header__turn-list-item'));
act(() => {
turnItems[1]?.click();
root.render(<FlowChatHeader {...createProps()} />);
});

expect(onJumpToTurn).toHaveBeenCalledWith('turn-2');
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
expect(container.querySelector('[data-testid="flowchat-header-turn-list"]')).toBeNull();
expect(container.querySelector('[data-testid="flowchat-header-turn-prev"]')).toBeNull();
expect(container.querySelector('[data-testid="flowchat-header-turn-next"]')).toBeNull();
});

it('renders background activity menus in a portal outside the scrollable panel', () => {
Expand Down
Loading
Loading