Skip to content
Open
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
158 changes: 55 additions & 103 deletions apps/desktop/e2e/partial-history-notice.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,121 +17,73 @@
* under the License.
*/

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

const NOTICE = '.maka-transcript-history-controls';
const GAP = '.maka-transcript-gap-row';
const TURN = '.maka-transcript-turn';

async function waitForPaint(page: Page): Promise<void> {
await page.evaluate(() => new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}));
}

async function noticePresentation(page: Page) {
return page.locator(NOTICE).evaluate((notice) => {
const style = getComputedStyle(notice);
const box = notice.getBoundingClientRect();
const composer = document.querySelector('.maka-composer-astryx');
if (!composer) throw new Error('the composer is missing');
const composerBox = composer.getBoundingClientRect();
return {
backgroundColor: style.backgroundColor,
borderWidths: [
style.borderTopWidth,
style.borderRightWidth,
style.borderBottomWidth,
style.borderLeftWidth,
],
display: style.display,
flexWrap: style.flexWrap,
justifyContent: style.justifyContent,
widthDelta: Math.abs(box.width - composerBox.width),
centerDelta: Math.abs(
(box.left + box.right) / 2 - (composerBox.left + composerBox.right) / 2,
),
fitsViewport: box.left >= 0 && box.right <= document.documentElement.clientWidth,
hasHorizontalOverflow: notice.scrollWidth > notice.clientWidth,
};
});
}

