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
74 changes: 73 additions & 1 deletion apps/desktop/e2e/prompt-rail.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { PROMPT_RAIL_PROMPT_COUNT } from '../src/main/e2e-fixture/seed-helpers';
import { expect, test } from './fixtures';
import type { Page } from '@playwright/test';

const MAX_PROMPT_RAIL_TICKS = 64;

/**
* The prompt anchor rail (#563) has failed three times in a row in the same
* way: the code kept working and the pixels stopped. #2161 pinned it against
Expand Down Expand Up @@ -72,6 +74,15 @@ async function scrollTranscriptTo(page: Page, position: 'top' | 'bottom'): Promi
}, position);
}

async function loadPromptRailBeyondVirtualWindow(page: Page): Promise<void> {
const transcript = page.locator('.maka-chat-message-list');
await scrollTranscriptTo(page, 'top');
await transcript.hover();
await page.mouse.wheel(0, -100);
await expect.poll(async () => Number(await transcript.getAttribute('data-turn-source-count')))
.toBeGreaterThan(100);
}

test('every tick paints a bar with a real box', async ({ promptRailWindow: page }) => {
// Measured over ALL ticks, not a sample: a helper that skips what it cannot
// evaluate creates its blind spot exactly where a regression lives.
Expand All @@ -82,7 +93,7 @@ test('every tick paints a bar with a real box', async ({ promptRailWindow: page
}),
);

expect(bars).toHaveLength(PROMPT_RAIL_PROMPT_COUNT);
expect(bars).toHaveLength(Math.min(PROMPT_RAIL_PROMPT_COUNT, MAX_PROMPT_RAIL_TICKS));
// #2580 shipped bars at 0x0 — present in the DOM, painting nothing.
expect(Math.min(...bars.map((bar) => bar.width))).toBeGreaterThan(0);
expect(Math.min(...bars.map((bar) => bar.height))).toBeGreaterThan(0);
Expand Down Expand Up @@ -208,6 +219,67 @@ test('the first click of a session lands on its prompt and holds', async ({
expect(settled?.tickIsCurrent).toBe(true);
});

test('long transcripts keep a bounded mounted turn window', async ({
promptRailWindow: page,
}) => {
const count = async () => page.locator('[data-virtual-turn-id]').count();
await page.locator('[data-chat-scroll-container="true"][data-turn-window="ready"]').waitFor();
await loadPromptRailBeyondVirtualWindow(page);
expect(await page.evaluate(() => {
const transcript = document.querySelector<HTMLElement>('.maka-chat-message-list');
const rows = transcript?.firstElementChild;
const turn = document.querySelector<HTMLElement>('[data-virtual-turn-id]');
if (!rows || !turn) throw new Error('the virtual transcript is missing');
return {
list: Number.parseFloat(getComputedStyle(rows).rowGap),
turn: Number.parseFloat(getComputedStyle(turn).rowGap),
};
})).toEqual({ list: 16, turn: 16 });
expect(await count()).toBeGreaterThan(0);
expect(await count()).toBeLessThanOrEqual(100);
await page.locator('.maka-prompt-rail-tick').first().click({ force: true });
await expect(page.locator('[data-turn-id="turn-prompt-rail-1"]')).toHaveCount(1);
expect(await count()).toBeGreaterThan(0);
expect(await count()).toBeLessThanOrEqual(100);
});

test('evicting a turn-owned sibling interaction hands focus back to the transcript', async ({
promptRailWindow: page,
}) => {
const scroller = page.locator('[data-chat-scroll-container="true"][data-turn-window="ready"]');
await scroller.waitFor();
await loadPromptRailBeyondVirtualWindow(page);
await scrollTranscriptTo(page, 'bottom');
await expect(page.locator('[data-virtual-turn-id="turn-prompt-rail-120"]')).toHaveCount(1);
const retainedTurnId = await page.evaluate(() => {
const turns = document.querySelectorAll<HTMLElement>('[data-virtual-turn-id]');
const turn = turns.item(turns.length - 1);
if (!turn?.dataset.virtualTurnId) throw new Error('the mounted turn is missing');
const turnOwnedAction = document.createElement('button');
turnOwnedAction.textContent = 'Turn-owned action';
turn.append(turnOwnedAction);
turnOwnedAction.focus();
const range = document.createRange();
range.selectNodeContents(turnOwnedAction);
const selection = document.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
return turn.dataset.virtualTurnId;
});

await page.evaluate(() => {
const root = document.querySelector<HTMLElement>('[data-chat-scroll-container="true"]');
if (!root) throw new Error('the chat scroll container is missing');
root.scrollTop = 0;
root.dispatchEvent(new Event('scroll'));
});
await expect(page.locator(`[data-virtual-turn-id="${retainedTurnId}"]`)).toHaveCount(0);
await expect.poll(() => page.evaluate(() => ({
focus: document.activeElement?.classList.contains('maka-chat-message-list') ?? false,
selection: document.getSelection()?.isCollapsed ?? true,
}))).toEqual({ focus: true, selection: true });
});

test('a tick is what the pointer lands on, not the scrollbar', async ({
promptRailWindow: page,
}) => {
Expand Down
114 changes: 107 additions & 7 deletions apps/desktop/src/main/__tests__/streaming-handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import {
type LiveTurnProjection,
type InteractionQueues,
} from '@maka/ui';
import { createAppShellSessionEventHandlers } from '../../renderer/app-shell-session-events.js';
import {
createAppShellSessionDisplayBatch,
createAppShellSessionEventHandlers,
} from '../../renderer/app-shell-session-events.js';

function renderWithLocale(child: ReactNode): string {
return renderToStaticMarkup(
Expand Down Expand Up @@ -66,23 +69,21 @@ describe('single live-turn handoff', () => {
phase: 'streamed',
steps: [{
stepId: 'assistant-1',
thinking: { text: '先检查', truncated: false, complete: true },
thinking: { text: '先检查', truncated: false, complete: false },
text: { text: '最终答案', truncated: false, complete: true },
tools: [{
toolUseId: 'tool-1',
toolName: 'Bash',
stepId: 'assistant-1',
status: 'completed',
status: 'running',
args: {},
result: { kind: 'text', text: 'ok' },
}],
}],
});

// The render-layer fold keeps answer text as the grouping boundary, but
// adds no second Processing disclosure around the native reasoning and
// tool-call disclosures. Order is product-facing; vendor class names are not.
assert.equal((markup.match(/data-processing="block"/g) ?? []).length, 0);
// Thinking and tools own their disclosures; do not wrap them in another.
assert.equal((markup.match(/maka-processing-block/g) ?? []).length, 0);
assert.ok(markup.indexOf('深度思考') >= 0);
assert.ok(markup.indexOf('深度思考') < markup.indexOf('最终答案'));
assert.ok(markup.indexOf('最终答案') < markup.indexOf('Bash'));
Expand Down Expand Up @@ -198,6 +199,105 @@ describe('single live-turn handoff', () => {
assert.ok(refreshes.some((call) => call.required === 'assistant-1'));
});

it('publishes visible deltas at most once per animation frame', () => {
const liveTurns = createStateSetter<Record<string, LiveTurnProjection>>({
'session-1': armLiveTurn('turn-1'),
});
const liveTurnBySessionRef = { current: liveTurns.get() };
const interactions = createStateSetter<InteractionQueues>({});
const frames: Array<() => void> = [];
let publications = 0;
const handlers = createAppShellSessionEventHandlers({
uiLocale: 'zh',
activeIdRef: { current: 'session-1' },
liveTurnBySessionRef,
refreshMessages: async () => true,
refreshSessions: async () => [],
setLiveTurnBySession: (updater) => {
publications += 1;
liveTurns.set(updater);
liveTurnBySessionRef.current = liveTurns.get();
},
setInteractionBySession: interactions.set,
showModelSetupToast: () => {},
toastApi: { error: () => {} },
scheduleFrame: (callback) => { frames.push(callback); },
});

for (let index = 0; index < 100; index += 1) {
handlers.handleEvent('session-1', {
type: 'text_delta',
id: `event-${index}`,
turnId: 'turn-1',
messageId: 'assistant-1',
ts: index,
text: 'x',
});
}
assert.equal(publications, 0);
assert.equal(frames.length, 1);
frames.shift()?.();
assert.equal(publications, 1);
assert.equal(liveTurns.get()['session-1']?.steps[0]?.text?.text, 'x'.repeat(100));

handlers.handleEvent('session-1', {
type: 'text_delta', id: 'event-100', turnId: 'turn-1', messageId: 'assistant-1', ts: 100, text: 'y',
});
handlers.handleEvent('session-1', {
type: 'text_complete', id: 'event-101', turnId: 'turn-1', messageId: 'assistant-1', ts: 101, text: 'done',
});
assert.equal(publications, 2);
assert.equal(liveTurns.get()['session-1']?.steps[0]?.text?.text, 'done');
frames.shift()?.();
assert.equal(publications, 2);
});

it('shares pending display events across handler replacement', () => {
const liveTurns = createStateSetter<Record<string, LiveTurnProjection>>({
'session-1': armLiveTurn('turn-1'),
});
const liveTurnBySessionRef = { current: liveTurns.get() };
const interactions = createStateSetter<InteractionQueues>({});
const frames: Array<() => void> = [];
const displayBatch = createAppShellSessionDisplayBatch();
let publications = 0;
const deps = {
uiLocale: 'zh' as const,
activeIdRef: { current: 'session-1' },
liveTurnBySessionRef,
refreshMessages: async () => true,
refreshSessions: async () => [],
setLiveTurnBySession: (updater: (current: Record<string, LiveTurnProjection>) => Record<string, LiveTurnProjection>) => {
publications += 1;
liveTurns.set(updater);
liveTurnBySessionRef.current = liveTurns.get();
},
setInteractionBySession: interactions.set,
showModelSetupToast: () => {},
toastApi: { error: () => {} },
scheduleFrame: (callback: () => void) => { frames.push(callback); },
displayBatch,
};
const beforeRender = createAppShellSessionEventHandlers(deps);
beforeRender.handleEvent('session-1', {
type: 'text_delta', id: 'delta', turnId: 'turn-1', messageId: 'assistant-1', ts: 1,
text: 'partial',
});

const afterRender = createAppShellSessionEventHandlers(deps);
afterRender.handleEvent('session-1', {
type: 'text_complete', id: 'complete', turnId: 'turn-1', messageId: 'assistant-1', ts: 2,
text: 'done',
});
assert.equal(liveTurns.get()['session-1']?.steps[0]?.text?.text, 'done');
assert.equal(liveTurns.get()['session-1']?.steps[0]?.text?.complete, true);

frames.shift()?.();
assert.equal(publications, 1);
assert.equal(liveTurns.get()['session-1']?.steps[0]?.text?.text, 'done');
assert.equal(liveTurns.get()['session-1']?.steps[0]?.text?.complete, true);
});

it('queues a sandbox boundary request without ending the live turn', () => {
const liveTurns = createStateSetter<Record<string, LiveTurnProjection>>({
'session-1': armLiveTurn('turn-1'),
Expand Down
12 changes: 2 additions & 10 deletions apps/desktop/src/main/e2e-fixture/seed-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,8 @@ export const E2E_FIXTURE_NOW = Date.UTC(2026, 4, 22, 3, 0, 0);

export const TURN_SESSION_ID = 'e2e-fixture-turn';
export const PROMPT_RAIL_SESSION_ID = 'e2e-fixture-prompt-rail';
/**
* Prompts seeded for the prompt-rail fixture. Three constraints set the
* number: the rail renders nothing below three prompts, the transcript has to
* overflow the scrollport or its pinning has nothing to be pinned against,
* and — the binding one — it must exceed the progressive mount's initial
* window of ten, or the head of the transcript is already mounted when the
* fixture opens and the jump-into-unmounted-turns path never runs. At eight
* prompts the spec could not see that bug at all.
*/
export const PROMPT_RAIL_PROMPT_COUNT = 30;
/** Exceeds both the 64-tick rail and 100-turn mounted-window bounds. */
export const PROMPT_RAIL_PROMPT_COUNT = 120;
export const LONG_SIDEBAR_SESSION_PREFIX = 'e2e-fixture-sidebar-long-';
export const LONG_SIDEBAR_SESSION_COUNT = 60;
export const LONG_SIDEBAR_PROJECT_ID = 'e2e-fixture-project';
Expand Down
Loading
Loading