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/13 09:47:32 UTC
(maka) branch fix/transcript-viewport-mounting created (now e58d0fa568)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a change to branch fix/transcript-viewport-mounting
in repository https://gitbox.apache.org/repos/asf/maka.git
at e58d0fa568 test(perf): compare viewport mounting at the original page budget
This branch includes the following new commits:
new b65a8c1647 fix(ui): mount transcript bodies only near the viewport
new e58d0fa568 test(perf): compare viewport mounting at the original page budget
The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 02/02: test(perf): compare viewport mounting at the original page budget
Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch fix/transcript-viewport-mounting
in repository https://gitbox.apache.org/repos/asf/maka.git
commit e58d0fa568de041ad6192ff6c4caa555b0012963
Author: AstroHan <le...@outlook.com>
AuthorDate: Sun Sep 13 17:47:10 2026 +0800
test(perf): compare viewport mounting at the original page budget
---
.github/workflows/performance-frontend.yml | 22 +++++++++++++++++++---
scripts/perf/scroll-paired.mjs | 9 +++++++--
2 files changed, 26 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/performance-frontend.yml b/.github/workflows/performance-frontend.yml
index 152a010bda..81df1bc6b4 100644
--- a/.github/workflows/performance-frontend.yml
+++ b/.github/workflows/performance-frontend.yml
@@ -35,14 +35,30 @@ jobs:
- run: npm ci
- run: npm --workspace @maka/desktop run build:workspace-deps
- run: npm --workspace @maka/desktop run build
+ - run: npm --workspace @maka/desktop run build-storybook
- id: browsers
run: npx playwright install --with-deps chromium
- name: Fetch exact experiment sources
- run: git fetch --depth=1 origin 839d14535a541ef0da29ea4d6626a2e3d0e32a8e 0039a230754a4bd832653f481194fb40516464db
- - name: Paired page admission ablation on real Host
+ run: git fetch --depth=1 origin 839d14535a541ef0da29ea4d6626a2e3d0e32a8e 0039a230754a4bd832653f481194fb40516464db b65a8c16476c61336c87b9838f161886df26c5e1
+ - name: Virtual history continuity and selection
+ run: |
+ node --input-type=module <<'JS'
+ import { chromium } from '@playwright/test';
+ import { startStaticServer, smokeStory } from './scripts/storybook-visual-smoke.mjs';
+ const server = await startStaticServer('apps/desktop/storybook-static');
+ const browser = await chromium.launch();
+ try {
+ const page = await browser.newPage();
+ await smokeStory(page, server.baseUrl, {
+ storyId: 'product-shell-official-appshell--virtual-history-continuity',
+ colorScheme: 'light', palette: 'default',
+ });
+ } finally { await browser.close(); await server.close(); }
+ JS
+ - name: Paired viewport mounting on real Host
env:
MAKA_PERF_OUTPUT: ${{ github.workspace }}/perf-results
- MAKA_PERF_VARIANTS: A,F,F,A,A,F
+ MAKA_PERF_VARIANTS: A,V,V,A,A,V
MAKA_PERF_TARGET_TURN: '60'
run: xvfb-run -a node scripts/perf/scroll-paired.mjs
- name: Upload raw measurements and diagnostics
diff --git a/scripts/perf/scroll-paired.mjs b/scripts/perf/scroll-paired.mjs
index f99382d9c4..373aa080ff 100644
--- a/scripts/perf/scroll-paired.mjs
+++ b/scripts/perf/scroll-paired.mjs
@@ -28,12 +28,14 @@ const refs = {
D: '0039a230754a4bd832653f481194fb40516464db',
E: '839d14535a541ef0da29ea4d6626a2e3d0e32a8e',
F: '839d14535a541ef0da29ea4d6626a2e3d0e32a8e',
+ V: 'b65a8c16476c61336c87b9838f161886df26c5e1',
};
const files = [
'packages/ui/src/chat-view.tsx',
'packages/ui/src/use-chat-scroll.ts',
'packages/ui/src/transcript-scroll-authority.tsx',
'apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts',
+ 'packages/ui/src/use-transcript-known-space.ts',
];
const original = files.map((file) => readFileSync(file));
const output = path.resolve(process.env.MAKA_PERF_OUTPUT ?? 'perf-results');
@@ -46,8 +48,11 @@ if (order.some((variant) => !(variant in refs))) throw new Error('Unknown scroll
mkdirSync(output, { recursive: true });
try {
for (const [index, variant] of order.entries()) {
- for (const file of files)
- writeFileSync(file, execFileSync('git', ['show', refs[variant] + ':' + file]));
+ for (const file of files) {
+ const ref =
+ file.endsWith('use-transcript-known-space.ts') && variant !== 'V' ? refs.B : refs[variant];
+ writeFileSync(file, execFileSync('git', ['show', ref + ':' + file]));
+ }
{
const file = files[3];
let source = readFileSync(file, 'utf8');
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 01/02: fix(ui): mount transcript bodies only near the viewport
Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch fix/transcript-viewport-mounting
in repository https://gitbox.apache.org/repos/asf/maka.git
commit b65a8c16476c61336c87b9838f161886df26c5e1
Author: AstroHan <le...@outlook.com>
AuthorDate: Sun Sep 13 17:46:39 2026 +0800
fix(ui): mount transcript bodies only near the viewport
---
apps/desktop/stories/app-shell.stories.tsx | 51 ++++++++++++++++
packages/ui/src/chat-view.tsx | 18 ++++--
packages/ui/src/use-chat-scroll.ts | 5 +-
packages/ui/src/use-transcript-known-space.ts | 29 +++++----
packages/ui/src/virtual-transcript-turn.tsx | 86 +++++++++++++++++++++++++++
scripts/perf/geometry-navigation.spec.ts | 4 +-
scripts/perf/scroll-input.spec.ts | 25 ++++++--
7 files changed, 192 insertions(+), 26 deletions(-)
diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx
index 2f2864c53c..98ea5938fb 100644
--- a/apps/desktop/stories/app-shell.stories.tsx
+++ b/apps/desktop/stories/app-shell.stories.tsx
@@ -2572,6 +2572,57 @@ export const HistoryWindowTraversal: Story = {
render: () => <HistoryHarness turns={40} bounded />,
};
+// Real path: traverse a long Session, return through already read history,
+// and jump to a loaded Turn whose body is currently outside the viewport.
+export const VirtualHistoryContinuity: Story = {
+ render: () => <HistoryHarness turns={40} bounded />,
+ play: async () => {
+ await historySettled();
+ const root = tailScroller();
+ const bodies = () => root.querySelectorAll('.maka-turn[data-turn-id]');
+ await waitFor(() => {
+ expect(bodies().length).toBeGreaterThan(0);
+ expect(bodies().length).toBeLessThan(root.querySelectorAll('.maka-transcript-turn').length);
+ });
+ const placeholder = root.querySelector<HTMLElement>('[data-virtual-placeholder]')!;
+ const target = placeholder.dataset.turnId!;
+ scrollAsReader(root, root.scrollTop + placeholder.getBoundingClientRect().top - root.getBoundingClientRect().top);
+ await waitFor(() => expect(root.querySelector(`.maka-turn[data-turn-id="${target}"]`)).not.toBeNull());
+
+ const traverse = async (direction: -1 | 1) => {
+ for (let step = 0; step < 160; step++) {
+ scrollAsReader(root, root.scrollTop + direction * root.clientHeight / 2);
+ await painted(5);
+ if (direction < 0 ? root.scrollTop <= 1 : root.scrollHeight - root.clientHeight - root.scrollTop <= 1) return;
+ }
+ throw new Error('History traversal did not reach its edge');
+ };
+ await traverse(-1);
+ await traverse(1);
+ await painted(8);
+ const knownHeight = root.scrollHeight;
+ await traverse(-1);
+ await traverse(1);
+ await painted(8);
+ expect(Math.abs(root.scrollHeight - knownHeight), 'revisiting measured history must preserve its extent').toBeLessThanOrEqual(1);
+ expect(bodies().length).toBeLessThan(16);
+
+ const selected = bodies().item(bodies().length - 1);
+ const selection = document.getSelection()!;
+ const range = document.createRange();
+ range.selectNodeContents(selected);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ const text = selection.toString();
+ expect(text.length).toBeGreaterThan(0);
+ await traverse(-1);
+ expect(selected.isConnected, 'an active selection must survive leaving the viewport').toBe(true);
+ expect(selection.toString()).toBe(text);
+ selection.removeAllRanges();
+ await waitFor(() => expect(selected.isConnected, 'released offscreen content should unmount').toBe(false));
+ },
+};
+
// #4256: one Turn taller than several viewports, its reasoning / answer / tool
// blocks each carrying a `data-maka-transcript-boundary` marker so sub-turn
// content-visibility bounds them. Reasoning stays mounted while folded, so it is
diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx
index bbf81240cc..20a73086a5 100644
--- a/packages/ui/src/chat-view.tsx
+++ b/packages/ui/src/chat-view.tsx
@@ -47,6 +47,7 @@ import { useLayer } from '@astryxdesign/core/Layer';
import { materializeChat } from './materialize.js';
import { useTranscriptProjection } from './use-transcript-projection.js';
import { useTranscriptKnownSpace } from './use-transcript-known-space.js';
+import { VirtualTranscriptTurn } from './virtual-transcript-turn.js';
import type { LiveTurnProjection } from './live-turn-projection.js';
import {
ModelProviderRetryIndicator,
@@ -552,6 +553,7 @@ export function ChatView(props: {
}
const knownSpace = useTranscriptKnownSpace(scrollRef, props.activeSession?.id,
turns.map((turn) => turn.turnId), Boolean(props.onRetainWindow));
+ const virtualized = Boolean(props.onRetainWindow) && typeof IntersectionObserver !== 'undefined';
const { highlightedTurnId } = useChatScroll({
scrollRef,
sessionId: props.activeSession?.id,
@@ -761,10 +763,16 @@ export function ChatView(props: {
aria-hidden="true" style={{ height: knownSpace.beforeHeight, flexShrink: 0, overflowAnchor: 'none' }} />}
{turns.map((turn) => {
return (
- <div
- key={turn.turnId}
- className="maka-transcript-turn"
- data-transcript-turn-id={turn.turnId}
+ <VirtualTranscriptTurn
+ key={`${props.activeSession?.id}:${turn.turnId}`}
+ turnId={turn.turnId}
+ scrollRef={scrollRef}
+ enabled={virtualized}
+ required={turn.turnId === props.activeTurn?.turnId
+ || turn.turnId === scrollTargetTurn?.turnId
+ || turn.turnId === props.restoreTargetTurn?.turnId}
+ initialHeight={knownSpace.height(turn.turnId)}
+ onMeasure={knownSpace.measure}
>
<TurnView
turn={turn}
@@ -814,7 +822,7 @@ export function ChatView(props: {
{conversationItemPlacement.byTurn.get(turn.turnId)?.map((item) => (
<Fragment key={item.id}>{item.content}</Fragment>
))}
- </div>
+ </VirtualTranscriptTurn>
);
})}
{transientMessages.filter(
diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts
index a65b065eca..1cfb2bf6dd 100644
--- a/packages/ui/src/use-chat-scroll.ts
+++ b/packages/ui/src/use-chat-scroll.ts
@@ -221,9 +221,12 @@ export function useChatScroll(input: {
? commandTargetTurnId.current
: undefined;
if (pending && turns.some((turn) => turn.dataset.turnId === pending)) return;
+ const selection = root.ownerDocument.getSelection?.();
const kept = turns.filter((turn) => {
const box = turn.getBoundingClientRect();
- return box.bottom >= rect.top - screen * 4 && box.top <= rect.bottom + screen * 4;
+ return (box.bottom >= rect.top - screen * 4 && box.top <= rect.bottom + screen * 4)
+ || turn.contains(root.ownerDocument.activeElement)
+ || Boolean(selection && !selection.isCollapsed && selection.containsNode(turn, true));
});
const first = kept[0]?.dataset.turnId;
const last = kept.at(-1)?.dataset.turnId;
diff --git a/packages/ui/src/use-transcript-known-space.ts b/packages/ui/src/use-transcript-known-space.ts
index 64fe7d9672..0091b95419 100644
--- a/packages/ui/src/use-transcript-known-space.ts
+++ b/packages/ui/src/use-transcript-known-space.ts
@@ -17,12 +17,10 @@
* under the License.
*/
-import { useLayoutEffect, useRef, type RefObject } from 'react';
+import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react';
-// Experiment only: measured space within one overlapping, fixed-layout history
-// traversal. No unknown-height estimates, persistent cache or publication change.
-// Resize/content revision and arbitrary disconnected navigation are not acceptance
-// claims of this A/B prototype. It is not intended to merge as a complete design.
+// The geometry ledger contains sizes and identities, never message bodies.
+// Unmeasured loaded rows get a local placeholder; unseen history has no estimate.
export function useTranscriptKnownSpace(
root: RefObject<HTMLElement | null>,
sessionId: string | undefined,
@@ -37,31 +35,32 @@ export function useTranscriptKnownSpace(
}>({ sessionId, ids: [], pitch: new Map(), gap: 0 });
const old = previous.current;
const mounted = new Set(ids);
- const overlaps = old.sessionId === sessionId
+ const overlaps = enabled && old.sessionId === sessionId
? old.ids.flatMap((id, index) => mounted.has(id) ? [index] : []) : [];
const prefix = overlaps.length ? old.ids.slice(0, overlaps[0]) : [];
const suffix = overlaps.length ? old.ids.slice(overlaps.at(-1)! + 1) : [];
const pitch = overlaps.length ? new Map(old.pitch) : new Map<string, number>();
- const sum = (values: readonly string[]) => values.reduce((total, id) => total + (pitch.get(id) ?? 0), 0);
+ const estimate = 320;
+ const sum = (values: readonly string[]) => values.reduce((total, id) => total + (pitch.get(id) ?? estimate + old.gap), 0);
const before = sum(prefix);
const after = sum(suffix);
const gap = overlaps.length ? old.gap : 0;
+ useEffect(() => {
+ if (!enabled) return;
+ const parent = root.current?.querySelector('.maka-transcript-turn')?.parentElement;
+ if (parent) previous.current.gap = Number.parseFloat(getComputedStyle(parent).rowGap) || 0;
+ }, [enabled, root, sessionId]);
useLayoutEffect(() => {
if (!enabled) return;
- const rows = [...(root.current?.querySelectorAll<HTMLElement>('.maka-transcript-turn') ?? [])];
- const nextGap = rows[0]?.parentElement
- ? Number.parseFloat(getComputedStyle(rows[0].parentElement).rowGap) || 0 : 0;
- for (const row of rows) {
- const id = row.dataset.transcriptTurnId;
- if (id) pitch.set(id, row.getBoundingClientRect().height + nextGap);
- }
- previous.current = { sessionId, ids: [...prefix, ...ids, ...suffix], pitch, gap: nextGap };
+ previous.current = { sessionId, ids: [...prefix, ...ids, ...suffix], pitch, gap: previous.current.gap };
});
return {
before, after,
beforeHeight: Math.max(0, before - gap),
afterHeight: Math.max(0, after - gap),
+ height: (id: string) => Math.max(1, (pitch.get(id) ?? estimate + gap) - gap),
+ measure: (id: string, height: number) => previous.current.pitch.set(id, height + previous.current.gap),
};
}
diff --git a/packages/ui/src/virtual-transcript-turn.tsx b/packages/ui/src/virtual-transcript-turn.tsx
new file mode 100644
index 0000000000..b2fa67aa21
--- /dev/null
+++ b/packages/ui/src/virtual-transcript-turn.tsx
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
+
+/** A flow placeholder owns geometry, never scroll position or history reads. */
+export function VirtualTranscriptTurn({
+ turnId, scrollRef, enabled, required, initialHeight, onMeasure, children,
+}: {
+ turnId: string;
+ scrollRef: RefObject<HTMLElement | null>;
+ enabled: boolean;
+ required: boolean;
+ initialHeight: number;
+ onMeasure(id: string, height: number): void;
+ children: ReactNode;
+}) {
+ const root = useRef<HTMLDivElement>(null);
+ const height = useRef(initialHeight);
+ const measure = useRef(onMeasure);
+ measure.current = onMeasure;
+ const [nearby, setNearby] = useState(!enabled);
+ const [retained, setRetained] = useState(false);
+ const mounted = !enabled || required || nearby || retained;
+
+ useEffect(() => {
+ const node = root.current;
+ const viewport = scrollRef.current;
+ if (!enabled || !node || !viewport) return;
+ const retainUserState = () => {
+ const selection = node.ownerDocument.getSelection();
+ // Removing a focused editor or a selection would destroy user state.
+ setRetained(node.contains(node.ownerDocument.activeElement)
+ || Boolean(selection && !selection.isCollapsed && selection.containsNode(node, true)));
+ };
+ const observer = new IntersectionObserver(([entry]) => {
+ retainUserState();
+ setNearby(entry.isIntersecting);
+ }, { root: viewport, rootMargin: '800px 0px' });
+ observer.observe(node);
+ const afterFocus = () => queueMicrotask(retainUserState);
+ node.addEventListener('focusout', afterFocus);
+ node.ownerDocument.addEventListener('selectionchange', retainUserState);
+ return () => {
+ observer.disconnect();
+ node.removeEventListener('focusout', afterFocus);
+ node.ownerDocument.removeEventListener('selectionchange', retainUserState);
+ };
+ }, [enabled, scrollRef]);
+
+ useEffect(() => {
+ const node = root.current;
+ if (!enabled || !mounted || !node) return;
+ const observer = new ResizeObserver(([entry]) => {
+ const next = entry.borderBoxSize[0]?.blockSize ?? entry.contentRect.height;
+ if (next <= 0) return;
+ height.current = next;
+ measure.current(turnId, next);
+ });
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [enabled, mounted, turnId]);
+
+ return <div ref={root} className="maka-transcript-turn"
+ data-transcript-turn-id={turnId}
+ data-turn-id={mounted ? undefined : turnId}
+ data-virtual-placeholder={mounted ? undefined : ''}
+ style={mounted ? undefined : { height: height.current, flexShrink: 0 }}
+ >{mounted ? children : null}</div>;
+}
diff --git a/scripts/perf/geometry-navigation.spec.ts b/scripts/perf/geometry-navigation.spec.ts
index 83b606ff9e..edd5504531 100644
--- a/scripts/perf/geometry-navigation.spec.ts
+++ b/scripts/perf/geometry-navigation.spec.ts
@@ -58,7 +58,9 @@ test('production layout: document mount and older history', async () => {
).observe({ type: 'longtask', buffered: true });
});
const ready = async (turn: number) => {
- await expect(page.locator(`[data-turn-id="turn-prompt-rail-${turn}"]`)).toHaveCount(1);
+ await expect(
+ page.locator(`.maka-turn[data-turn-id="turn-prompt-rail-${turn}"]`),
+ ).toHaveCount(1);
await page.evaluate(() => document.fonts.ready);
await expect(page.locator('.maka-markdown-pending')).toHaveCount(0);
await page.evaluate(
diff --git a/scripts/perf/scroll-input.spec.ts b/scripts/perf/scroll-input.spec.ts
index 1a024e3507..42c7e5e205 100644
--- a/scripts/perf/scroll-input.spec.ts
+++ b/scripts/perf/scroll-input.spec.ts
@@ -44,7 +44,9 @@ test('dense upward input with real Host history', async () => {
const diagnose = process.env.MAKA_PERF_DIAGNOSE === '1';
for (let trial = 0; trial < (diagnose ? 1 : 3); trial++) {
await page.reload();
- await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(1);
+ await expect(page.locator('.maka-turn[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(
+ 1,
+ );
if (process.env.MAKA_PERF_NO_HAS === '1') {
const removed = await page.evaluate(() => {
const removed: string[] = [];
@@ -120,11 +122,17 @@ test('dense upward input with real Host history', async () => {
const ids = [...root.querySelectorAll<HTMLElement>('[data-turn-id]')].map(
(el) => el.dataset.turnId,
);
- if (p.ranges.at(-1)?.ids.join(',') === ids.join(',')) return;
+ const mountedCount = root.querySelectorAll('.maka-turn[data-turn-id]').length;
+ if (
+ p.ranges.at(-1)?.ids.join(',') === ids.join(',') &&
+ p.ranges.at(-1)?.mountedCount === mountedCount
+ )
+ return;
p.ranges.push({
ms: performance.now(),
phase: p.phase,
ids,
+ mountedCount,
});
};
range();
@@ -208,7 +216,10 @@ test('dense upward input with real Host history', async () => {
20,
);
const initial = raw.frames[0];
- const changes = raw.ranges.slice(1);
+ const changes = raw.ranges.filter(
+ (frame: any, i: number) =>
+ i > 0 && frame.ids.join(',') !== raw.ranges[i - 1].ids.join(','),
+ );
expect(
changes.length,
'real Host history must publish at least one changed range',
@@ -228,11 +239,17 @@ test('dense upward input with real Host history', async () => {
maxFrameGapMs: Math.max(
...raw.frames.slice(1).map((frame: any, i: number) => frame.ms - raw.frames[i].ms),
),
- maxMounted: Math.max(...raw.ranges.map((frame: any) => frame.ids.length)),
+ maxMounted: Math.max(...raw.ranges.map((frame: any) => frame.mountedCount)),
publicationsDuringInput: changes.filter((frame: any) => frame.ms < releasedAt).length,
publicationsAfterInput: changes.filter((frame: any) => frame.ms >= releasedAt).length,
firstPublicationAfterReleaseMs: firstReleased ? firstReleased.ms - releasedAt : -1,
};
+ if (process.env.MAKA_PERF_VARIANT === 'V') {
+ expect(
+ row.maxMounted,
+ 'a fetched page must not mount all of its Turn bodies',
+ ).toBeLessThan(Math.max(...raw.ranges.map((frame: any) => frame.ids.length)) / 2);
+ }
samples.push(row);
console.log(JSON.stringify(row));
await writeFile(
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org