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
4 changes: 2 additions & 2 deletions apps/desktop/e2e-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@
"electron": "observation seeding, reconnect and settle are Host subscriptions surviving a renderer remount"
},
"transcript-scroll-cost.spec.ts": {
"tests": 3,
"electron": "the perf budget is measured from CDP wheel input and the browser's own render skipping"
"tests": 4,
"electron": "the perf budget is measured from CDP wheel input and the browser's own render skipping; prompt-rail navigation and history prepend cross the renderer, preload and main-process transcript range boundary"
},
"workhub-layout.spec.ts": {
"tests": 2,
Expand Down
60 changes: 60 additions & 0 deletions apps/desktop/e2e/transcript-scroll-cost.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,66 @@ test('the browser skips the Turns the reader has scrolled past', async ({
expect((await sample(page)).skippedTurns).toBeGreaterThan(0);
});

test('a large upward gesture after a prompt-rail jump stays continuous', async ({
promptRailWindow: page,
}) => {
const wheelDelta = 80;
const wheelTicks = 24;
await page.setViewportSize({ width: 1_000, height: 700 });
const ticks = page.locator('.maka-prompt-rail-tick[data-prompt-turn-id]');
const tick = ticks.nth(Math.floor(await ticks.count() / 2));
const turnId = await tick.getAttribute('data-prompt-turn-id');
expect(turnId).not.toBeNull();

await tick.click();
const destination = page.locator(`[data-turn-id="${turnId}"]`);
await expect(destination).toHaveCount(1);
await expect.poll(async () => page.evaluate((id) => {
const root = document.querySelector<HTMLElement>('[data-chat-scroll-container="true"]');
const turn = document.querySelector<HTMLElement>(`[data-turn-id="${CSS.escape(id)}"]`);
if (!root || !turn) return null;
return turn.getBoundingClientRect().top - root.getBoundingClientRect().top;
}, turnId!)).toBe(0);

const turns = page.locator('[data-turn-id]');
const firstTurnBefore = await turns.first().getAttribute('data-turn-id');
expect(firstTurnBefore).not.toBe(turnId);
const cdp = await page.context().newCDPSession(page);
const box = await page.locator(SCROLLER).boundingBox();
if (!box) throw new Error('the chat scroll container has no box');
const visibleTurnTops = () => page.evaluate(() => {
const root = document.querySelector<HTMLElement>('[data-chat-scroll-container="true"]');
if (!root) return [];
const rootRect = root.getBoundingClientRect();
return [...root.querySelectorAll<HTMLElement>('[data-turn-id]')].flatMap((turn) => {
const rect = turn.getBoundingClientRect();
return rect.bottom > rootRect.top && rect.top < rootRect.bottom
? [{ id: turn.dataset.turnId!, top: rect.top - rootRect.top }]
: [];
});
});
for (let index = 0; index < wheelTicks; index += 1) {
const before = await visibleTurnTops();
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseWheel',
x: box.x + box.width / 2,
y: box.y + box.height / 2,
deltaX: 0,
deltaY: -wheelDelta,
});
await page.evaluate(() => new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
));
const after = new Map((await visibleTurnTops()).map((turn) => [turn.id, turn.top]));
const retained = before.find((turn) => after.has(turn.id));
expect(retained, 'each input step must retain visible transcript content').toBeDefined();
expect(Math.abs(after.get(retained!.id)! - retained!.top))
.toBeLessThanOrEqual(wheelDelta + 2);
}

await expect.poll(() => turns.first().getAttribute('data-turn-id')).not.toBe(firstTurnBefore);
});

