Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 138 additions & 6 deletions apps/desktop/e2e/transcript-scroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* under the License.
*/

import { expect, test, COMPOSER_INPUT } from './fixtures';
import { expect, test, COMPOSER_INPUT, ensureSidebarExpanded } from './fixtures';
import type { Page } from '@playwright/test';

/**
Expand Down Expand Up @@ -80,15 +80,15 @@ function scrollMetrics(page: Page): Promise<{
* `toBeVisible` passes on the transparent one.
*/
function scrollButtonOffered(page: Page): Promise<boolean> {
return page.evaluate((name) => {
return page.evaluate((names) => {
const button = [...document.querySelectorAll('button')].find(
(candidate) => candidate.getAttribute('aria-label') === name
|| candidate.textContent?.trim() === name,
(candidate) => names.includes(candidate.getAttribute('aria-label') ?? '')
|| names.includes(candidate.textContent?.trim() ?? ''),
);
if (!button) throw new Error(`the "${name}" affordance is missing`);
if (!button) throw new Error(`the "${names.join('" / "')}" affordance is missing`);
const style = getComputedStyle(button);
return style.pointerEvents !== 'none' && Number(style.opacity) > 0.5;
}, '滚动主对话到底部');
}, ['滚动主对话到底部', 'Scroll main conversation to bottom']);
}

function turnTop(page: Page, turnId: string): Promise<number> {
Expand All @@ -99,6 +99,48 @@ function turnTop(page: Page, turnId: string): Promise<number> {
}, turnId);
}

function turnOffsetFromScroller(page: Page, turnId: string): Promise<number> {
return page.evaluate(([selector, id]) => {
const root = document.querySelector(selector);
const turn = document.querySelector(`[data-turn-id="${CSS.escape(id as string)}"]`);
if (!root || !turn) return Number.POSITIVE_INFINITY;
return Math.round(turn.getBoundingClientRect().top - root.getBoundingClientRect().top);
}, [SCROLLER, turnId] as const);
}

function waitForStableTurnAtScrollerStart(page: Page, turnId: string): Promise<void> {
return page.evaluate(([selector, id]) => new Promise<void>((resolve, reject) => {
let previousTop = Number.NaN;
let previousHeight = Number.NaN;
let stableFrames = 0;
const timeout = window.setTimeout(
() => reject(new Error(`Turn ${id} did not settle at the scroller start`)),
10_000,
);
const measure = (): void => {
const root = document.querySelector<HTMLElement>(selector);
const turn = root?.querySelector<HTMLElement>(`[data-turn-id="${CSS.escape(id)}"]`);
if (root && turn) {
const offset = turn.getBoundingClientRect().top - root.getBoundingClientRect().top;
stableFrames = Math.abs(offset) <= 4
&& root.scrollTop === previousTop
&& root.scrollHeight === previousHeight
? stableFrames + 1
: 0;
previousTop = root.scrollTop;
previousHeight = root.scrollHeight;
if (stableFrames >= 6) {
window.clearTimeout(timeout);
resolve();
return;
}
}
window.requestAnimationFrame(measure);
};
measure();
}), [SCROLLER, turnId]);
}