test('partial history is a quiet reading-column control with neutral rail ticks', async ({
test('bounded transcript ranges expose only their truthful boundary gaps', async ({
partialHistoryWindow: page,
}) => {
await page.setViewportSize({ width: 1_400, height: 800 });
await expect(page.locator(NOTICE)).toHaveCount(0);

const firstPrompt = page.locator(
const olderGap = page.locator('[data-transcript-gap="older"]');
const newerGap = page.locator('[data-transcript-gap="newer"]');
await expect(olderGap).toBeVisible();
await expect(olderGap).toContainText('上方还有未加载的较早消息');
await expect(olderGap.getByRole('button', { name: '加载较早消息' })).toBeVisible();
await expect(newerGap).toHaveCount(0);
await expect(page.locator('.maka-transcript-history-controls')).toHaveCount(0);

const oldestPrompt = page.locator(
'.maka-prompt-rail-tick[data-prompt-turn-id="turn-partial-history-1"]',
);
await expect(firstPrompt).toBeVisible();
await firstPrompt.click();
await expect(oldestPrompt).toBeVisible();
await oldestPrompt.click();

const notice = page.locator(NOTICE);
await expect(notice).toBeVisible();
await expect(notice).toContainText('正在查看较早的消息');
await expect(notice.getByRole('button', { name: '返回最新消息' })).toBeVisible();
await expect(notice).not.toContainText(/保存|加载/);
const firstTurn = page.locator('[data-turn-id="turn-partial-history-1"]');
await expect(firstTurn).toBeVisible();
await expect(firstTurn).toHaveAttribute('data-search-highlight', 'true');
await expect(olderGap).toHaveCount(0);
await expect(newerGap).toBeVisible();
await expect(newerGap).toContainText('下方还有未加载的较新消息');
await expect(newerGap.getByRole('button', { name: '加载较新消息' })).toBeVisible();
await expect(page.locator(GAP)).toHaveCount(1);
expect(await page.locator(TURN).count()).toBeLessThanOrEqual(10);

const regular = await noticePresentation(page);
expect(regular).toEqual({
backgroundColor: 'rgba(0, 0, 0, 0)',
borderWidths: ['0px', '0px', '0px', '0px'],
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'center',
widthDelta: expect.any(Number),
centerDelta: expect.any(Number),
fitsViewport: true,
hasHorizontalOverflow: false,
});
expect(regular.widthDelta).toBeLessThanOrEqual(1);
expect(regular.centerDelta).toBeLessThanOrEqual(1);

await page.mouse.move(0, 0);
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
const railPresentation = await page.evaluate(() => {
const ticks = [...document.querySelectorAll<HTMLElement>('.maka-prompt-rail-tick')];
const presentation = (tick: HTMLElement) => {
const bar = tick.querySelector<HTMLElement>('.maka-prompt-rail-tick-bar');
if (!bar) throw new Error('a prompt rail tick is missing its bar');
const style = getComputedStyle(bar);
return {
backgroundColor: style.backgroundColor,
borderStyle: style.borderStyle,
borderWidth: style.borderWidth,
boxShadow: style.boxShadow,
};
};
const neutralPaint = ticks
.filter((tick) => tick.dataset.active !== 'true' && !tick.matches(':hover'))
.map(presentation);
const residentStyleRules = [...document.styleSheets].flatMap((sheet) =>
[...sheet.cssRules].filter((rule) => rule.cssText.includes('data-resident'))
);
return {
residentAttributeCount: document.querySelectorAll('[data-resident]').length,
residentStyleRuleCount: residentStyleRules.length,
neutralTickCount: neutralPaint.length,
neutralPaintCount: new Set(neutralPaint.map((paint) => JSON.stringify(paint))).size,
};
const loadNewer = newerGap.getByRole('button', { name: '加载较新消息' });
const loadNewerTop = await loadNewer.evaluate((button) =>
Math.round(button.getBoundingClientRect().top)
);
await loadNewer.click();
await expect(page.locator('[data-turn-id="turn-partial-history-2"]')).toBeVisible();
await page.evaluate(async () => {
for (let frame = 0; frame < 30; frame += 1) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
}
});
expect(railPresentation.residentAttributeCount).toBe(0);
expect(railPresentation.residentStyleRuleCount).toBe(0);
expect(railPresentation.neutralTickCount).toBeGreaterThan(1);
expect(railPresentation.neutralPaintCount).toBe(1);
await expect(olderGap).toBeVisible();
await expect(newerGap).toBeVisible();
await expect(loadNewer).toBeEnabled();
await expect.poll(async () =>
Math.abs(
Math.round(await loadNewer.evaluate((button) => button.getBoundingClientRect().top))
- loadNewerTop,
)
).toBeLessThanOrEqual(4);
await expect(loadNewer).toBeFocused();
await expect(oldestPrompt).toBeVisible();
await expect(page.locator(GAP)).toHaveCount(2);
expect(await page.locator(TURN).count()).toBeLessThanOrEqual(10);

await page.setViewportSize({ width: 520, height: 720 });
await waitForPaint(page);
const narrow = await noticePresentation(page);
expect(narrow.centerDelta).toBeLessThanOrEqual(1);
expect(narrow.fitsViewport).toBe(true);
expect(narrow.hasHorizontalOverflow).toBe(false);
const returnToLatest = page.getByRole('button', { name: '滚动主对话到底部' });
await expect(returnToLatest).toBeVisible();
// This scenario owns the bounded-range action wired into the existing dock
// affordance. Its separate fixed-dock hit-test layering is outside #4123.
await returnToLatest.evaluate((button: HTMLButtonElement) => button.click());

await notice.getByRole('button', { name: '返回最新消息' }).click();
await expect(notice).toHaveCount(0);
await expect(
page.locator('[data-turn-id="turn-partial-history-8"]'),
).toBeVisible();
await expect(page.locator('[data-turn-id="turn-partial-history-18"]')).toBeVisible();
await expect(newerGap).toHaveCount(0);
await expect(oldestPrompt).toBeVisible();
expect(await page.locator(TURN).count()).toBeLessThanOrEqual(10);
});
7 changes: 5 additions & 2 deletions apps/desktop/e2e/transcript-scroll-cost.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,13 @@ async function moveToTail(page: Page): Promise<void> {
*/
async function returnToLatest(page: Page): Promise<void> {
const returnLatest = page.getByRole('button', {
name: /^(?:返回最新消息|Return to latest)$/,
name: /^(?:滚动主对话到底部|Scroll main conversation to bottom)$/,
});
await expect(returnLatest).toBeVisible();
await returnLatest.click();
// Range loading now reuses the existing transcript dock action. Its fixed
// layer's pointer hit testing is covered separately from this range-cost
// test, so invoke the action without introducing that unrelated dependency.
await returnLatest.evaluate((button: HTMLButtonElement) => button.click());
}

/**
Expand Down
3 changes: 1 addition & 2 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -896,7 +896,7 @@
"react": 1
},
"importSpecifiers": 124,
"nonTriviaTokens": 15588
"nonTriviaTokens": 15585
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 2,
Expand Down Expand Up @@ -1168,7 +1168,6 @@
"actionFactories": [],
"dependencyPaths": {
"./chat-recovery-notice": 1,
"./locales/conversation-copy": 1,
"./locales/shell-copy": 1,
"./onboarding-hero": 1,
"./use-app-shell-session-ui-reads": 1,
Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/src/main/e2e-fixture/scenarios-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,17 +172,18 @@ export function partialHistorySession(now: number): SessionHeader {
}

/**
* Eight turns whose durable transcript is well over the Desktop range budget.
* Eighteen turns whose durable transcript is well over both Desktop range budgets.
* The whitespace is stored but collapses when rendered, keeping this a useful
* visual fixture while forcing the initial open to contain only the latest
* contiguous range.
*/
export function partialHistoryMessages(now: number): StoredMessage[] {
const messages: StoredMessage[] = [];
const rangePadding = ' '.repeat(180 * 1024);
for (let index = 1; index <= 8; index += 1) {
const turnCount = 18;
for (let index = 1; index <= turnCount; index += 1) {
const turnId = `turn-partial-history-${index}`;
const ts = now - (9 - index) * 60_000;
const ts = now - (turnCount + 1 - index) * 60_000;
messages.push({
type: 'user',
id: `msg-partial-history-user-${index}`,
Expand Down
64 changes: 33 additions & 31 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1116,6 +1116,7 @@ function AppShellContent({
setSearchModalOpen,
searchScrollTarget,
setSearchScrollTarget,
consumeSearchScrollTarget,
closeSearchModal,
searchModalDeps,
searchModalOnNavigate,
Expand Down Expand Up @@ -2340,7 +2341,7 @@ function AppShellContent({
}), [activeId, activeIdRef, newestDurablePromptSequence, transcriptTurnIndex]);
useEffect(() => transcriptReadingPosition.restoreRange({
sessionId: activeId,
searchTarget: searchScrollTarget,
searchTarget: searchScrollTarget?.handled ? null : searchScrollTarget,
readingAnchor: activeId
? sessionUiController.transcriptReadingAnchorBySessionRef.current[activeId]
: undefined,
Expand All @@ -2362,7 +2363,7 @@ function AppShellContent({
),
}));
},
}), [activeId, activeSession?.profileId, messages, searchScrollTarget?.nonce]);
}), [activeId, activeSession?.profileId, messages, searchScrollTarget]);
useShellRunUpdates({
activeId,
setShellRunUpdatesBySession: sessionUiController.setShellRunUpdatesBySession,
Expand Down Expand Up @@ -2537,32 +2538,37 @@ function AppShellContent({
setAnchor: sessionUiController.setTranscriptReadingAnchor,
});
}
async function loadTranscriptHistory(target: 'earlier' | 'latest', anchorTurnId?: string) {
async function loadTranscriptHistory(
target: 'earlier' | 'newer' | 'latest',
anchorTurnId?: string,
) {
const controller = transcriptRangeRef.current;
const sessionId = activeId;
if (!controller || !sessionId || historyLoadPendingRef.current) return;
historyLoadPendingRef.current = true;
setHistoryLoadPendingSessionId(sessionId);
if (target !== 'earlier') handleTranscriptReadingAnchorChange();
try {
if (target === 'earlier') {
await controller.loadBefore(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, anchorTurnId);
} else await controller.loadLatest();
await transcriptReadingPosition.loadRange(
controller,
target,
DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES,
anchorTurnId,
);
} catch (error) {
if (
activeIdRef.current !== sessionId ||
transcriptRangeRef.current !== controller
) {
return;
}
showSessionError(
sessionId,
desktopConversationCopy.actions.messageReadFailedTitle,
localizedShellErrorMessage(
error,
desktopConversationCopy.actions.operationFailedFallback,
uiLocale,
),
);
activeIdRef.current === sessionId &&
transcriptRangeRef.current === controller
)
showSessionError(
sessionId,
desktopConversationCopy.actions.messageReadFailedTitle,
localizedShellErrorMessage(
error,
desktopConversationCopy.actions.operationFailedFallback,
uiLocale,
),
);
} finally {
historyLoadPendingRef.current = false;
setHistoryLoadPendingSessionId((current) => current === sessionId ? undefined : current);
Expand Down Expand Up @@ -2850,6 +2856,7 @@ function AppShellContent({
scrollToBottomLabel={
desktopConversationCopy.actions.scrollMainToBottom
}
onReturnToTail={() => loadTranscriptHistory('latest')}
hidden={navSelection.section !== 'sessions'}
composer={
<>
Expand Down Expand Up @@ -3059,7 +3066,7 @@ function AppShellContent({
historyLoadPending={historyLoadPendingSessionId === activeId}
onLoadEarlierHistory={(anchorTurnId) =>
loadTranscriptHistory('earlier', anchorTurnId)}
onReturnToLatestHistory={() => loadTranscriptHistory('latest')}
onLoadNewerHistory={() => loadTranscriptHistory('newer')}
liveContentSeedRevision={liveContent.liveContentSeedRevision(activeEventSeed, activeId)}
messages={messages}
transientMessages={transientMessages}
Expand Down Expand Up @@ -3103,16 +3110,11 @@ function AppShellContent({
}
: undefined
}
restoreTargetTurn={activeTranscriptReadingAnchor
? {
turnId: activeTranscriptReadingAnchor.turnId,
unavailable:
activeUnavailableTranscriptRestore
=== activeTranscriptReadingAnchor.turnId,
}
: activeUnavailableTranscriptRestore
? { turnId: activeUnavailableTranscriptRestore, unavailable: true }
: undefined}
onScrollTargetHandled={consumeSearchScrollTarget}
restoreTargetTurn={transcriptReadingPosition.restoreTarget(
activeTranscriptReadingAnchor,
activeUnavailableTranscriptRestore,
)}
onReadingAnchorChange={activeId
? handleTranscriptReadingAnchorChange
: undefined}
Expand Down
Loading