/**
* The bound the Desktop transcript is built on: paging back through a history
* far longer than the active range mounts a bounded number of Turns, not a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1222,6 +1222,7 @@ test('loads a history target with newer messages available below it', async () =
}));
const bootstrapPage = transcriptPage('older', null, 4);
const aroundPage = transcriptPage('newer', 'newer', 4);
const beforeStartPage = olderProbePage(0, false);
const inputs: Array<{ direction: string; anchorSequence: number | null }> = [];
const handle = runtimeHostSessionFixture({
snapshot: continuitySnapshot(),
Expand All @@ -1236,10 +1237,12 @@ test('loads a history target with newer messages available below it', async () =
loadTranscriptOverlay: async () => [],
decodeTranscriptPage: async (page) => page === bootstrapPage
? { messages: messages.slice(4), nextCursor: null }
: { messages: messages.slice(0, 3), nextCursor: 'newer' },
: page === beforeStartPage
? { messages: [], nextCursor: null }
: { messages: messages.slice(0, 3), nextCursor: 'newer' },
loadTranscriptPage: async (input) => {
inputs.push(input);
return input.direction === 'older' ? olderProbePage(0, false) : aroundPage;
return input.direction === 'older' ? beforeStartPage : aroundPage;
},
async close() {},
});
Expand Down Expand Up @@ -1273,6 +1276,8 @@ test('keeps an oversized transcript sparse while moving between indexed prompts'
const bootstrapPage = transcriptPage('older', 'older', 15);
const historicalPage = transcriptPage('newer', 'newer', 15);
const intermediatePage = transcriptPage('newer', 'newer', 15);
const beforeStartPage = transcriptPage('older', null, 15);
const beforeIntermediatePage = transcriptPage('older', 'older', 15);
const latestPage = transcriptPage('older', 'older', 15);
const requests: Array<{
direction: 'older' | 'newer';
Expand All @@ -1294,6 +1299,10 @@ test('keeps an oversized transcript sparse while moving between indexed prompts'
loadTranscriptOverlay: async () => [],
decodeTranscriptPage: async (page) => page === bootstrapPage || page === latestPage
? { messages: messages.slice(12, 16), nextCursor: 'older' }
: page === beforeStartPage
? { messages: [], nextCursor: null }
: page === beforeIntermediatePage
? { messages: messages.slice(1, 6), nextCursor: 'older' }
: page === historicalPage
? { messages: messages.slice(0, 5), nextCursor: 'newer' }
: { messages: messages.slice(6, 11), nextCursor: 'newer' },
Expand All @@ -1304,8 +1313,9 @@ test('keeps an oversized transcript sparse while moving between indexed prompts'
maxBytes: input.maxBytes,
});
if (input.direction === 'older') {
if (input.maxBytes > 1) return latestPage;
return olderProbePage(input.anchorSequence!, input.anchorSequence !== 0);
if (input.anchorSequence === 0) return beforeStartPage;
if (input.anchorSequence === 6) return beforeIntermediatePage;
return latestPage;
}
return input.anchorSequence === null ? historicalPage : intermediatePage;
},
Expand Down Expand Up @@ -1338,8 +1348,8 @@ test('keeps an oversized transcript sparse while moving between indexed prompts'
assertRangeFitsBudget(rendererStore);

await replica.loadAround(6, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES);
assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [6, 7, 8, 9, 10]);
assert.deepEqual(renderedUserPrompts(rendererStore), ['Prompt 4', 'Prompt 5', 'Prompt 6']);
assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [4, 5, 6, 7]);
assert.deepEqual(renderedUserPrompts(rendererStore), ['Prompt 3', 'Prompt 4']);
assert.equal(rendererStore.range().hasOlder, true);
assert.equal(rendererStore.range().hasNewer, true);
assertRangeFitsBudget(rendererStore);
Expand All @@ -1351,13 +1361,13 @@ test('keeps an oversized transcript sparse while moving between indexed prompts'
assert.equal(rendererStore.range().hasNewer, false);
assertRangeFitsBudget(rendererStore);

// Every jump that is not to the tail pays one extra single-byte read, the
// only thing that can say whether the anchor has anything older than it.
// Every jump that is not to the tail reads both sides of the target. The
// resident budget then keeps the target and the nearest complete Turns.
assert.deepEqual(requests, [
{ direction: 'newer', anchorSequence: null, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES },
{ direction: 'older', anchorSequence: 0, maxBytes: 1 },
{ direction: 'older', anchorSequence: 0, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES },
{ direction: 'newer', anchorSequence: 5, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES },
{ direction: 'older', anchorSequence: 6, maxBytes: 1 },
{ direction: 'older', anchorSequence: 6, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES },
{ direction: 'older', anchorSequence: 16, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES },
]);
replica.close();
Expand Down
30 changes: 24 additions & 6 deletions apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,12 +239,14 @@ test('superseded batches remain ACKable and cannot reset the latest range while
const firstOldBatch = deferred<void>();
const eventsClosed = deferred<void>();
const bootstrap = page(1);
const historyPage = page(1);
const historyPage = { ...page(1), direction: 'newer' as const };
const beforeStartPage = page(1);
const latestPage = page(1);
const old = record(0);
const largeOld = { ...old, message: { ...old.message, text: 'A'.repeat(700 * 1024) } as StoredMessage };
const latest = record(1);
const blocked: DesktopTranscriptBatch[] = [];
const requests: Array<{ direction: string; anchorSequence: number | null }> = [];
let releaseAcks = false;
const observer = new RuntimeHostSessionObserver({
client: { openSession: async () => runtimeHostSessionFixture({
Expand All @@ -255,11 +257,22 @@ test('superseded batches remain ACKable and cannot reset the latest range while
durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' },
},
loadTranscriptOverlay: async () => [],
decodeTranscriptPage: async (candidate) => ({
messages: candidate === historyPage ? [largeOld] : [latest],
nextCursor: candidate === historyPage ? 'newer' : 'older',
}),
loadTranscriptPage: async (request) => request.direction === 'newer' ? historyPage : latestPage,
decodeTranscriptPage: async (candidate) => candidate === beforeStartPage
? { messages: [], nextCursor: null }
: {
messages: candidate === historyPage ? [largeOld] : [latest],
nextCursor: candidate === historyPage ? 'newer' : 'older',
},
loadTranscriptPage: async (request) => {
requests.push({ direction: request.direction, anchorSequence: request.anchorSequence ?? null });
if (request.direction === 'newer') {
assert.equal(request.anchorSequence, null);
return historyPage;
}
if (request.anchorSequence === 0) return beforeStartPage;
assert.equal(request.anchorSequence, 2);
return latestPage;
},
async close() { eventsClosed.resolve(); },
}) },
emitSessionsChanged() {},
Expand Down Expand Up @@ -287,6 +300,11 @@ test('superseded batches remain ACKable and cannot reset the latest range while
releaseAcks = true;
for (const batch of blocked) ack(batch);
await Promise.all([history, following]);
assert.deepEqual(requests, [
{ direction: 'newer', anchorSequence: null },
{ direction: 'older', anchorSequence: 0 },
{ direction: 'older', anchorSequence: 2 },
]);
assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']);
const snapshot = store.snapshot();
for (const batch of blocked) assert.equal(store.accept(batch), false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ test('durable tail advancement preserves an explicitly selected oversized histor
try {
await fixture.replica.loadAround(0, PAGE_BYTES);
assert.deepEqual(sequences(fixture.replica), [0, 1]);
assert.equal(fixture.replica.snapshot().hasOlder, false);
assert.deepEqual(fixture.requests, [
{ direction: 'newer', anchorSequence: null, throughSequence: 3 },
{ direction: 'older', anchorSequence: 0, throughSequence: 3 },
]);
fixture.requests.length = 0;

await fixture.replica.advance(4);
Expand Down Expand Up @@ -455,15 +460,23 @@ async function oversizedHistoryFixture(options: { live?: boolean } = {}) {
const through = request.throughSequence ?? 4;
const anchor = request.anchorSequence ?? null;
requests.push({ direction: request.direction, anchorSequence: anchor, throughSequence: through });
const history = request.direction === 'older' ? anchor === 2 : anchor === null;
const available = records.filter(({ identity }) => identity <= through && (
anchor === null || (request.direction === 'older' ? identity < anchor : identity > anchor)
));
const history = request.direction === 'older'
? anchor !== null && anchor <= 2
: anchor === null;
// Keep the fixture's oversized first Turn on its own page, but never
// return records on the wrong side of the requested exclusive anchor.
const selected = history ? available.filter(({ identity }) => identity < 2)
: request.direction === 'older' ? available.filter(({ identity }) => identity >= 2)
: available;
return page({
direction: request.direction,
through,
records: history ? records.slice(0, 2)
: request.direction === 'older' ? records.slice(2, through + 1)
: records.slice((anchor ?? -1) + 1, through + 1),
hasMore: history ? request.direction === 'newer' : request.direction === 'older',
protectedSequence: history ? 0 : through >= 5 ? 5 : 2,
records: selected,
hasMore: selected.length < available.length,
protectedSequence: selected.length === 0 ? null : history ? 0 : through >= 5 ? 5 : 2,
});
},
async close() {},
Expand Down
34 changes: 25 additions & 9 deletions apps/desktop/src/main/desktop-transcript-replica.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,10 +444,11 @@ export class DesktopTranscriptReplica {
maxBytes,
});
if (!this.#isNavigationCurrent(token)) return;
// A durable sequence is an event ordinal times its stride, so the oldest row
// of a Session is at no fixed number and `sequence > 0` cannot answer this.
// Ask for one row older than the anchor instead; a jump is user-initiated,
// so the extra bounded read is paid once per jump.
// A navigation target is a reading position, not a range boundary. Keep a
// bounded page on its older side too, so the first upward gesture after a
// prompt-rail jump moves through resident Turns instead of racing a prepend.
// A durable sequence is an event ordinal times its stride, so only an older
// page can also say whether this target is the start of the Session.
const older = loadTail
? null
: await this.#handle.loadTranscriptPage({
Expand All @@ -456,8 +457,20 @@ export class DesktopTranscriptReplica {
throughSequence,
cursor: null,
anchorSequence: sequence,
maxBytes: 1,
maxBytes,
});
let decodedOlder: Awaited<ReturnType<DesktopRuntimeHostSession['decodeTranscriptPage']>>
| undefined;
if (older) {
await this.#withDecodedPage(older, (decoded) => {
this.#acceptRange(decoded.messages);
const lastOlder = decoded.messages.at(-1);
if (lastOlder && !this.#matchesCoverageStep(sequence, lastOlder.identity + 1)) {
throw correlationError('Desktop transcript older range crossed its anchor');
}
decodedOlder = decoded;
});
}
await this.#withDecodedPage(page, (decoded) => {
if (!this.#isNavigationCurrent(token)) return;
// `#resident` can flip to false across the `await` above (a concurrent
Expand All @@ -467,7 +480,10 @@ export class DesktopTranscriptReplica {
// memory budget. The paged catch-up guards its own post-await callback
// the same way; mirror it before mutating or publishing.
if (!this.#resident) return;
this.#acceptRange(decoded.messages);
const messages = decodedOlder
? [...decodedOlder.messages, ...decoded.messages]
: decoded.messages;
this.#acceptRange(messages);
if (
decoded.messages.length > 0 &&
(loadTail
Expand All @@ -478,13 +494,13 @@ export class DesktopTranscriptReplica {
}
const evictedDurableSequences = [...this.#durable.keys()];
this.#clearDurable();
const completedOverlayMessageIds = this.#installDurable(decoded.messages);
const completedOverlayMessageIds = this.#installDurable(messages);
this.#durableThrough = throughSequence;
this.#readingAnchorSequence = this.#intent === 'history' ? sequence : undefined;
this.#readingAnchorTurnId = this.#intent === 'history'
? this.#durable.get(sequence)?.message.turnId : undefined;
this.#adjacentReadingSequence = undefined;
this.#hasOlder = loadTail ? decoded.nextCursor !== null : older!.fragments.length > 0;
this.#hasOlder = loadTail ? decoded.nextCursor !== null : decodedOlder!.nextCursor !== null;
this.#hasNewer = loadTail ? false : decoded.nextCursor !== null;
evictedDurableSequences.push(
...this.#evictToBudget(
Expand All @@ -493,7 +509,7 @@ export class DesktopTranscriptReplica {
loadTail ? (page.protectedTurnSequence ?? sequence) : sequence,
),
);
this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences);
this.#publish(messages, completedOverlayMessageIds, evictedDurableSequences);
});
if (this.#isNavigationCurrent(token) && this.#needsOverlaySettlement(throughSequence)) {
await this.#settleOverlayThrough(throughSequence, token);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/styles/chat-message.css
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,8 @@
padding: var(--space-0-5) var(--space-1-5);
}
.maka-transcript-gap-row {
/* A range boundary is transient UI, not a reader-visible transcript anchor. */
overflow-anchor: none;
width: min(var(--maka-reading-measure), 100%);
margin: var(--space-2) auto;
padding-block: var(--space-1);
Expand Down