You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/03 08:44:28 UTC
(maka) branch main updated: fix(ui): restore prompt rail scroll tracking (#4417)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 51704b5047 fix(ui): restore prompt rail scroll tracking (#4417)
51704b5047 is described below
commit 51704b5047f404ba4f38b98fefcf33463c20f1b0
Author: Sun-GLiang <81...@users.noreply.github.com>
AuthorDate: Thu Sep 3 16:44:23 2026 +0800
fix(ui): restore prompt rail scroll tracking (#4417)
#4206 removed row virtualization but left the `[data-virtual-turn-id]` lookup in
the Prompt Rail. That marker has had no producer since, so the container query
resolved to null and the scroll-spy effect returned at its guard: the
IntersectionObserver was never installed, manual scrolling highlighted nothing,
and a rail that had not been clicked had no current tick at all.
The rail now observes the `.maka-chat-message-list` inner column and tracks
mounted Turns through `data-transcript-turn-id`, both of which have live
producers. React is the sole writer of `data-active` and `aria-current`; the
imperative attribute path is gone rather than reduced.
Initial geometry and IntersectionObserver updates share one selection rule: the
last mounted Turn when at the tail, otherwise the earliest Turn in the top
reading band, falling back to the full scrollport for transcripts short enough
that the alignment spacer is the only thing in that band.
The observed set is every mounted Turn rather than only the Turns the rail knows
about. Landmark queries are capped at 64 while the mount window independently
keeps 10 Turns, so in a long session roughly half the mounted prompts are absent
from the landmark index; filtering by that index made the rail lose track in
exactly the sessions this bug was reported from. Turn IDs stay structural so
streaming preview text does not rebuild the scroll-spy.
The manual-scroll E2E fails on the pre-fix code because no tick is current, and
asserts exactly one current tick at every moment of the journey. The streaming
E2E instruments the real IntersectionObserver in the renderer and asserts it is
constructed once across a long streamed response.
Fixes #4415
Generated-by: Maka
---
apps/desktop/e2e/prompt-rail.spec.ts | 224 +++++++++++-
.../ui/src/__tests__/prompt-anchor-rail.test.ts | 147 ++++++++
packages/ui/src/prompt-anchor-rail.tsx | 398 ++++++++++++++++-----
3 files changed, 676 insertions(+), 93 deletions(-)
diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts
index efbcec3dd0..7af51a35c2 100644
--- a/apps/desktop/e2e/prompt-rail.spec.ts
+++ b/apps/desktop/e2e/prompt-rail.spec.ts
@@ -19,7 +19,7 @@
import { PROMPT_RAIL_PROMPT_COUNT } from '../src/main/e2e-fixture/seed-helpers';
import { DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS } from '../src/preload/transcript-contract';
-import { ensureSidebarExpanded, expect, test } from './fixtures';
+import { COMPOSER_INPUT, ensureSidebarExpanded, expect, test } from './fixtures';
import type { Page } from '@playwright/test';
const MAX_PROMPT_RAIL_TICKS = 64;
@@ -131,6 +131,103 @@ function notifyTranscriptScrolled(page: Page): Promise<void> {
});
}
+interface ActivePromptRailSnapshot {
+ currentIds: string[];
+ expectedId: string | null;
+ sourceTurnId: string | null;
+}
+
+async function activePromptRailSnapshot(page: Page): Promise<ActivePromptRailSnapshot> {
+ return page.evaluate(async ({ promptCount }) => {
+ const root = document.querySelector<HTMLElement>('[data-chat-scroll-container="true"]');
+ if (!root) throw new Error('the chat scroll container is missing');
+ const ticks = [...document.querySelectorAll<HTMLElement>('.maka-prompt-rail-tick')];
+ const currentIds = ticks
+ .filter((tick) => tick.getAttribute('aria-current') === 'true')
+ .map((tick) => tick.dataset.promptTurnId ?? '');
+ const rootBounds = root.getBoundingClientRect();
+ const atEnd = root.scrollHeight - root.scrollTop - root.clientHeight <= 2;
+ const turns = [...root.querySelectorAll<HTMLElement>('[data-transcript-turn-id]')]
+ .map((turn) => ({
+ element: turn,
+ id: turn.dataset.transcriptTurnId ?? '',
+ index: Number(turn.dataset.transcriptTurnId?.split('-').at(-1)) - 1,
+ }))
+ .filter((turn) => turn.id.length > 0 && Number.isFinite(turn.index));
+ const readingBandTurns = turns
+ .filter(({ element }) => {
+ const bounds = element.getBoundingClientRect();
+ return bounds.bottom > rootBounds.top
+ && bounds.top < rootBounds.top + rootBounds.height * 0.34;
+ })
+ .sort((left, right) => left.index - right.index);
+ const scrollportTurns = turns
+ .filter(({ element }) => {
+ const bounds = element.getBoundingClientRect();
+ return bounds.bottom > rootBounds.top && bounds.top < rootBounds.bottom;
+ })
+ .sort((left, right) => left.index - right.index);
+ const sourceTurn = atEnd
+ ? turns.reduce<typeof turns[number] | null>(
+ (latest, turn) => latest === null || turn.index > latest.index ? turn : latest,
+ null,
+ )
+ : readingBandTurns[0] ?? scrollportTurns[0] ?? null;
+ const expectedRailIndex = sourceTurn === null || ticks.length === 0
+ ? null
+ : Math.round(
+ sourceTurn.index * (ticks.length - 1) / (promptCount - 1),
+ );
+ const expectedId = expectedRailIndex === null
+ ? null
+ : ticks[expectedRailIndex]?.dataset.promptTurnId ?? null;
+ return {
+ currentIds,
+ expectedId,
+ sourceTurnId: sourceTurn?.id ?? null,
+ };
+ }, { promptCount: PROMPT_RAIL_PROMPT_COUNT });
+}
+
+async function expectPromptRailMatchesReadingPosition(page: Page): Promise<void> {
+ let lastSnapshot: ActivePromptRailSnapshot | null = null;
+ try {
+ await expect.poll(async () => {
+ lastSnapshot = await activePromptRailSnapshot(page);
+ return lastSnapshot.expectedId !== null
+ && lastSnapshot.currentIds.length === 1
+ && lastSnapshot.currentIds[0] === lastSnapshot.expectedId;
+ }, { message: 'the one current tick maps from the Turn being read' }).toBe(true);
+ } catch {
+ throw new Error(`the prompt rail did not settle on the reading position: ${JSON.stringify(lastSnapshot)}`);
+ }
+ const snapshot = await activePromptRailSnapshot(page);
+ expect(snapshot.expectedId, `no visible Turn in ${JSON.stringify(snapshot)}`).not.toBeNull();
+ expect(snapshot.currentIds).toEqual([snapshot.expectedId]);
+}
+
+async function scrollTranscriptThroughHistory(page: Page): Promise<void> {
+ for (let pageIndex = 0; pageIndex < PROMPT_RAIL_PROMPT_COUNT; pageIndex += 1) {
+ const firstBefore = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id');
+ if (firstBefore === 'turn-prompt-rail-1') return;
+ await page.evaluate(() => {
+ const root = document.querySelector<HTMLElement>('[data-chat-scroll-container="true"]');
+ if (!root) throw new Error('the chat scroll container is missing');
+ root.scrollTop = 0;
+ root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true }));
+ root.dispatchEvent(new Event('scroll'));
+ });
+ await expect.poll(async () =>
+ page.locator('[data-turn-id]').first().getAttribute('data-turn-id'), {
+ message: `history loads before ${firstBefore}`,
+ timeout: 20_000,
+ }).not.toBe(firstBefore);
+ await waitForPaintedFrames(page);
+ await expectPromptRailMatchesReadingPosition(page);
+ }
+ throw new Error('the first prompt did not enter the active transcript range');
+}
+
test('every tick paints a bar with a real box', async ({ promptRailWindow: page }) => {
// Measured over ALL ticks, not a sample: a helper that skips what it cannot
// evaluate creates its blind spot exactly where a regression lives.
@@ -270,6 +367,131 @@ test('the first click of a session lands on its prompt and holds', async ({
expect(settled?.tickIsCurrent).toBe(true);
});
+test('manual transcript scrolling keeps exactly the visible prompt current', async ({
+ promptRailWindow: page,
+}) => {
+ await page.setViewportSize({ width: 1_000, height: 700 });
+ await scrollTranscriptTo(page, 'bottom');
+ await expectPromptRailMatchesReadingPosition(page);
+ await expect(page.locator('.maka-prompt-rail-tick[aria-current="true"]')).toHaveCount(1);
+ await expect(page.locator('.maka-prompt-rail-tick').last()).toHaveAttribute(
+ 'aria-current',
+ 'true',
+ );
+
+ await page.evaluate(() => {
+ const rail = document.querySelector('.maka-prompt-rail');
+ if (!rail) throw new Error('the prompt rail is missing');
+ const counts: number[] = [];
+ const record = () => counts.push(
+ rail.querySelectorAll('.maka-prompt-rail-tick[aria-current="true"]').length,
+ );
+ const observer = new MutationObserver(record);
+ observer.observe(rail, {
+ attributes: true,
+ subtree: true,
+ attributeFilter: ['aria-current'],
+ });
+ record();
+ Object.assign(window, {
+ __makaPromptRailCurrentCounts: counts,
+ __makaPromptRailCurrentObserver: observer,
+ });
+ });
+
+ await scrollTranscriptThroughHistory(page);
+ await scrollTranscriptTo(page, 'top');
+ await expectPromptRailMatchesReadingPosition(page);
+ await expect(page.locator('.maka-prompt-rail-tick[aria-current="true"]')).toHaveCount(1);
+ await expect(page.locator('.maka-prompt-rail-tick').first()).toHaveAttribute(
+ 'aria-current',
+ 'true',
+ );
+
+ const currentCounts = await page.evaluate(() => {
+ const state = window as Window & {
+ __makaPromptRailCurrentCounts?: number[];
+ __makaPromptRailCurrentObserver?: MutationObserver;
+ };
+ state.__makaPromptRailCurrentObserver?.disconnect();
+ return state.__makaPromptRailCurrentCounts ?? [];
+ });
+ expect(currentCounts.length).toBeGreaterThan(1);
+ expect(currentCounts.every((count) => count === 1), currentCounts.join(',')).toBe(true);
+});
+
+test('streaming deltas do not reconstruct the prompt rail observer', async ({
+ window: page,
+}) => {
+ const composer = page.locator(COMPOSER_INPUT);
+ const sendAndSettle = async (prompt: string, expectedTurns: number): Promise<void> => {
+ await composer.fill(prompt);
+ await composer.press('Enter');
+ await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(expectedTurns, {
+ timeout: 20_000,
+ });
+ };
+ await sendAndSettle('First prompt rail observer seed', 1);
+ await sendAndSettle('Second prompt rail observer seed', 2);
+
+ await page.evaluate(() => {
+ const NativeIntersectionObserver = window.IntersectionObserver;
+ const state: {
+ constructions: number;
+ initialConstructions: number | null;
+ } = { constructions: 0, initialConstructions: null };
+ window.IntersectionObserver = class extends NativeIntersectionObserver {
+ constructor(
+ callback: IntersectionObserverCallback,
+ options?: IntersectionObserverInit,
+ ) {
+ super(callback, options);
+ if (
+ options?.root === document.querySelector('[data-chat-scroll-container="true"]')
+ && options.rootMargin === '0px 0px -66% 0px'
+ ) {
+ state.constructions += 1;
+ state.initialConstructions ??= state.constructions;
+ }
+ }
+ };
+ Object.assign(window, { __makaPromptRailObserverProbe: state });
+ });
+
+ const streamingPrompt = Array.from(
+ { length: 40 },
+ (_, index) => `Observer stability line ${index + 1}`,
+ ).join('\n');
+ await composer.fill(streamingPrompt);
+ await composer.press('Enter');
+
+ await expect.poll(() => page.evaluate(() => (
+ window as Window & {
+ __makaPromptRailObserverProbe?: { constructions: number };
+ }
+ ).__makaPromptRailObserverProbe?.constructions ?? 0), {
+ message: 'the third Turn creates the prompt rail observer',
+ }).toBeGreaterThan(0);
+
+ // The fake backend emits nine characters per delta, so reaching the last
+ // line proves many same-Turn text updates landed after observer creation.
+ await expect(page.getByRole('log').getByText(
+ /Fake backend received:[\s\S]*Observer stability line 40/,
+ )).toBeVisible({
+ timeout: 20_000,
+ });
+
+ const settled = await page.evaluate(() => ({ ...(
+ window as Window & {
+ __makaPromptRailObserverProbe: {
+ constructions: number;
+ initialConstructions: number | null;
+ };
+ }
+ ).__makaPromptRailObserverProbe }));
+ expect(settled.constructions).toBe(settled.initialConstructions);
+});
+
test('active transcript Turns keep stable DOM identities while scrolling', async ({
promptRailWindow: page,
}) => {
diff --git a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts
index b1e806c628..26eac1c61d 100644
--- a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts
+++ b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts
@@ -27,6 +27,8 @@ import {
mergePromptAnchorRailTurns,
observeActivePromptRailVisibility,
PromptAnchorRail,
+ selectPromptRailActiveTurn,
+ selectPromptRailTickForMountedTurn,
type PromptRailFrameScheduler,
} from '../prompt-anchor-rail.js';
@@ -226,6 +228,151 @@ test('a jump gives the transcript back the moment the reader touches it', () =>
harness.restore();
});
+const turnIndexById = new Map([
+ ['turn-1', 0],
+ ['turn-2', 1],
+ ['turn-3', 2],
+ ['turn-4', 3],
+]);
+
+test('the transcript tail selects the latest mounted Turn', () => {
+ assert.equal(selectPromptRailActiveTurn({
+ atEnd: true,
+ mountedTurnIds: ['turn-3', 'turn-1', 'turn-4'],
+ readingBandTurnIds: ['turn-1'],
+ scrollportTurnIds: ['turn-1', 'turn-3'],
+ turnIndexById,
+ }), 'turn-4');
+});
+
+test('the reading band selects its earliest Turn by transcript order', () => {
+ assert.equal(selectPromptRailActiveTurn({
+ atEnd: false,
+ mountedTurnIds: ['turn-1', 'turn-2', 'turn-3', 'turn-4'],
+ readingBandTurnIds: ['turn-4', 'turn-2', 'turn-3'],
+ scrollportTurnIds: ['turn-1', 'turn-2', 'turn-3', 'turn-4'],
+ turnIndexById,
+ }), 'turn-2');
+});
+
+test('an empty reading band falls back to the earliest Turn in the scrollport', () => {
+ assert.equal(selectPromptRailActiveTurn({
+ atEnd: false,
+ mountedTurnIds: ['turn-1', 'turn-2', 'turn-3', 'turn-4'],
+ readingBandTurnIds: [],
+ scrollportTurnIds: ['turn-4', 'turn-3'],
+ turnIndexById,
+ }), 'turn-3');
+});
+
+test('no eligible Turn leaves the selection unresolved', () => {
+ assert.equal(selectPromptRailActiveTurn({
+ atEnd: false,
+ mountedTurnIds: ['unknown-turn'],
+ readingBandTurnIds: [],
+ scrollportTurnIds: [],
+ turnIndexById,
+ }), null);
+});
+
+test('an unsampled mounted Turn maps through the two nearest durable landmarks', () => {
+ const railTurns = Array.from({ length: 64 }, (_, railIndex) => {
+ const turnIndex = Math.round(railIndex * 119 / 63);
+ return { turnId: `turn-${turnIndex + 1}`, label: '', sequence: turnIndex * 2 };
+ });
+ assert.equal(selectPromptRailTickForMountedTurn({
+ activeTurnId: 'turn-66',
+ mountedTurnIds: ['turn-66', 'turn-67', 'turn-68', 'turn-69'],
+ railTurns,
+ previousRailTurnId: 'turn-67',
+ atEnd: false,
+ }), 'turn-65');
+});
+
+test('uneven sequence gaps choose a nearby real landmark instead of a linear tick position', () => {
+ const railTurns = [
+ { turnId: 'turn-a', label: '', sequence: 0 },
+ { turnId: 'turn-b', label: '', sequence: 10 },
+ { turnId: 'turn-c', label: '', sequence: 1_000 },
+ { turnId: 'turn-d', label: '', sequence: 1_010 },
+ ];
+ assert.equal(selectPromptRailTickForMountedTurn({
+ activeTurnId: 'active',
+ mountedTurnIds: ['turn-b', 'active', 'turn-c'],
+ railTurns,
+ previousRailTurnId: 'turn-b',
+ atEnd: false,
+ }), 'turn-c');
+});
+
+test('one-sided sequence extrapolation cannot skip past the adjacent tick', () => {
+ const railTurns = [
+ { turnId: 'turn-a', label: '', sequence: 0 },
+ { turnId: 'turn-b', label: '', sequence: 10 },
+ { turnId: 'turn-c', label: '', sequence: 1_000 },
+ { turnId: 'turn-d', label: '', sequence: 1_010 },
+ ];
+ assert.equal(selectPromptRailTickForMountedTurn({
+ activeTurnId: 'active',
+ mountedTurnIds: ['active', 'turn-b', 'turn-c'],
+ railTurns,
+ previousRailTurnId: 'turn-d',
+ atEnd: false,
+ }), 'turn-a');
+});
+
+test('a prompt-less mounted tail uses the nearest loaded prompt before the index arrives', () => {
+ assert.equal(selectPromptRailTickForMountedTurn({
+ activeTurnId: 'active',
+ mountedTurnIds: ['turn-a', 'turn-b', 'active'],
+ railTurns: [
+ { turnId: 'turn-a', label: '' },
+ { turnId: 'turn-b', label: '' },
+ ],
+ previousRailTurnId: null,
+ atEnd: true,
+ }), 'turn-b');
+});
+
+test('a prompt-less tail without a mounted landmark uses the final rail tick', () => {
+ assert.equal(selectPromptRailTickForMountedTurn({
+ activeTurnId: 'active',
+ mountedTurnIds: ['active'],
+ railTurns: [
+ { turnId: 'turn-a', label: '', sequence: 0 },
+ { turnId: 'turn-b', label: '', sequence: 10 },
+ ],
+ previousRailTurnId: null,
+ atEnd: true,
+ }), 'turn-b');
+});
+
+test('a window without a sampled wrapper preserves its previous current tick', () => {
+ assert.equal(selectPromptRailTickForMountedTurn({
+ activeTurnId: 'active',
+ mountedTurnIds: ['active', 'neighbor'],
+ railTurns: [
+ { turnId: 'turn-a', label: '', sequence: 0 },
+ { turnId: 'turn-b', label: '', sequence: 10 },
+ ],
+ previousRailTurnId: 'turn-a',
+ atEnd: false,
+ }), 'turn-a');
+});
+
+test('a window without landmarks replaces a stale current with a current rail tick', () => {
+ assert.equal(selectPromptRailTickForMountedTurn({
+ activeTurnId: 'active',
+ mountedTurnIds: ['active'],
+ railTurns: [
+ { turnId: 'turn-a', label: '', sequence: 0 },
+ { turnId: 'turn-b', label: '', sequence: 10 },
+ ],
+ previousRailTurnId: 'stale-turn',
+ atEnd: false,
+ }), 'turn-a');
+});
+
test('keeps the active tick visible when the rail viewport resizes', () => {
let railBox = box(0, 600);
let tickBox = box(570, 590);
diff --git a/packages/ui/src/prompt-anchor-rail.tsx b/packages/ui/src/prompt-anchor-rail.tsx
index 473abd2274..a78de69d54 100644
--- a/packages/ui/src/prompt-anchor-rail.tsx
+++ b/packages/ui/src/prompt-anchor-rail.tsx
@@ -45,6 +45,8 @@ const HOVER_FALLOFF_TICKS = 3;
*/
const PREVIEW_DELAY_MS = 120;
const MAX_PROMPT_RAIL_TICKS = 64;
+/** Distinguish a positive IO overlap from Chromium's zero-area edge contact. */
+const POSITIVE_INTERSECTION_RATIO = 0.000_001;
/** Quiet frames at the destination that end a jump's hold. */
const JUMP_SETTLE_QUIET_FRAMES = 3;
@@ -245,38 +247,145 @@ export interface PromptAnchorRailProps {
onNavigateStart?: (() => void) | undefined;
}
+export function selectPromptRailActiveTurn(input: {
+ atEnd: boolean;
+ mountedTurnIds: Iterable<string>;
+ readingBandTurnIds: Iterable<string>;
+ scrollportTurnIds: Iterable<string>;
+ turnIndexById: ReadonlyMap<string, number>;
+}): string | null {
+ const readingBandTurnIds = [...input.readingBandTurnIds];
+ const candidates = input.atEnd
+ ? input.mountedTurnIds
+ : readingBandTurnIds.length > 0
+ ? readingBandTurnIds
+ : input.scrollportTurnIds;
+ let selected: string | null = null;
+ let selectedIndex = input.atEnd ? -1 : Number.POSITIVE_INFINITY;
+ for (const turnId of candidates) {
+ const index = input.turnIndexById.get(turnId);
+ if (index === undefined) continue;
+ if (
+ (input.atEnd && index > selectedIndex)
+ || (!input.atEnd && index < selectedIndex)
+ ) {
+ selected = turnId;
+ selectedIndex = index;
+ }
+ }
+ return selected;
+}
+
+export function selectPromptRailTickForMountedTurn(input: {
+ activeTurnId: string;
+ mountedTurnIds: readonly string[];
+ railTurns: readonly PromptAnchorRailTurn[];
+ previousRailTurnId: string | null;
+ atEnd: boolean;
+}): string | null {
+ const previousRailTurnId = input.railTurns.some(
+ (turn) => turn.turnId === input.previousRailTurnId,
+ ) ? input.previousRailTurnId : null;
+ const fallbackRailTurnId = input.atEnd
+ ? input.railTurns.at(-1)?.turnId ?? null
+ : previousRailTurnId ?? input.railTurns[0]?.turnId ?? null;
+ const direct = input.railTurns.find((turn) => turn.turnId === input.activeTurnId);
+ if (direct) return direct.turnId;
+ const activeIndex = input.mountedTurnIds.indexOf(input.activeTurnId);
+ if (activeIndex === -1) return fallbackRailTurnId;
+ const mountedRailTurns = input.mountedTurnIds.flatMap((turnId, mountedIndex) => {
+ const railIndex = input.railTurns.findIndex((turn) => turn.turnId === turnId);
+ return railIndex === -1 ? [] : [{ mountedIndex, railIndex }];
+ });
+ const nearestMountedRailTurn = [...mountedRailTurns]
+ .sort((left, right) =>
+ Math.abs(left.mountedIndex - activeIndex) - Math.abs(right.mountedIndex - activeIndex)
+ || left.mountedIndex - right.mountedIndex,
+ )[0];
+ const nearestMountedRailTurnId = nearestMountedRailTurn
+ ? input.railTurns[nearestMountedRailTurn.railIndex]?.turnId ?? null
+ : null;
+ const sequenceAnchors = mountedRailTurns
+ .flatMap(({ mountedIndex, railIndex }) => {
+ const sequence = input.railTurns[railIndex]?.sequence;
+ return sequence === undefined ? [] : [{ mountedIndex, railIndex, sequence }];
+ })
+ .sort((left, right) =>
+ Math.abs(left.mountedIndex - activeIndex) - Math.abs(right.mountedIndex - activeIndex)
+ || left.mountedIndex - right.mountedIndex,
+ )
+ .slice(0, 2)
+ .sort((left, right) => left.mountedIndex - right.mountedIndex);
+ const [firstAnchor, secondAnchor] = sequenceAnchors;
+ if (!firstAnchor || !secondAnchor) {
+ return firstAnchor
+ ? input.railTurns[firstAnchor.railIndex]?.turnId ?? null
+ : nearestMountedRailTurnId ?? fallbackRailTurnId;
+ }
+ const activeSequence = firstAnchor.sequence
+ + (secondAnchor.sequence - firstAnchor.sequence)
+ * (activeIndex - firstAnchor.mountedIndex)
+ / (secondAnchor.mountedIndex - firstAnchor.mountedIndex);
+ const firstSequence = input.railTurns[0]?.sequence;
+ const lastSequence = input.railTurns[input.railTurns.length - 1]?.sequence;
+ const projectedRailIndex = firstSequence !== undefined
+ && lastSequence !== undefined
+ && lastSequence > firstSequence
+ ? Math.round(
+ (activeSequence - firstSequence)
+ * (input.railTurns.length - 1)
+ / (lastSequence - firstSequence),
+ )
+ : firstAnchor.railIndex;
+ let selected: PromptAnchorRailTurn | null = null;
+ let selectedIndex = -1;
+ let selectedDistance = Number.POSITIVE_INFINITY;
+ const candidateRange = activeIndex < firstAnchor.mountedIndex
+ ? [Math.max(0, firstAnchor.railIndex - 1), firstAnchor.railIndex]
+ : activeIndex > secondAnchor.mountedIndex
+ ? [
+ secondAnchor.railIndex,
+ Math.min(input.railTurns.length - 1, secondAnchor.railIndex + 1),
+ ]
+ : [firstAnchor.railIndex, secondAnchor.railIndex];
+ for (let index = 0; input.railTurns.length > index; index += 1) {
+ if (index < candidateRange[0]! || index > candidateRange[1]!) continue;
+ const turn = input.railTurns[index]!;
+ if (turn.sequence === undefined) continue;
+ const distance = Math.abs(turn.sequence - activeSequence);
+ if (
+ distance < selectedDistance
+ || (
+ distance === selectedDistance
+ && Math.abs(index - projectedRailIndex) < Math.abs(selectedIndex - projectedRailIndex)
+ )
+ ) {
+ selected = turn;
+ selectedIndex = index;
+ selectedDistance = distance;
+ }
+ }
+ return selected?.turnId ?? fallbackRailTurnId;
+}
+
/** Right-edge rail: bounded prompt landmarks that scroll to `[data-turn-id]`. */
export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRef, onNavigateFallback, onNavigateStart }: PromptAnchorRailProps): React.ReactElement | null {
const copy = getConversationCopy(useUiLocale()).sessions;
- const activeTurnIdRef = useRef<string | null>(null);
+ const [activeSelection, setActiveSelection] = useState<{
+ turnId: string;
+ atEnd: boolean;
+ } | null>(null);
+ const activeTurnId = activeSelection?.turnId ?? null;
+ const [mountedTurnIds, setMountedTurnIds] = useState<readonly string[]>([]);
const [safeArea, setSafeArea] = useState<{ scrollport: number; dock: number } | null>(null);
const railRef = useRef<HTMLElement | null>(null);
+ const previousActiveRailTurnIdRef = useRef<string | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const activeVisibilityFrame = useRef(0);
- const markActiveTurn = useCallback((turnId: string) => {
- if (activeTurnIdRef.current === turnId) return;
- activeTurnIdRef.current = turnId;
- const rail = railRef.current;
- const previous = rail?.querySelector<HTMLElement>('[data-active="true"]');
- previous?.removeAttribute('data-active');
- previous?.removeAttribute('aria-current');
- const target = rail?.querySelector<HTMLElement>(
- `[data-prompt-turn-id="${CSS.escape(turnId)}"]`,
+ const markActiveTurn = useCallback((turnId: string, atEnd = false) => {
+ setActiveSelection((current) =>
+ current?.turnId === turnId && current.atEnd === atEnd ? current : { turnId, atEnd },
);
- target?.setAttribute('data-active', 'true');
- target?.setAttribute('aria-current', 'true');
- if (activeVisibilityFrame.current !== 0) cancelAnimationFrame(activeVisibilityFrame.current);
- activeVisibilityFrame.current = requestAnimationFrame(() => {
- activeVisibilityFrame.current = requestAnimationFrame(() => {
- activeVisibilityFrame.current = 0;
- if (rail) keepActivePromptRailTickVisible(rail);
- });
- });
- }, []);
- useEffect(() => () => {
- if (activeVisibilityFrame.current !== 0) {
- cancelAnimationFrame(activeVisibilityFrame.current);
- }
}, []);
// Identified by a sequence number rather than a boolean so a second click
// during a jump starts its own claim instead of inheriting what is left of
@@ -290,35 +399,90 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe
const jumpTargetRef = useRef<string | null>(null);
const onNavigateStartRef = useRef(onNavigateStart);
onNavigateStartRef.current = onNavigateStart;
- const turnIndexById = useMemo(
- () => new Map(turns.map((turn, index) => [turn.turnId, index])),
- [turns],
- );
- const railTurns = useMemo(() => {
- if (turns.length <= MAX_PROMPT_RAIL_TICKS) return turns;
+ // Prompt/reply text changes while an answer streams, but the scroll spy only
+ // depends on Turn identity and order. Keep that structural value stable so a
+ // text delta does not tear down and rebuild every transcript observer.
+ const orderedTurnIdsRef = useRef<readonly string[]>([]);
+ const nextOrderedTurnIds = turns.map((turn) => turn.turnId);
+ if (
+ orderedTurnIdsRef.current.length !== nextOrderedTurnIds.length
+ || nextOrderedTurnIds.some((turnId, index) => orderedTurnIdsRef.current[index] !== turnId)
+ ) {
+ orderedTurnIdsRef.current = nextOrderedTurnIds;
+ }
+ const orderedTurnIds = orderedTurnIdsRef.current;
+ const railTurnIndexes = useMemo(() => {
+ if (orderedTurnIds.length <= MAX_PROMPT_RAIL_TICKS) {
+ return orderedTurnIds.map((_, index) => index);
+ }
return Array.from({ length: MAX_PROMPT_RAIL_TICKS }, (_, index) =>
- turns[Math.round(index * (turns.length - 1) / (MAX_PROMPT_RAIL_TICKS - 1))]!,
+ Math.round(index * (orderedTurnIds.length - 1) / (MAX_PROMPT_RAIL_TICKS - 1)),
);
- }, [turns]);
-
- const railTurnIdFor = (turnId: string): string | null => {
- const turnIndex = turnIndexById.get(turnId);
- if (turnIndex === undefined) return null;
- if (turns.length === railTurns.length) return turnId;
- const railIndex = Math.round(turnIndex * (railTurns.length - 1) / (turns.length - 1));
- return railTurns[railIndex]?.turnId ?? null;
- };
+ }, [orderedTurnIds]);
+ const railTurnIds = useMemo(
+ () => railTurnIndexes.map((turnIndex) => orderedTurnIds[turnIndex]!),
+ [orderedTurnIds, railTurnIndexes],
+ );
+ const railTurns = railTurnIndexes.map((turnIndex) => turns[turnIndex]!);
+ const mappedActiveRailTurnId = (() => {
+ if (activeTurnId === null) return null;
+ if (railTurnIds.includes(activeTurnId)) return activeTurnId;
+ const orderedActiveIndex = orderedTurnIds.indexOf(activeTurnId);
+ if (orderedActiveIndex !== -1 && orderedTurnIds.length > railTurnIds.length) {
+ return railTurnIds[Math.round(
+ orderedActiveIndex * (railTurnIds.length - 1) / (orderedTurnIds.length - 1),
+ )] ?? null;
+ }
+ return selectPromptRailTickForMountedTurn({
+ activeTurnId,
+ mountedTurnIds,
+ railTurns,
+ previousRailTurnId: previousActiveRailTurnIdRef.current,
+ atEnd: activeSelection?.atEnd ?? false,
+ });
+ })();
+ const activeRailTurnId = mappedActiveRailTurnId
+ ?? (railTurnIds.includes(previousActiveRailTurnIdRef.current ?? '')
+ ? previousActiveRailTurnIdRef.current
+ : null);
+ useEffect(() => {
+ if (activeRailTurnId !== null) previousActiveRailTurnIdRef.current = activeRailTurnId;
+ }, [activeRailTurnId]);
+
+ // React is the only writer of the active attributes. Once that render has
+ // committed, bring the current tick into the rail's own bounded viewport.
+ useEffect(() => {
+ const rail = railRef.current;
+ if (!rail || activeRailTurnId === null) return;
+ if (activeVisibilityFrame.current !== 0) cancelAnimationFrame(activeVisibilityFrame.current);
+ activeVisibilityFrame.current = requestAnimationFrame(() => {
+ activeVisibilityFrame.current = requestAnimationFrame(() => {
+ activeVisibilityFrame.current = 0;
+ keepActivePromptRailTickVisible(rail);
+ });
+ });
+ return () => {
+ if (activeVisibilityFrame.current !== 0) {
+ cancelAnimationFrame(activeVisibilityFrame.current);
+ activeVisibilityFrame.current = 0;
+ }
+ };
+ }, [activeRailTurnId]);
useEffect(() => {
const root = scrollRef.current;
- const mountedTurnList = root?.querySelector('[data-virtual-turn-id]')?.parentElement;
- if (!root || !mountedTurnList || turns.length === 0) return;
+ const messageList = root?.querySelector('.maka-chat-message-list');
+ // Astryx ChatMessageList renders one inner flex column as its first child;
+ // that column is the direct parent of Maka's keyed transcript Turn wrappers.
+ const mountedTurnList = messageList?.firstElementChild;
+ if (!root || !mountedTurnList || orderedTurnIds.length === 0) return;
const idByElement = new Map<Element, string>();
- const visible = new Set<string>();
+ let mountedTurnIndexById = new Map<string, number>();
+ const readingBandTurnIds = new Set<string>();
const observeElement = (element: Element): void => {
- const turnId = element.getAttribute('data-turn-id');
- if (!turnId || !turnIndexById.has(turnId) || idByElement.has(element)) return;
+ const turnId = element.getAttribute('data-transcript-turn-id');
+ if (!turnId || idByElement.has(element)) return;
idByElement.set(element, turnId);
observer.observe(element);
};
@@ -326,69 +490,118 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe
const turnId = idByElement.get(element);
if (!turnId) return;
idByElement.delete(element);
- visible.delete(turnId);
+ readingBandTurnIds.delete(turnId);
observer.unobserve(element);
};
const visitTurnElements = (node: Node, visit: (element: Element) => void): void => {
if (!(node instanceof Element)) return;
- if (node.hasAttribute('data-turn-id')) visit(node);
- for (const element of node.querySelectorAll('[data-turn-id]')) visit(element);
+ if (node.hasAttribute('data-transcript-turn-id')) visit(node);
+ for (const element of node.querySelectorAll('[data-transcript-turn-id]')) visit(element);
+ };
+ const refreshMountedTurnOrder = (): void => {
+ const nextMountedTurnIds = [...mountedTurnList.querySelectorAll(
+ '[data-transcript-turn-id]',
+ )].flatMap((element) => {
+ const turnId = element.getAttribute('data-transcript-turn-id');
+ return turnId ? [turnId] : [];
+ });
+ mountedTurnIndexById = new Map(
+ nextMountedTurnIds.map((turnId, index) => [turnId, index]),
+ );
+ setMountedTurnIds((current) =>
+ current.length === nextMountedTurnIds.length
+ && nextMountedTurnIds.every((turnId, index) => current[index] === turnId)
+ ? current
+ : nextMountedTurnIds,
+ );
+ };
+ const turnIdsIntersecting = (top: number, bottom: number): string[] => {
+ const turnIds: string[] = [];
+ for (const [element, turnId] of idByElement) {
+ const bounds = element.getBoundingClientRect();
+ if (bounds.bottom > top && bounds.top < bottom) turnIds.push(turnId);
+ }
+ return turnIds;
};
- const activeFor = (turnId: string | null): void => {
- if (turnId === null) return;
- const railTurnId = railTurnIdFor(turnId);
- if (railTurnId !== null) markActiveTurn(railTurnId);
+ const seedReadingBandFromGeometry = (): void => {
+ const rootBounds = root.getBoundingClientRect();
+ readingBandTurnIds.clear();
+ for (const turnId of turnIdsIntersecting(
+ rootBounds.top,
+ rootBounds.top + rootBounds.height * 0.34,
+ )) {
+ readingBandTurnIds.add(turnId);
+ }
};
const resolveActive = (): void => {
// A jump owns the highlight until its scroll settles. Without this the
// observer walks the highlight through every prompt the scroll passes,
// which is the travelling the click was meant to skip.
if (jumpTargetRef.current !== null) return;
- if (root.scrollHeight - root.scrollTop - root.clientHeight <= SCROLL_END_EPSILON_PX) {
- let latest: string | null = null;
- let latestIndex = -1;
- for (const turnId of idByElement.values()) {
- const index = turnIndexById.get(turnId) ?? -1;
- if (index > latestIndex) {
- latest = turnId;
- latestIndex = index;
- }
- }
- activeFor(latest);
- return;
- }
- let firstVisible: string | null = null;
- let firstIndex = Number.POSITIVE_INFINITY;
- for (const turnId of visible) {
- const index = turnIndexById.get(turnId) ?? Number.POSITIVE_INFINITY;
- if (index < firstIndex) {
- firstVisible = turnId;
- firstIndex = index;
- }
- }
- activeFor(firstVisible);
+ const atEnd =
+ root.scrollHeight - root.scrollTop - root.clientHeight <= SCROLL_END_EPSILON_PX;
+ const rootBounds = !atEnd && readingBandTurnIds.size === 0
+ ? root.getBoundingClientRect()
+ : null;
+ const active = selectPromptRailActiveTurn({
+ atEnd,
+ mountedTurnIds: idByElement.values(),
+ readingBandTurnIds,
+ scrollportTurnIds: rootBounds !== null
+ ? turnIdsIntersecting(rootBounds.top, rootBounds.bottom)
+ : [],
+ turnIndexById: mountedTurnIndexById,
+ });
+ if (active !== null) markActiveTurn(active, atEnd);
};
- const observer = new IntersectionObserver(
- (entries) => {
- for (const entry of entries) {
- const id = idByElement.get(entry.target);
- if (!id) continue;
- if (entry.isIntersecting) visible.add(id);
- else visible.delete(id);
- }
+ const observer = new IntersectionObserver((entries) => {
+ for (const entry of entries) {
+ const turnId = idByElement.get(entry.target);
+ if (!turnId) continue;
+ if (entry.intersectionRect.height > 0) readingBandTurnIds.add(turnId);
+ else readingBandTurnIds.delete(turnId);
+ }
+ resolveActive();
+ }, {
+ root,
+ rootMargin: '0px 0px -66% 0px',
+ // The positive threshold delivers a callback when an overlap becomes
+ // a zero-area boundary touch, which the strict geometry rule excludes.
+ threshold: [0, POSITIVE_INTERSECTION_RATIO],
+ });
+ for (const element of mountedTurnList.querySelectorAll('[data-transcript-turn-id]')) {
+ observeElement(element);
+ }
+ refreshMountedTurnOrder();
+ seedReadingBandFromGeometry();
+ resolveActive();
+
+ let membershipFrame = 0;
+ let membershipFramesLeft = 0;
+ const settleMembershipGeometry = (): void => {
+ membershipFrame = requestAnimationFrame(() => {
+ membershipFrame = 0;
+ seedReadingBandFromGeometry();
resolveActive();
- },
- { root, rootMargin: '0px 0px -66% 0px', threshold: 0 },
- );
- for (const element of mountedTurnList.querySelectorAll('[data-turn-id]')) observeElement(element);
-
+ membershipFramesLeft -= 1;
+ if (membershipFramesLeft > 0) settleMembershipGeometry();
+ });
+ };
const mutationObserver = new MutationObserver((records) => {
for (const record of records) {
for (const node of record.removedNodes) visitTurnElements(node, unobserveElement);
for (const node of record.addedNodes) visitTurnElements(node, observeElement);
}
- resolveActive();
+ refreshMountedTurnOrder();
+ // Browser scroll anchoring and the paged transcript projection can land
+ // across several frames after the child-list mutation. Follow that short
+ // settle window, or a prepended page can leave its previous boundary
+ // Turn current after the replacement is being read.
+ membershipFramesLeft = 6;
+ if (membershipFrame === 0) {
+ settleMembershipGeometry();
+ }
});
mutationObserver.observe(mountedTurnList, { childList: true });
@@ -406,9 +619,10 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe
observer.disconnect();
mutationObserver.disconnect();
root.removeEventListener('scroll', onScroll);
+ if (membershipFrame !== 0) cancelAnimationFrame(membershipFrame);
if (frame !== 0) cancelAnimationFrame(frame);
};
- }, [markActiveTurn, scrollRef, turnIndexById, railTurns]);
+ }, [markActiveTurn, orderedTurnIds, scrollRef]);
useEffect(() => {
const root = scrollRef.current;
@@ -448,7 +662,7 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe
const rail = railRef.current;
if (!rail) return;
return observeActivePromptRailVisibility(rail);
- }, [turns]);
+ }, [orderedTurnIds]);
// A click owns the highlight until the destination settles, so the scroll it
// started cannot walk the active tick through every prompt on the way. Keyed
@@ -525,7 +739,7 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe
onPointerLeave={() => setHoveredIndex(null)}
>
{railTurns.map((turn, index) => {
- const isActive = turn.turnId === activeTurnIdRef.current;
+ const isActive = turn.turnId === activeRailTurnId;
const preview = turn.label.trim() || copy.emptyPrompt;
const replyPreview = (turn.reply ?? '').replace(/\s+/g, ' ').trim().slice(0, 140);
const proximity =
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org