/**
* Sample the tail through the frames a growing transcript produces.
*
Expand Down Expand Up @@ -206,6 +248,96 @@ test('a streaming answer keeps the viewport at the tail', async ({ window: page
expect(await scrollButtonOffered(page)).toBe(false);
});

test('switching Sessions restores a Turn anchor while a tail Session follows background growth', async ({
promptRailWindow: page,
}) => {
test.slow();
await page.setViewportSize({ width: 900, height: 700 });
await ensureSidebarExpanded(page);
const rows = page.locator('.maka-session-row');
const selected = rows.locator('button.astryx-side-nav-item.selected');
const readingSessionId = await selected.evaluate(
(button) => button.closest('.maka-session-row')?.getAttribute('data-session-id'),
);
if (!readingSessionId) throw new Error('the prompt-rail Session is not selected');
const tailSessionId = await rows.evaluateAll(
(items, active) => items
.map((row) => row.getAttribute('data-session-id'))
.find((sessionId) => sessionId !== active) ?? null,
readingSessionId,
);
if (!tailSessionId) throw new Error('the fixture has no tail-intent Session');
const rowButton = (sessionId: string) => page.locator(
`.maka-session-row[data-session-id=${JSON.stringify(sessionId)}] button`,
).first();

// Move the active bounded range away from the latest window, then establish
// a reading position through the real scroll event path. Reopening the
// Session now has to use the saved sequence before the Turn can exist.
await page.locator('.maka-prompt-rail-tick').first().click({ force: true });
const readingTurnId = 'turn-prompt-rail-1';
await expect(page.locator(`[data-turn-id="${readingTurnId}"]`)).toHaveCount(1, {
timeout: 20_000,
});
await expect.poll(
async () => Math.abs(await turnOffsetFromScroller(page, readingTurnId)),
{ message: 'the unloaded prompt reaches the scroller start' },
).toBeLessThanOrEqual(24);
// The prompt rail holds its target through late content measurement. Six
// unchanged painted frames exceed its own quiet-frame handoff, so switching
// after this point cannot carry that release into the next Session. Observe
// transcript geometry directly: the Astryx dock can be briefly unmounted on
// a loaded Xvfb worker even though the reading position is already stable.
await waitForStableTurnAtScrollerStart(page, readingTurnId);
expect(await distanceToTail(page)).toBeGreaterThan(100);

await rowButton(tailSessionId).click();
await expect(rowButton(tailSessionId)).toHaveClass(/selected/);
await waitForPaintedFrames(page, 6);
expect(await distanceToTail(page)).toBeLessThanOrEqual(4);
// The fixture Session predates connection identities. Choosing any current
// model upgrades it onto the E2E Runtime Host before this test starts a Turn.
const modelSwitcher = page.locator('.maka-model-switcher-trigger');
await modelSwitcher.click();
await page.getByRole('menuitemradio', { name: 'glm-4.5', exact: true }).click();
await expect(modelSwitcher).toContainText('glm-4.5');

await page.evaluate((sessionId) => {
const state = { complete: false, unsubscribe: () => undefined };
state.unsubscribe = window.maka.sessions.subscribeEvents(sessionId, (event) => {
if (event.type !== 'complete') return;
state.complete = true;
state.unsubscribe();
});
(window as typeof window & { __makaBackgroundTailProbe?: typeof state })
.__makaBackgroundTailProbe = state;
}, tailSessionId);
await sendPrompt(page, LONG_PROMPT);
await expect(page.locator('.maka-user-message', { hasText: '第 1 行' })).toBeVisible();

// The transcript collapses before each async replacement. This round trip
// therefore exercises the production ordering that made a saved scrollTop
// become zero, not merely a pre-filled test tree.
await rowButton(readingSessionId).click();
await expect.poll(
async () => Math.abs(await turnOffsetFromScroller(page, readingTurnId)),
{ timeout: 20_000, message: 'the saved reading Turn returns to the scroller start' },
).toBeLessThanOrEqual(4);
expect(await scrollButtonOffered(page)).toBe(true);

await expect.poll(() => page.evaluate(() => (
window as typeof window & {
__makaBackgroundTailProbe?: { complete: boolean };
}
).__makaBackgroundTailProbe?.complete === true), { timeout: 30_000 }).toBe(true);
await rowButton(tailSessionId).click();
await expect(rowButton(tailSessionId)).toHaveClass(/selected/);
await waitForPaintedFrames(page, 6);
const tail = await scrollMetrics(page);
expect(tail.distance, JSON.stringify(tail)).toBeLessThanOrEqual(4);
expect(await scrollButtonOffered(page)).toBe(false);
});

/**
* The turn wrappers are not the transcript. It also renders the optimistic user
* message, the no-tail live fallback and orphaned conversation items outside
Expand Down
38 changes: 13 additions & 25 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -733,27 +733,18 @@
"nonTriviaTokens": 650
},
"src/renderer/app-shell-session-ui-state.ts": {
"importDeclarations": 6,
"importDeclarations": 0,
"bridgePaths": {},
"environmentCapabilities": {},
"hookCalls": {
"useRef": 1
},
"hookCalls": {},
"lifecycleMethods": {},
"unresolvedDependencies": 0,
"actionFactories": [
"createAppShellSessionUiStateController"
],
"actionFactories": [],
"dependencyPaths": {
"./observable-state.js": 1,
"./shell-run-update-state.js": 1,
"@maka/core/events": 1,
"@maka/core/session-event-health": 1,
"@maka/ui": 1,
"react": 1
"./features/conversation/index.js": 1
},
"importSpecifiers": 8,
"nonTriviaTokens": 1183
"importSpecifiers": 0,
"nonTriviaTokens": 7
},
"src/renderer/app-shell-stop-action.ts": {
"importDeclarations": 4,
Expand Down Expand Up @@ -949,6 +940,7 @@
"./desktop-execution-boundary-surface": 1,
"./desktop-slash-command": 1,
"./error-boundary": 1,
"./features/conversation": 1,
"./features/goals": 1,
"./features/module-hub": 1,
"./features/session-collaboration": 1,
Expand Down Expand Up @@ -978,7 +970,6 @@
"./settings/runtime-host-ssh-terminal-dialog.js": 1,
"./settings/tasks-settings-page": 1,
"./stale-sessions": 1,
"./task-readiness-notice": 1,
"./use-active-execution-boundary": 1,
"./use-app-shell-composer-quotes": 1,
"./use-app-shell-session-ui-reads": 1,
Expand Down Expand Up @@ -1023,7 +1014,7 @@
"react": 1
},
"importSpecifiers": 186,
"nonTriviaTokens": 15779
"nonTriviaTokens": 15725
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 3,
Expand Down Expand Up @@ -1074,7 +1065,7 @@
"nonTriviaTokens": 581
},
"src/renderer/use-app-shell-session-ui-reads.ts": {
"importDeclarations": 4,
"importDeclarations": 3,
"bridgePaths": {},
"environmentCapabilities": {},
"hookCalls": {
Expand All @@ -1086,11 +1077,10 @@
"dependencyPaths": {
"./app-shell-session-ui-state.js": 1,
"./live-turn-snapshot.js": 1,
"./use-external-store-selector.js": 1,
"@maka/ui": 1
"./use-external-store-selector.js": 1
},
"importSpecifiers": 10,
"nonTriviaTokens": 329
"importSpecifiers": 7,
"nonTriviaTokens": 322
},
"src/renderer/use-app-shell-session-workspace.ts": {
"importDeclarations": 11,
Expand Down Expand Up @@ -4612,9 +4602,7 @@
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {
"@maka/core/session-send-projection": 1,
"@maka/core/task-submission-readiness": 1,
"@maka/core/ui-locale": 1
"./features/conversation/index.js": 1
}
},
"src/renderer/theme.ts": {
Expand Down
Loading