diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index ca1078374a..3ef2c9e555 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -873,7 +873,7 @@ "react": 1 }, "importSpecifiers": 109, - "nonTriviaTokens": 13749 + "nonTriviaTokens": 13746 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, diff --git a/apps/desktop/src/main/__tests__/chat-view-optimistic-render.test.ts b/apps/desktop/src/main/__tests__/chat-view-optimistic-render.test.ts index bf71bb0372..129d826841 100644 --- a/apps/desktop/src/main/__tests__/chat-view-optimistic-render.test.ts +++ b/apps/desktop/src/main/__tests__/chat-view-optimistic-render.test.ts @@ -53,7 +53,6 @@ function renderNoSessionChatView( ...props, } as ComponentProps); const layout = createElement(ChatSurfaceLayout, { - scrollOwner: 'host', composer: null, children: view, }); diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index f0fa7ea648..aa961387c6 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -21,6 +21,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { createElement, type ReactNode } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; +import { parseHTML } from 'linkedom'; import type { SessionEvent } from '@maka/core/events'; import { armLiveTurn, @@ -87,6 +88,31 @@ function renderLiveTurn(liveTurn: LiveTurnProjection): string { } describe('single live-turn handoff', () => { + it('keeps activity in the answer footer before the session or Turn arrives', () => { + const session: NonNullable[0]['activeSession']> = { + id: 'session-1', name: 'pending', status: 'running' as const, backend: 'ai-sdk', + labels: [], isFlagged: false, isArchived: false, hasUnread: false, + llmConnectionSlug: 'conn', connectionLocked: false, model: 'model', permissionMode: 'ask' as const, + }; + for (const activeSession of [undefined, session]) { + const markup = renderWithLocale(createElement(ChatView, { + activeSession, + messages: [], + transientMessages: [{ + id: 'message-pending', ts: 1, text: 'send now', + transientPlacement: 'current_turn', + }], + runningStatus: true, + scrollBehavior: 'smooth', + onNew() {}, + } satisfies Parameters[0])); + const { document } = parseHTML(markup); + const status = document.querySelector('.maka-assistant-answer [role="status"]'); + assert.ok(status?.closest('.maka-turn-footer'), 'activity must occupy the shared footer'); + assert.equal(document.querySelector('.maka-assistant-answer [role="toolbar"]'), null); + } + }); + it('renders a transient user message without manufacturing a Turn', () => { const markup = renderWithLocale(createElement(ChatView, { activeSession: { @@ -164,7 +190,9 @@ describe('single live-turn handoff', () => { } satisfies Parameters[0])); assert.doesNotMatch(markup, /maka-chat-message-loading/); - assert.ok(markup.indexOf('send now') < markup.indexOf('data-turn-id="turn-1"')); + const answerIndex = markup.indexOf('maka-assistant-answer'); + assert.ok(answerIndex >= 0); + assert.ok(markup.indexOf('send now') < answerIndex); assert.equal((markup.match(/data-transient-message-id="turn-1"/g) ?? []).length, 1); assert.equal((markup.match(/data-transcript-turn-id="turn-1"/g) ?? []).length, 1); }); @@ -200,8 +228,10 @@ describe('single live-turn handoff', () => { onNew() {}, } satisfies Parameters[0])); - assert.ok(markup.indexOf('send now') < markup.indexOf('data-turn-id="host-turn"')); - assert.ok(markup.indexOf('do this next') > markup.indexOf('data-turn-id="host-turn"')); + const answerIndex = markup.indexOf('maka-assistant-answer'); + assert.ok(answerIndex >= 0); + assert.ok(markup.indexOf('send now') < answerIndex); + assert.ok(markup.indexOf('do this next') > answerIndex); assert.equal((markup.match(/data-transient-message-id=/g) ?? []).length, 2); }); diff --git a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts index b4829000eb..78353d503f 100644 --- a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts @@ -194,6 +194,7 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { CSS: globalThis.CSS, document: globalThis.document, window: globalThis.window, Element: globalThis.Element, HTMLElement: globalThis.HTMLElement, Node: globalThis.Node, MutationObserver: globalThis.MutationObserver, ResizeObserver: globalThis.ResizeObserver, + requestAnimationFrame: globalThis.requestAnimationFrame, IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT, }; const { document, window } = parseHTML('
'); @@ -232,6 +233,7 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { CSS: { escape: (value: string) => value }, document, window, Element: window.Element, HTMLElement: window.HTMLElement, Node: window.Node, MutationObserver: TestMutationObserver, ResizeObserver: TestResizeObserver, + requestAnimationFrame: window.requestAnimationFrame, IS_REACT_ACT_ENVIRONMENT: true, }); const messages: StoredMessage[] = []; @@ -307,7 +309,14 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { await act(async () => { authority!.releasePin(); await commands.current!.loadHistory(direction); }); }, async readAt(offset: number) { - await act(() => { scroller.scrollTop = offset; scroller.dispatchEvent(new window.Event('scroll')); }); + await act(() => { + const input = new window.Event('wheel'); + Object.assign(input, { deltaY: offset - scroller.scrollTop }); + scroller.dispatchEvent(input); + scroller.scrollTop = offset; + scroller.dispatchEvent(new window.Event('scroll')); + scroller.dispatchEvent(new window.Event('scrollend')); + }); await render(); }, async append(id: string, size: number) { diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 5b791ca802..135de12c0f 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2505,7 +2505,6 @@ function AppShellContent({ // following the tail and the moves the reader asks for are one // authority there, and the composer never remounts for any of // them — its contenteditable DOM carries the live draft. - scrollOwner="host" data-maka-onboarding={showOnboardingHero ? 'true' : undefined} scrollToBottomLabel={ desktopConversationCopy.actions.scrollMainToBottom diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index ff97628181..103c8098ff 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -301,7 +301,6 @@ export function QuoteCompanionPanel(props: { return (
diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx index 5c763ccd77..154a5d01bb 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx @@ -155,7 +155,6 @@ export function WorkHubRoot() { diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 1380bcac24..aa84d5cccd 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -148,6 +148,13 @@ contain-intrinsic-block-size: auto 96px; } +/* A long prompt can put even the first answer beyond the relevance margin. + The streaming frontier must contribute its real height before following; + completed blocks in the same active Turn still retain lazy layout. */ +.maka-chat-message-list [data-maka-transcript-boundary][data-live-streaming='true'] { + content-visibility: visible; +} + /* Container blocks — a Processing sequence, a linked-agent list — hold many entries, so their first-paint estimate stays multi-line. It remains an estimate rather than a clamp: the block grows to its measured size after diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 4764dbc1d3..16e4bd739a 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -485,7 +485,6 @@ function ComposedShell(props: { (
void) | undefined; let startTailStream: (() => void) | undefined; +let settleTailTurn: (() => void) | undefined; /** Streams one line per frame into a live Turn. */ -function StreamingTailHarness() { +function StreamingTailHarness({ pendingUser = false }: { pendingUser?: boolean } = {}) { const [question, setQuestion] = useState(); + const [settled, setSettled] = useState(false); const [streaming, setStreaming] = useState(false); const [viewportNavigation] = useState(createTranscriptViewportNavigation); const [lines, setLines] = useState(1); useEffect(() => { startTailStream = () => setStreaming(true); - return () => { startTailStream = undefined; }; + settleTailTurn = () => setSettled(true); + return () => { startTailStream = undefined; settleTailTurn = undefined; }; }, []); useEffect(() => { if (!streaming) return; @@ -2112,7 +2122,7 @@ function StreamingTailHarness() { }, [streaming]); return ( { // Production publishes this once before admitting the sent Message. @@ -2123,23 +2133,29 @@ function StreamingTailHarness() { }, }} chat={{ - runningStatus: Boolean(question), + runningStatus: Boolean(question) && !settled, + transientMessages: pendingUser && question && !settled ? [{ + id: 'msg-tail-1', text: question, ts: NOW - 30_000, + transientPlacement: 'current_turn', hostTurnId: 'turn-tail', + deliveryStatus: '已接收', + }] : [], viewportNavigation, messages: [ user('history-question', 'history-turn', 6, '已有问题。'), assistant('history-answer', 'history-turn', 5, TAIL_LINES.slice(0, 40).join('\n\n')), ...(question ? [ - user('msg-tail-1', 'turn-tail', 3, question), + ...(!pendingUser || settled ? [user('msg-tail-1', 'turn-tail', 3, question)] : []), + ...(settled ? [assistant('msg-assistant-tail', 'turn-tail', 2, TAIL_LINES.slice(0, lines).join('\n\n'))] : []), { type: 'turn_state' as const, id: 'state-tail', turnId: 'turn-tail', ts: NOW - 30_000, - status: 'running' as const, + status: settled ? 'completed' as const : 'running' as const, }, ] : []), ], - liveTurn: question ? { + liveTurn: question && !settled ? { turnId: 'turn-tail', phase: 'streamed', steps: [{ @@ -2168,7 +2184,7 @@ export const StreamingTailFollow: Story = { const input = canvasElement.querySelector('.maka-composer-editor [contenteditable="true"]'); if (!input) throw new Error('The composer input is missing'); await userEvent.type(input, 'Second question after reading history.'); - tailScroller().scrollTop = 0; + scrollAsReader(tailScroller(), 0); await painted(6); expect(tailMetrics().distance).toBeGreaterThan(500); expect(dockOffered()).toBe(true); @@ -2203,6 +2219,69 @@ export const StreamingTailFollow: Story = { }, }; +async function verifySubmittedPrompt(canvasElement: HTMLElement, lines = 1): Promise { + await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); + await painted(40); + const input = canvasElement.querySelector('.maka-composer-editor [contenteditable="true"]'); + if (!input) throw new Error('The composer input is missing'); + await userEvent.type(input, '请简短说明当前提交过程发生了什么。', { delay: null }); + for (let line = 1; line < lines; line += 1) { + await userEvent.keyboard('{Shift>}{Enter}{/Shift}', { delay: null }); + await userEvent.type(input, '这是多行提示词,提交后输入框收起仍应平稳跟随新回答。', { delay: null }); + } + await painted(40); + // Observe admission itself: the correct final bottom can hide a reverse + // jump when an offscreen block first uses an estimate, then its real size. + const tops: number[] = []; + const arrival = (async () => { + for (let frame = 0; frame < 40; frame += 1) { + await painted(1); + const turn = canvasElement.querySelector('[data-transcript-turn-id="turn-tail"]'); + if (turn) tops.push(turn.getBoundingClientRect().top); + } + })(); + await userEvent.keyboard('{Enter}'); + await arrival; + await expect(tops.length).toBeGreaterThan(1); + const reversal = Math.max(0, ...tops.slice(1).map((top, index) => top - tops[index]!)); + await expect(reversal, JSON.stringify(tops)).toBeLessThanOrEqual(4); + await expect(tailMetrics().distance).toBeLessThanOrEqual(4); +} + +export const SubmittedPromptDoesNotReverse: Story = { + render: () => , + play: async ({ canvasElement }) => verifySubmittedPrompt(canvasElement), +}; + +export const MultilineSubmittedPromptDoesNotReverse: Story = { + render: () => , + play: async ({ canvasElement }) => verifySubmittedPrompt(canvasElement, 8), +}; + +export const TallSubmittedPromptDoesNotReverse: Story = { + render: () => , + play: async ({ canvasElement }) => verifySubmittedPrompt(canvasElement, 80), +}; + +export const SubmittedPromptSettlesWithoutReversing: Story = { + render: () => , + play: async ({ canvasElement }) => { + await verifySubmittedPrompt(canvasElement); + const turn = canvasElement.querySelector('[data-transcript-turn-id="turn-tail"]')!; + const before = turn.getBoundingClientRect().top; + const offsets: number[] = []; + settleTailTurn?.(); + for (let frame = 0; frame < 40; frame += 1) { + await painted(1); + offsets.push(turn.getBoundingClientRect().top - before); + } + expect(Math.max(...offsets), JSON.stringify(offsets)).toBeLessThanOrEqual(4); + expect(canvasElement.querySelector('.maka-message-delivery')).toBeNull(); + expect(canvasElement.querySelector('.maka-turn-processing')).toBeNull(); + expect(tailMetrics().distance).toBeLessThanOrEqual(4); + }, +}; + /** Lets a play function drive props React owns. One story renders per page. */ let appendTurn: (() => void) | undefined; @@ -2314,7 +2393,7 @@ export const ReaderScrolledUpIsNotPulledBack: Story = { expect(boundaryStyle.willChange).toContain('opacity'); await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); - root.scrollTop -= 500; + scrollAsReader(root, root.scrollTop - 500); await painted(6); const before = tailMetrics().distance; expect(before, JSON.stringify(tailMetrics())).toBeGreaterThan(100); @@ -2350,13 +2429,15 @@ export const ReaderScrolledUpIsNotPulledBack: Story = { // pins the causal contract; these samples separately ensure the fix never // turns into a real footer movement. const iconTops: number[] = []; - root.scrollTop = root.scrollHeight; + scrollAsReader(root, root.scrollHeight); root.dispatchEvent(new Event('scroll')); for (let frame = 0; frame < 16; frame += 1) { await painted(1); iconTops.push(contextGauge.querySelector('svg')!.getBoundingClientRect().top); } - expect(Math.max(...iconTops) - Math.min(...iconTops)).toBeLessThanOrEqual(0.25); + // Chromium rounds the native scroll limit to a CSS pixel; a fractional + // content height can move the sticky dock by up to half a pixel. + expect(Math.max(...iconTops) - Math.min(...iconTops)).toBeLessThanOrEqual(0.5); }, }; @@ -2365,7 +2446,7 @@ export const DockAffordanceReturnsToTail: Story = { play: async () => { await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); - tailScroller().scrollTop = 0; + scrollAsReader(tailScroller(), 0); await painted(6); // Offered at all is the assertion: with Astryx's scroll layer off, its // `isScrolledUp` never updates again, so the stock button would stay @@ -2538,7 +2619,7 @@ export const OversizedTurnHoldsAReadingAnchorOnColdScroll: Story = { // `behavior: 'instant'` overrides the shell's smooth scrolling: the shell // animates over many frames, and a step measured before the animation // lands reads a still anchor as a 240px jump. - root.scrollTo({ top: root.scrollTop - intended, behavior: 'instant' }); + scrollAsReader(root, root.scrollTop - intended); root.dispatchEvent(new Event('scroll')); await painted(4); const moved = anchor.getBoundingClientRect().top - topBefore; @@ -2567,6 +2648,11 @@ export const OversizedTurnHoldsAReadingAnchorOnColdScroll: Story = { }, }; +export const OversizedLiveTurnHoldsAReadingAnchorOnColdScroll: Story = { + ...OversizedTurnHoldsAReadingAnchorOnColdScroll, + render: () => , +}; + export const AWheelTheScrollerCannotActOnAsksForHistory: Story = { render: () => , play: async () => { @@ -2593,7 +2679,7 @@ export const EarlierHistoryLandsAboveTheReader: Story = { // Just short of the band that asks for more, so the active range has // painted turns around the reader before the load starts. Landing straight // on zero leaves no visible turn above the load boundary to anchor on. - root.scrollTop = loadBand() + 400; + scrollAsReader(root, loadBand() + 400); await painted(6); const before = firstResidentTurnId(); const heightBefore = root.scrollHeight; @@ -2601,7 +2687,7 @@ export const EarlierHistoryLandsAboveTheReader: Story = { // The move that asks for earlier history and the reading of where the // reader is, in one task. - root.scrollTop = Math.min(300, root.scrollHeight - root.clientHeight); + scrollAsReader(root, Math.min(300, root.scrollHeight - root.clientHeight)); const rootTop = root.getBoundingClientRect().top; const turn = [...root.querySelectorAll('[data-turn-id]')].find( (candidate) => candidate.getBoundingClientRect().bottom > rootTop, @@ -2690,7 +2776,7 @@ export const UpwardTraversalHoldsTurnGeometry: Story = { while (root.scrollTop > 0 && steps < 40) { const anchor = anchorInView(); const scrollBefore = root.scrollTop; - root.scrollTop = Math.max(0, scrollBefore - TRAVERSAL_STEP); + scrollAsReader(root, Math.max(0, scrollBefore - TRAVERSAL_STEP)); await painted(4); // The reader moved by what the scroller actually moved, so the Turn under @@ -2745,7 +2831,7 @@ export const HistoryAtTheTopStillLandsAboveTheReader: Story = { // The one position where the browser declines to anchor, and the one the // wheel-to-load path puts the reader in. - root.scrollTop = 0; + scrollAsReader(root, 0); wheelUp(root); await waitFor(() => expect(firstResidentTurnId()).not.toBe(before)); @@ -2855,7 +2941,7 @@ export const PromptRailStaysInsideTheScrollport: Story = { expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight); for (const position of ['top', 'bottom'] as const) { - scroller.scrollTop = position === 'top' ? 0 : scroller.scrollHeight; + scrollAsReader(scroller, position === 'top' ? 0 : scroller.scrollHeight); scroller.dispatchEvent(new Event('scroll')); await painted(4); @@ -2925,14 +3011,14 @@ export const PromptRailHasNoGapsBetweenTicks: Story = { /** Away from the tail, but still inside the band that would ask for history. */ async function scrollAwayFromTail(): Promise { const root = tailScroller(); - root.scrollTop = Math.min(root.scrollHeight - root.clientHeight - 100, loadBand() + 200); + scrollAsReader(root, Math.min(root.scrollHeight - root.clientHeight - 100, loadBand() + 200)); root.dispatchEvent(new Event('scroll')); await painted(4); } async function scrollTranscriptTo(position: 'top' | 'bottom'): Promise { const root = tailScroller(); - root.scrollTop = position === 'top' ? 0 : root.scrollHeight; + scrollAsReader(root, position === 'top' ? 0 : root.scrollHeight); root.dispatchEvent(new Event('scroll')); await painted(4); } diff --git a/apps/desktop/stories/onboarding.stories.tsx b/apps/desktop/stories/onboarding.stories.tsx index 0905029f1d..d63d734ccc 100644 --- a/apps/desktop/stories/onboarding.stories.tsx +++ b/apps/desktop/stories/onboarding.stories.tsx @@ -96,7 +96,6 @@ function DetailPane(props: {
diff --git a/packages/ui/src/__tests__/prompt-rail-observer-identity.test.tsx b/packages/ui/src/__tests__/prompt-rail-observer-identity.test.tsx index 53a8f88d22..7680f0d5f0 100644 --- a/packages/ui/src/__tests__/prompt-rail-observer-identity.test.tsx +++ b/packages/ui/src/__tests__/prompt-rail-observer-identity.test.tsx @@ -185,7 +185,6 @@ function harness() { function view(messages: StoredMessage[]): ReactElement { const chat = createElement(ChatView, { messages, activeSession, onNew: () => {} } as never); const layout = createElement(ChatSurfaceLayout, { - scrollOwner: 'host', composer: null, children: chat, }); diff --git a/packages/ui/src/__tests__/return-to-latest-pin.test.tsx b/packages/ui/src/__tests__/return-to-latest-pin.test.tsx index fc3de1accd..a7e95eb5b3 100644 --- a/packages/ui/src/__tests__/return-to-latest-pin.test.tsx +++ b/packages/ui/src/__tests__/return-to-latest-pin.test.tsx @@ -130,6 +130,7 @@ interface ReturnToLatestHarness { readonly scrollButton: HTMLElement; readonly clickEvent: Event; readerScroll(): void; + geometryScroll(): void; } /** @@ -203,7 +204,6 @@ function harness(options: { readonly onClick: () => Promise | void }): Ret }, } as never); const layout = createElement(ChatSurfaceLayout, { - scrollOwner: 'host', scrollToBottomLabel: '回到最新', onReturnToTail: options.onClick, composer: null, @@ -226,16 +226,10 @@ function harness(options: { readonly onClick: () => Promise | void }): Ret assert.ok(scrollRoot, 'the layout mounts a scroll container'); const scrollButton = mount.querySelector('button[aria-label="回到最新"]'); assert.ok(scrollButton, 'the return-to-latest affordance is rendered'); - // The authority attached itself to the scroller on mount and wrote the tail - // into a zero-sized box, so its classification state says scrollTop=0 over a - // zero-height scroller. Give the box real geometry, then deliver one scroll - // event at that same offset so the echo branch refreshes the recorded - // geometry without being taken for the reader. Park the reader at 600 — the - // way paging back leaves them — and let `readerScroll()` announce it. + // LinkeDOM has no layout. Start at the tail; readerScroll supplies input and + // its resulting offset, whereas geometryScroll supplies no reading intent. Object.assign(scrollRoot, { scrollHeight: 2_400, clientHeight: 600 }); - scrollRoot.scrollTop = 0; - scrollRoot.dispatchEvent(new window.Event('scroll')); - scrollRoot.scrollTop = 600; + scrollRoot.scrollTop = 1_800; // linkedom ships Event but not MouseEvent; a bubbling Event still reaches // React's root listener, which reads only the type for onClick. const clickEvent = new window.Event('click', { bubbles: true }); @@ -249,6 +243,13 @@ function harness(options: { readonly onClick: () => Promise | void }): Ret scrollButton, clickEvent, readerScroll() { + const event = new window.Event('wheel', { bubbles: true }); + Object.defineProperty(event, 'deltaY', { value: -120 }); + scrollRoot.dispatchEvent(event); + scrollRoot.scrollTop = 600; + scrollRoot.dispatchEvent(new window.Event('scroll')); + }, + geometryScroll() { scrollRoot.dispatchEvent(new window.Event('scroll')); }, }; @@ -289,7 +290,7 @@ test('the anchor stays cleared while the range is still loading', async () => { // The arriving range is what used to re-report the anchor; whatever moves // the scroller while the load is pending must not hand the shell a Turn. - view.readerScroll(); + view.geometryScroll(); assert.deepEqual(view.anchors, [undefined, 'turn-0', undefined]); click.release(); diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index 18258df582..a0034c82b4 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -17,30 +17,25 @@ * under the License. */ -/** - * The state machine only. Whether the reader ends up looking at the right - * pixels needs a real layout engine and currently has no test at all — a - * harness that fakes layout can only report the ordering the harness itself - * chose, so do not add that claim here. - * - * What is worth asserting here is the one property the whole design rests on: - * a scroll event that this authority did not cause is the reader, exactly, with - * no signal in between to be wrong about. - */ +/** State/command tests. Real layout and native input are checked in Chromium. */ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { createTranscriptScrollAuthority } from '../transcript-scroll-authority.js'; interface FakeRoot { + ownerDocument: EventTarget; style: { overflowAnchor: string }; scrollTop: number; scrollHeight: number; clientHeight: number; /** The boxes `scrollHeight` is made of, which is what the authority watches. */ children: readonly unknown[]; - addEventListener(type: string, listener: () => void): void; - removeEventListener(type: string, listener: () => void): void; + addEventListener(type: string, listener: (event: unknown) => void): void; + removeEventListener(type: string, listener: (event: unknown) => void): void; + input(deltaY: number, modifiers?: { ctrlKey?: boolean; metaKey?: boolean }): void; + grabScrollbar(): void; + end(): void; /** Dispatch the scroll event the browser would, one frame later. */ emitScroll(): void; grow(by: number): void; @@ -49,22 +44,32 @@ interface FakeRoot { } function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): FakeRoot { - const listeners = new Set<() => void>(); + const listeners = new Map void>>(); + const emit = (type: string, event?: unknown): void => { + for (const listener of listeners.get(type) ?? []) listener(event); + }; const root: FakeRoot = { + ownerDocument: new EventTarget(), style: { overflowAnchor: '' }, scrollTop: 0, scrollHeight: options?.scrollHeight ?? 3_000, clientHeight: options?.clientHeight ?? 600, children: [{}], addEventListener(type, listener) { - if (type === 'scroll') listeners.add(listener); + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(listener); }, - removeEventListener(_type, listener) { - listeners.delete(listener); + removeEventListener(type, listener) { + listeners.get(type)?.delete(listener); }, emitScroll() { - for (const listener of [...listeners]) listener(); + emit('scroll'); + }, + input(deltaY, modifiers) { emit('wheel', { deltaY, ...modifiers, composedPath: () => [proxy] }); }, + grabScrollbar() { + emit('pointerdown', { button: 0, pointerType: 'mouse', pointerId: 1, target: proxy }); }, + end() { emit('scrollend'); }, grow(by) { root.scrollHeight += by; }, @@ -74,7 +79,7 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F }; // The browser clamps a write past the end; without that the "we wrote it" // and "the reader is at the tail" cases would not agree on any number. - return new Proxy(root, { + const proxy = new Proxy(root, { set(target, property, value) { if (property === 'scrollTop') { target.scrollTop = Math.min(value as number, target.scrollHeight - target.clientHeight); @@ -83,6 +88,7 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F return Reflect.set(target, property, value); }, }); + return proxy; } /** @@ -91,15 +97,16 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F * is every box changing at once, which is the only distinction the authority * draws between them: none. * - * Frames are not faked — nothing here schedules one. Whether a scroll event is - * this authority's own is answered by where the scroller is, not by when the - * event arrives. + * End-of-operation frame callbacks are advanced explicitly. */ -function withObservers(run: (resize: () => void) => T): T { +function withObservers(run: (resize: () => void, frame: () => void) => T): T { const observers = new Set<() => void>(); - const globals = globalThis as { ResizeObserver?: unknown; MutationObserver?: unknown }; + const frames: FrameRequestCallback[] = []; + const globals = globalThis as { ResizeObserver?: unknown; MutationObserver?: unknown; requestAnimationFrame?: unknown }; const originalResize = globals.ResizeObserver; const originalMutation = globals.MutationObserver; + const originalFrame = globals.requestAnimationFrame; + globals.requestAnimationFrame = (callback: FrameRequestCallback) => frames.push(callback); globals.ResizeObserver = class { constructor(private readonly callback: () => void) {} // Registered on `observe` rather than on construction: the authority @@ -121,13 +128,33 @@ function withObservers(run: (resize: () => void) => T): T { try { return run(() => { for (const observer of [...observers]) observer(); - }); + }, () => { for (const callback of frames.splice(0)) callback(0); }); } finally { globals.ResizeObserver = originalResize; globals.MutationObserver = originalMutation; + globals.requestAnimationFrame = originalFrame; } } +test('Ctrl and Meta wheel zoom preserve following without requesting history', () => { + withObservers((resize) => { + for (const modifiers of [{ ctrlKey: true }, { metaKey: true }]) { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + const detach = authority.attach(root as unknown as HTMLElement); + let readerReports = 0; + authority.subscribeToReaderScroll(() => { readerReports += 1; }); + root.input(-100, modifiers); + root.grow(200); + resize(); + assert.equal(authority.getSnapshot().pinned, true); + assert.equal(root.scrollTop, root.scrollHeight - root.clientHeight); + assert.equal(readerReports, 0); + detach(); + } + }); +}); + test('content that grows under a pinned transcript keeps the tail on screen', () => { withObservers((resize) => { const root = fakeRoot(); @@ -139,19 +166,116 @@ test('content that grows under a pinned transcript keeps the tail on screen', () resize(); assert.equal(root.scrollTop, 2_900); - // Its own write echoes back as an ordinary scroll event, and finding the - // scroller still on the offset it wrote is how it knows. + // The write's scroll event carries no reader input. + root.emitScroll(); + assert.equal(authority.getSnapshot().pinned, true); + }); +}); + +test('identical shrink/grow geometry follows only when no reader input intervened', () => { + for (const readerInput of [false, true]) { + withObservers((resize) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + let readerMoves = 0; + authority.subscribeToReaderScroll((_direction, phase) => { + if (phase === 'scroll') readerMoves += 1; + }); + if (readerInput) root.input(-100); + root.grow(-190); + root.scrollTop = 2_210; // Browser clamps at the intermediate bottom. + root.grow(22); + root.emitScroll(); + assert.equal(authority.getSnapshot().pinned, !readerInput); + assert.equal(readerMoves, readerInput ? 1 : 0); + resize(); + assert.equal(root.scrollTop, readerInput ? 2_210 : 2_232); + }); + } +}); + +test('scrollend cannot retire a continuing operation or a newer input', () => { + withObservers((resize, frame) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + root.input(-100); + root.scrollTop = 1_000; + root.emitScroll(); + root.end(); + root.input(100); + frame(); + frame(); + root.scrollTop = 1_500; root.emitScroll(); + root.end(); // An old animation ends while the new one is still moving. + frame(); + root.scrollTop = 2_400; + root.emitScroll(); + frame(); + root.end(); + frame(); + frame(); assert.equal(authority.getSnapshot().pinned, true); + root.grow(50); + resize(); + assert.equal(root.scrollTop, 2_450); + }); +}); + +test('scrollbar defaults can land after pointerup, while an unmoved click retires', () => { + withObservers((resize, frame) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + root.grabScrollbar(); + root.ownerDocument.dispatchEvent(new Event('pointerup')); + root.scrollTop = 1_700; + root.emitScroll(); + frame(); + root.end(); + frame(); + frame(); + root.grow(100); + resize(); + assert.equal(root.scrollTop, 1_700); + + authority.pinToTail(); + root.grabScrollbar(); + root.ownerDocument.dispatchEvent(new Event('pointerup')); + frame(); + root.grow(100); + resize(); + assert.equal(root.scrollTop, 2_600); + }); +}); + +test('explicit navigation cancels input provenance before positioning its target', () => { + withObservers(() => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + root.input(-100); + root.scrollTop = 1_700; + root.emitScroll(); + authority.releasePin(); + let reports = 0; + authority.subscribeToReaderScroll(() => { reports += 1; }); + root.scrollTop = 200; + root.emitScroll(); + assert.equal(reports, 0); + assert.equal(authority.getSnapshot().pinned, false); }); }); -test('a scroll this authority did not write is the reader, and releases the tail', () => { +test('user input releases the tail before content can overwrite the scroll', () => { withObservers((resize) => { const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); authority.attach(root as unknown as HTMLElement); + root.input(-100); root.scrollTop = 1_000; root.emitScroll(); assert.equal(authority.getSnapshot().pinned, false); @@ -171,6 +295,7 @@ test('returning to the tail re-pins, and following resumes', () => { const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); authority.attach(root as unknown as HTMLElement); + root.input(-100); root.scrollTop = 0; root.emitScroll(); assert.equal(authority.getSnapshot().pinned, false); @@ -213,49 +338,6 @@ test('a viewport that loses height takes the pinned reader back to the tail', () }); }); -test('a scroll event that arrives late is still this authority\'s own write', () => { - withObservers((resize) => { - const root = fakeRoot(); - const authority = createTranscriptScrollAuthority(); - authority.attach(root as unknown as HTMLElement); - assert.equal(root.scrollTop, 2_400); - - // The write's event has not been dispatched yet, and the transcript keeps - // growing underneath it. By the time it lands the scroller is 302px from a - // tail that has moved — which is exactly what a reader who scrolled up - // looks like, and is why timing cannot be the discriminator. - root.grow(302); - root.emitScroll(); - assert.equal(authority.getSnapshot().pinned, true); - - resize(); - assert.equal(root.scrollTop, 2_702); - }); -}); - -test('growth that outruns the write does not read as the reader scrolling up', () => { - withObservers((resize) => { - const root = fakeRoot(); - const authority = createTranscriptScrollAuthority(); - authority.attach(root as unknown as HTMLElement); - assert.equal(root.scrollTop, 2_400); - - // The transcript grew, and the scroll event for it arrives before this - // authority has been told to follow it. The offset is 302px from a tail - // that moved — identical, as a position, to a reader who scrolled up. - root.grow(302); - root.scrollTop = 2_402; - root.emitScroll(); - assert.equal(authority.getSnapshot().pinned, true); - - // The affordance still knows how far the tail now is, and the next growth - // signal takes the reader back to it. - assert.equal(authority.getSnapshot().awayFromTail, true); - resize(); - assert.equal(root.scrollTop, 2_702); - }); -}); - test('a reader who scrolls up while the answer grows is still the reader', () => { withObservers((resize) => { const root = fakeRoot(); @@ -263,11 +345,9 @@ test('a reader who scrolls up while the answer grows is still the reader', () => authority.attach(root as unknown as HTMLElement); assert.equal(root.scrollTop, 2_400); - // The same shape as the case above — a scroll event carrying a grown - // `scrollHeight` — and the opposite intent. Growth cannot move the offset - // backwards, so an offset that went up the transcript is the reader's, and - // during a streaming answer this is the only kind of event they produce. + // Input must suspend following before a concurrent resize can write. root.grow(37); + root.input(-500); root.scrollTop = 1_900; root.emitScroll(); assert.equal(authority.getSnapshot().pinned, false); @@ -287,12 +367,15 @@ test('reports both directions even when the reader returns to the last written o const authority = createTranscriptScrollAuthority(); authority.attach(root as unknown as HTMLElement); const directions: string[] = []; - authority.subscribeToReaderScroll((direction) => directions.push(direction)); + authority.subscribeToReaderScroll((direction, phase) => { if (phase === 'scroll') directions.push(direction); }); root.emitScroll(); + root.input(-100); root.scrollTop = 900; root.emitScroll(); + root.input(100); root.scrollTop = 2_400; root.emitScroll(); + root.end(); assert.deepEqual(directions, ['up', 'down']); }); }); @@ -312,6 +395,7 @@ test('a slow reader is a reader, however small each step is', () => { // noise and the reader never moves at all; they only mean anything added // up. Nothing grows here, so there is nothing else they could be. for (let step = 0; step < 90; step += 1) { + root.input(-2); root.scrollTop -= 2; root.emitScroll(); } @@ -349,30 +433,6 @@ test('content leaving from above the reader is not the reader either', () => { }); }); -test('a viewport that grew does not move the reader, it only clamps them', () => { - withObservers(() => { - const root = fakeRoot(); - const authority = createTranscriptScrollAuthority(); - authority.attach(root as unknown as HTMLElement); - authority.releasePin(); - root.scrollTop = 2_350; - root.emitScroll(); - let readerMoves = 0; - authority.subscribeToReaderScroll(() => { - readerMoves += 1; - }); - - // The composer loses a line, so the scrollport gets taller and the end of - // the transcript moves up past where the reader was sitting. The browser - // clamps them to it; they did not ask to go. - root.shrinkViewport(-200); - root.scrollTop = 2_200; - root.emitScroll(); - assert.equal(readerMoves, 0); - assert.equal(authority.getSnapshot().pinned, false); - }); -}); - test('content landing above a released reader does not re-pin them', () => { withObservers((resize) => { const root = fakeRoot(); @@ -402,8 +462,8 @@ test('only the reader\'s own movement reaches a reader-scroll listener', () => { const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); let heard = 0; - const stop = authority.subscribeToReaderScroll(() => { - heard += 1; + const stop = authority.subscribeToReaderScroll((_direction, phase) => { + if (phase === 'scroll') heard += 1; }); authority.attach(root as unknown as HTMLElement); @@ -418,6 +478,7 @@ test('only the reader\'s own movement reaches a reader-scroll listener', () => { assert.equal(heard, 0); // The reader, at last. + root.input(-100); root.scrollTop = 900; root.emitScroll(); assert.equal(heard, 1); diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx index fd8ebd003d..70759d1579 100644 --- a/packages/ui/src/__tests__/use-chat-scroll.test.tsx +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -41,6 +41,7 @@ const originalGlobals = { Node: globalThis.Node, ResizeObserver: globalThis.ResizeObserver, window: globalThis.window, + requestAnimationFrame: globalThis.requestAnimationFrame, }; const originalActEnvironment = (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; @@ -48,6 +49,12 @@ const originalActEnvironment = (globalThis as typeof globalThis & { let mountedRoot: ReturnType | undefined; +function wheel(target: HTMLElement, deltaY: number): void { + const event = new window.Event('wheel', { bubbles: true }); + Object.defineProperty(event, 'deltaY', { value: deltaY }); + target.dispatchEvent(event); +} + afterEach(async () => { if (mountedRoot) await act(() => mountedRoot?.unmount()); mountedRoot = undefined; @@ -108,6 +115,7 @@ const installScrollTestEnvironment = ( Node: window.Node, ResizeObserver: TestResizeObserver, window, + requestAnimationFrame: window.requestAnimationFrame, IS_REACT_ACT_ENVIRONMENT: true, }); return { frames, resizeCallbacks }; @@ -160,23 +168,21 @@ test('pages only toward reader input, including wheels at a bounded edge', async )); await render(true); assert.deepEqual(calls, [], 'mounting at a partial tail is not a request'); + wheel(scroller, -100); scroller.scrollTop = 900; scroller.dispatchEvent(new window.Event('scroll')); + wheel(scroller, 100); scroller.scrollTop = 1000; scroller.dispatchEvent(new window.Event('scroll')); assert.deepEqual(calls, [ { direction: 'up', anchor: 'turn-1' }, { direction: 'down', anchor: 'turn-2' }, + { direction: 'down', anchor: 'turn-2' }, // Input and its resulting scroll. ], 'overlapping edge bands must not reverse the requested direction'); scroller.scrollTop = 1800; scroller.dispatchEvent(new window.Event('scroll')); assert.equal(authority.getSnapshot().pinned, false, 'a partial tail must not follow a page fill'); - const wheel = (target: HTMLElement, deltaY: number) => { - const event = new window.Event('wheel', { bubbles: true }); - Object.defineProperty(event, 'deltaY', { value: deltaY }); - target.dispatchEvent(event); - }; calls.length = 0; wheel(scroller, 100); assert.deepEqual(calls, [{ direction: 'down', anchor: 'turn-3' }]); @@ -344,6 +350,7 @@ test('a session switch restores a Turn anchor after async fill and preserves tai await renderSession('session-a'); assert.equal(scroller.scrollTop, 2_400); + wheel(scroller, -100); scroller.scrollTop = 900; scroller.dispatchEvent(new window.Event('scroll')); assert.equal(anchors.get('session-a'), 'turn-a-2'); @@ -453,9 +460,11 @@ test('a session switch restores a Turn anchor after async fill and preserves tai assert.equal(scroller.scrollTop, 2_400); assert.equal(anchors.has('session-a'), false); + wheel(scroller, -100); scroller.scrollTop = 1_000; scroller.dispatchEvent(new window.Event('scroll')); assert.equal(anchors.get('session-a'), 'turn-a-latest'); + scroller.dispatchEvent(new window.Event('scrollend')); installTranscript(800, [{ id: 'geometry-resident', start: 0, height: 800 }]); scroller.scrollTop = scroller.scrollTop; await renderSession('session-a'); diff --git a/packages/ui/src/chat-surface-layout.tsx b/packages/ui/src/chat-surface-layout.tsx index f234c63ed8..184e47c8c3 100644 --- a/packages/ui/src/chat-surface-layout.tsx +++ b/packages/ui/src/chat-surface-layout.tsx @@ -29,21 +29,10 @@ import { cn } from './utils.js'; /** * Stock ChatLayoutProps, minus `autoScroll`. That prop is the patch-package * seam (`patches/@astryxdesign+core+0.5.2.patch`) forwarding Astryx's own - * published `enabled` option to `useChatStreamScroll`, and `scrollOwner` - * decides it — a caller-supplied value would be silently overwritten. + * published `enabled` option to `useChatStreamScroll`. Maka always owns + * transcript scrolling, so callers cannot enable a competing writer. */ export type ChatSurfaceLayoutProps = Omit, 'autoScroll'> & { - /** - * Who positions this transcript. - * - * `astryx` keeps the library's auto-follow, for the surfaces that render - * their own content rather than a `ChatView`. `host` turns Astryx's scroll - * layer off entirely — no listeners, no spring — and hands `scrollTop` to - * Maka's single authority, which is what a `ChatView` transcript needs: it - * knows turn identity, the Host active range and the navigation the reader - * asked for, none of which a generic scroll container can see. - */ - scrollOwner?: 'astryx' | 'host'; scrollToBottomLabel?: string; /** Loads the durable tail after the scroll authority pins to it. */ onReturnToTail?(): Promise | void; @@ -52,8 +41,7 @@ export type ChatSurfaceLayoutProps = Omit, 'au /** * Maka's product seam for the Astryx chat page shell. * - * Astryx owns the bottom dock and the message area. Whether it also owns - * scrolling is `scrollOwner`'s answer, and there is never more than one owner. + * Astryx owns the bottom dock and the message area; Maka owns scrolling. * * The density default drops a `compact` override and lets Astryx's own default * (`balanced`) stand. Compact spends spacing-2 on the dock's gutters — 8px @@ -70,12 +58,10 @@ export type ChatSurfaceLayoutProps = Omit, 'au export function ChatSurfaceLayout({ className, density = 'balanced', - scrollOwner = 'astryx', scrollToBottomLabel, onReturnToTail, ...props }: ChatSurfaceLayoutProps) { - const hostOwned = scrollOwner === 'host'; const astryxOverrides = useMemo( () => scrollToBottomLabel @@ -88,12 +74,11 @@ export function ChatSurfaceLayout({ const layout = ( - : props.scrollButton} + scrollButton={props.scrollButton === null ? null + : } density={density} className={cn('maka-chat-layout', className)} data-chat-scroll-container="true" diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 753e199409..97b9fc1499 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -161,6 +161,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { onEditUserMessage?: () => void; editDisabled?: boolean; editDisabledReason?: string; + delivery?: TransientUserMessageProjection; }) { const locale = useUiLocale(); const copyText = getConversationCopy(locale).messages; @@ -173,7 +174,11 @@ const UserMessageBody = memo(function UserMessageBody(props: { + {props.delivery.deliveryStatus} + + ) : props.ts !== undefined ? ( /* `value` takes ms directly: Timestamp's own parseValue reads anything past 1e12 as milliseconds (2001-09-09 onward), and a chat message never predates that. */ @@ -182,6 +187,9 @@ const UserMessageBody = memo(function UserMessageBody(props: { } footer={ <> + {props.delivery?.deliveryActions?.map((action) => ( + + ))} - {message.deliveryStatus && ( -
- {message.deliveryStatus} - {message.deliveryActions?.map((action) => ( - - ))} -
- )}
); @@ -374,6 +375,7 @@ function MessageCopyButton(props: { */ export const TurnView = memo(function TurnView(props: { turn: TurnViewModel; + transientMessages?: readonly TransientUserMessageProjection[]; userLabel?: string; /** * PR109d-b: footer actions derived from `TurnStatus` + lineage map @@ -434,10 +436,8 @@ export const TurnView = memo(function TurnView(props: { * present, the assistant `ChatMessage` renders the live 深度思考 + answer bubble as * the trailing entries of its timeline — the SAME node the committed turn * will settle into, so live→settled is a data-source swap (no unmount/mount). - * While live the footer is a reserved-height placeholder, not the real - * `TurnFooterActions`: the tail turn's derived status is `completed` (a live - * turn has no `turn_state`), so rendering the real footer would offer a - * clickable regenerate/branch on a still-streaming answer. + * While live the footer shows activity in the same slot as completed + * actions, without exposing actions against a still-streaming answer. */ liveStreaming?: { onStreamingSettled?: (messageId?: string) => void; @@ -554,6 +554,9 @@ export const TurnView = memo(function TurnView(props: { {copy.agentGraphTriggered} )} + {props.transientMessages?.map((message) => ( + + ))} {turn.user && ( )} - {ownsTurnChrome && props.liveStreaming && ( - <> - {props.liveStreaming.providerRetry ? ( - - ) : ( - props.liveStreaming.runningStatus && ( - - ) - )} - - )}
{ownsTurnChrome && reverseBadges.length > 0 && ( @@ -765,34 +753,32 @@ export const TurnView = memo(function TurnView(props: { ))} )} - {ownsTurnChrome && - (props.liveStreaming ? ( - /* #642: reserved-height footer placeholder while streaming — same - `mt-0.5 h-8` box the real footer occupies, so the live→settled - swap is height-neutral (the footer slot never grows/shrinks). No - actionable footer here: the live tail's derived status is - `completed`, so a real `TurnFooterActions` would render a - clickable regenerate/branch on a still-streaming answer. */ -