From 1dc65439e108b010f547fa2186acafa7f56a9eb7 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 12 Aug 2026 21:41:05 +0800 Subject: [PATCH 1/3] fix(ui): give the prompt rail's tick bars a box again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2580 moved the rail's tick onto Astryx's Button. The bar the tick draws was a direct child of the flex tick and got blockified; the Button wraps its children in a label span, so the bar went back to normal flow as an inline box. An inline box takes no width or height, so every bar computed to 0x0 and the rail shipped invisible in 0.1.9 and 0.1.10 — present in the DOM, painting nothing. `display: block` on the bar restores it. Measured on the new fixture at 1280x800: the rail's box goes from 8px wide (its own padding, ticks contributing nothing) back to the designed 22px. This is the third time the rail has failed by rendering and not painting — #2161 pinned it against a containing block as tall as the conversation, #2338 parked it under macOS's overlay scrollbar — and the second time it reached a release. The e2e coverage that would have caught all three was deleted in #2462, and the multi-prompt fixtures it ran on in #2656, so this adds back the smallest thing that closes the gap: - `chat-prompt-rail`, a plain 8-prompt conversation. The rail hides itself below three prompts, so the shipped single-prompt fixture cannot show it at all. - `prompt-rail.spec.ts` with one test per past failure: bars have a real box, the rail stays inside the scrollport at both scroll extremes, and a tick is what the pointer lands on. Three tests where the deleted suite had nine. Verified the first test fails on the unfixed renderer with "Expected: > 0, Received: 0" and passes with the fix. Neither a static CSS read nor a jsdom unit test can see any of this: jsdom has no layout engine. --- apps/desktop/e2e/fixtures.ts | 15 ++ apps/desktop/e2e/prompt-rail.spec.ts | 145 ++++++++++++++++++ apps/desktop/src/main/e2e-fixture.ts | 16 +- .../src/main/e2e-fixture/scenarios-chat.ts | 47 +++++- .../src/main/e2e-fixture/seed-helpers.ts | 8 + .../src/renderer/styles/prompt-rail.css | 6 + packages/core/src/e2e-fixture.ts | 1 + 7 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/e2e/prompt-rail.spec.ts diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 8d8368327d3..5f8f021caa3 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -338,6 +338,7 @@ export const test = base.extend<{ gitReviewWindow: { page: Page; projectRoot: string }; invocableSkillsWindow: Page; linkColorWindow: Page; + promptRailWindow: Page; }>({ // Seeded: a pre-staged connection clears onboarding so the composer is ready. window: async ({}, use) => { @@ -375,6 +376,20 @@ export const test = base.extend<{ e2eFixtureScenario: 'settings-bots-onboarding', }, use); }, + // A multi-prompt transcript for the prompt anchor rail. Shown, because every + // assertion in prompt-rail.spec.ts is geometry the compositor has to settle. + promptRailWindow: async ({}, use) => { + await withE2eWindow({ + seed: false, + // A rendered turn, deliberately not the rail: Playwright treats a + // zero-area element as hidden, so gating readiness on a tick would turn + // every rail regression into a 20s cold-start timeout instead of the + // assertion that names it. + readinessSelector: '[data-turn-id]', + e2eFixtureScenario: 'chat-prompt-rail', + showWindow: true, + }, use); + }, }); export { expect }; diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts new file mode 100644 index 00000000000..c5481b8395a --- /dev/null +++ b/apps/desktop/e2e/prompt-rail.spec.ts @@ -0,0 +1,145 @@ +import { PROMPT_RAIL_PROMPT_COUNT } from '../src/main/e2e-fixture/seed-helpers'; +import { expect, test } from './fixtures'; +import type { Page } from '@playwright/test'; + +/** + * 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 + * `.maka-chat-shell` while Astryx's ChatLayout owned the scroll container, so + * the rail laid out across the whole conversation and scrolled off screen. + * #2338 parked it under macOS's overlay scrollbar, which takes no layout space + * but still swallows the pointer, so every tick rendered and none could be + * clicked. #2580 moved the tick onto Astryx's `Button`, whose label span put + * the tick's bar back into normal flow — an inline box takes no width or + * height, so the bars computed to 0x0 and the rail shipped invisible in 0.1.9 + * and 0.1.10. + * + * None of the three is visible to a static read of the CSS, and none is + * reachable from jsdom, which has no layout. Only a real scroller with a real + * transcript can see them, so this file keeps one test per failure and nothing + * else. It is deliberately much smaller than the suite deleted in #2462. + * + * Two boundaries worth knowing before adding to it: + * - e2e-fixture renders carry `data-maka-e2e-fixture`, and `base.css` gives + * that `animation: none` plus a 0.01ms transition cap, so a fixture's state + * never depends on when it settles. Assert the end states motion resolves + * to; there is no way to assert that anything animated. + * - the reachability test is load-bearing on macOS only. Linux's in-flow + * scrollbar moves the content column left instead of overlaying it, so the + * #2338 regression goes green on CI. Run this spec on macOS before merging + * anything that touches the rail's right edge. + */ + +const RAIL_PROBE = `(() => { + const scroller = document.querySelector('[data-chat-scroll-container="true"]'); + const rail = document.querySelector('.maka-prompt-rail'); + if (!scroller || !rail) return null; + const s = scroller.getBoundingClientRect(); + const r = rail.getBoundingClientRect(); + // Astryx renders the composer dock as the scroll container's last child; + // the rail measures it the same way, for want of a published hook. + const dock = scroller.lastElementChild.getBoundingClientRect(); + return { + // Positive on all four = the rail's box is inside the scrollport and clear + // of the band the sticky dock occupies at the bottom. + insetTop: Math.round(r.top - s.top), + insetBottom: Math.round(s.bottom - r.bottom), + insetRight: Math.round(s.right - r.right), + dockClearance: Math.round(dock.top - r.bottom), + railWidth: Math.round(r.width), + scrollTop: Math.round(scroller.scrollTop), + }; +})()`; + +interface RailProbe { + insetTop: number; + insetBottom: number; + insetRight: number; + dockClearance: number; + railWidth: number; + scrollTop: number; +} + +function probeRail(page: Page): Promise { + return page.evaluate(RAIL_PROBE) as Promise; +} + +async function scrollTranscriptTo(page: Page, position: 'top' | 'bottom'): Promise { + await page.evaluate((where) => { + const scroller = document.querySelector('[data-chat-scroll-container="true"]'); + if (!scroller) throw new Error('the chat scroll container is missing'); + scroller.scrollTop = where === 'top' ? 0 : scroller.scrollHeight; + }, position); +} + +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. + const bars = await page.evaluate(() => + [...document.querySelectorAll('.maka-prompt-rail-tick-bar')].map((bar) => { + const box = bar.getBoundingClientRect(); + return { width: Math.round(box.width), height: Math.round(box.height) }; + }), + ); + + expect(bars).toHaveLength(PROMPT_RAIL_PROMPT_COUNT); + // #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); +}); + +test('the rail stays inside the scrollport at both scroll extremes', async ({ + promptRailWindow: page, +}) => { + const scroll = await page.evaluate(() => { + const scroller = document.querySelector('[data-chat-scroll-container="true"]'); + if (!scroller) throw new Error('the chat scroll container is missing'); + return { height: scroller.scrollHeight, client: scroller.clientHeight }; + }); + // Without an overflowing transcript the rail has nothing to be pinned + // against and the rest of this test proves nothing. + expect(scroll.height).toBeGreaterThan(scroll.client); + + for (const position of ['top', 'bottom'] as const) { + await scrollTranscriptTo(page, position); + // The bottom is where it bites: a sticky offset is clamped by its + // containing block, and the chat shell ends a dock-height above the + // scrollport's bottom edge (CI caught -62px insetTop that way). + await expect + .poll(async () => { + const probe = await probeRail(page); + if (!probe) return null; + return { + insetTop: probe.insetTop >= 0, + insetBottom: probe.insetBottom >= 0, + insetRight: probe.insetRight >= 0, + dockClearance: probe.dockClearance >= 0, + }; + }, { message: `rail geometry at the ${position} of the transcript` }) + .toEqual({ + insetTop: true, + insetBottom: true, + insetRight: true, + dockClearance: true, + }); + } +}); + +test('a tick is what the pointer lands on, not the scrollbar', async ({ + promptRailWindow: page, +}) => { + // `elementFromPoint`, not `hover()`: dispatched events cannot see occlusion, + // and macOS's overlay scrollbar occludes without taking layout space. + const hit = await page.evaluate(() => { + const tick = document.querySelector('.maka-prompt-rail-tick'); + if (!tick) throw new Error('the prompt rail has no ticks'); + const box = tick.getBoundingClientRect(); + const found = document.elementFromPoint( + Math.round(box.left + box.width / 2), + Math.round(box.top + box.height / 2), + ); + return { insideRail: found?.closest('.maka-prompt-rail') !== null }; + }); + + expect(hit.insideRail).toBe(true); +}); diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index 694b44e0a68..693e90bc040 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -10,10 +10,16 @@ import { LONG_SIDEBAR_PROJECT_ID, LONG_SIDEBAR_PROJECT_NAME, LONG_SIDEBAR_SESSION_PREFIX, + PROMPT_RAIL_SESSION_ID, TURN_SESSION_ID, writeSession, } from './e2e-fixture/seed-helpers.js'; -import { turnMessages, turnSession } from './e2e-fixture/scenarios-chat.js'; +import { + promptRailMessages, + promptRailSession, + turnMessages, + turnSession, +} from './e2e-fixture/scenarios-chat.js'; import { seedMcpFixture, seedSkillsMarketFixture } from './e2e-fixture/scenarios-modules.js'; import { longSidebarSessions } from './e2e-fixture/scenarios-sessions.js'; import { @@ -27,6 +33,7 @@ import { usageStatsSessions } from './e2e-fixture/scenarios-usage.js'; const E2E_FIXTURE_SCENARIOS = new Set([ 'fetched-empty', 'turn-narrative', + 'chat-prompt-rail', 'settings-data', 'settings-bots-onboarding', 'settings-general', @@ -125,6 +132,10 @@ export function getE2eFixtureState(fixture: E2eFixture | null): E2eFixtureState return { ...state, activeSessionId: TURN_SESSION_ID, openSettingsSection: 'models' }; case 'turn-narrative': return { ...state, activeSessionId: TURN_SESSION_ID, workbarCollapsed: false, workbarTab: 'tasks' }; + case 'chat-prompt-rail': + // Workbar collapsed: the rail lives on the chat scrollport's right edge, + // and the panel would take the width the measurements are about. + return { ...state, activeSessionId: PROMPT_RAIL_SESSION_ID, workbarCollapsed: true }; case 'settings-data': return { ...state, activeSessionId: TURN_SESSION_ID, openSettingsSection: 'data' }; case 'settings-bots-onboarding': @@ -180,6 +191,9 @@ export async function seedE2eFixture(input: { } await writeSession(input.workspaceRoot, turnSession(now), turnMessages(now)); + if (scenario === 'chat-prompt-rail') { + await writeSession(input.workspaceRoot, promptRailSession(now), promptRailMessages(now)); + } if (scenario === 'sidebar-search-modal-open') { for (const seed of longSidebarSessions(now)) { await writeSession(input.workspaceRoot, seed.header, seed.messages); diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts index a7ded24124f..b05ed86d3a2 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts @@ -1,5 +1,10 @@ import type { SessionHeader, StoredMessage } from '@maka/core/session'; -import { header, TURN_SESSION_ID } from './seed-helpers.js'; +import { + header, + PROMPT_RAIL_PROMPT_COUNT, + PROMPT_RAIL_SESSION_ID, + TURN_SESSION_ID, +} from './seed-helpers.js'; export function turnSession(now: number): SessionHeader { return header({ @@ -77,3 +82,43 @@ export function turnMessages(now: number): StoredMessage[] { }, ]; } + +export function promptRailSession(now: number): SessionHeader { + return header({ + id: PROMPT_RAIL_SESSION_ID, + name: '长对话提示词导航示例', + connection: 'zai-live', + model: 'glm-5.1', + now, + lastMessageAt: now - 60_000, + }); +} + +/** + * A plain multi-prompt conversation: no tools, no thinking, no usage rows. + * `prompt-rail.spec.ts` measures the rail against this, so every turn is just + * a prompt and a reply long enough to push the transcript past the scrollport. + */ +export function promptRailMessages(now: number): StoredMessage[] { + const messages: StoredMessage[] = []; + for (let index = 1; index <= PROMPT_RAIL_PROMPT_COUNT; index += 1) { + const turnId = `turn-prompt-rail-${index}`; + const ts = now - (PROMPT_RAIL_PROMPT_COUNT - index + 1) * 60_000; + messages.push({ + type: 'user', + id: `msg-prompt-rail-user-${index}`, + turnId, + ts, + text: `第 ${index} 个问题:这一段的调用链路是怎样的?`, + }); + messages.push({ + type: 'assistant', + id: `msg-prompt-rail-assistant-${index}`, + turnId, + ts: ts + 1_000, + text: `第 ${index} 段回答。`.repeat(40), + modelId: 'glm-5.1', + }); + } + return messages; +} diff --git a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts index 4cd41b448dc..563bd5261e4 100644 --- a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts +++ b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts @@ -14,6 +14,14 @@ import { 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. Clears the rail's own + * three-prompt floor (it renders nothing below that) with room to spare, and + * gives the transcript enough height to overflow the scrollport — without + * that, the rail's pinning has nothing to be pinned against. + */ +export const PROMPT_RAIL_PROMPT_COUNT = 8; 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'; diff --git a/apps/desktop/src/renderer/styles/prompt-rail.css b/apps/desktop/src/renderer/styles/prompt-rail.css index b59a67b07b3..0f705e8665c 100644 --- a/apps/desktop/src/renderer/styles/prompt-rail.css +++ b/apps/desktop/src/renderer/styles/prompt-rail.css @@ -134,6 +134,12 @@ pointer (0 = hovered) and rests at the falloff distance, so the whole ramp is one expression and the resting width is its own tail. */ .maka-prompt-rail-tick-bar { + /* Load-bearing since #2580 put the tick on Astryx's Button: the bar used to + be a direct child of the flex tick, which blockified it, and now it sits + inside the Button's own label span in normal flow. An inline box takes no + width or height, so without this the bar computes to 0x0 and the whole + rail paints nothing. */ + display: block; width: calc(14px + ((3 - var(--maka-prompt-rail-proximity, 3)) * 3px)); height: 3px; border-radius: var(--radius-pill); diff --git a/packages/core/src/e2e-fixture.ts b/packages/core/src/e2e-fixture.ts index 1425683d649..32eceef2bd2 100644 --- a/packages/core/src/e2e-fixture.ts +++ b/packages/core/src/e2e-fixture.ts @@ -6,6 +6,7 @@ import type { UiLocale } from './ui-locale.js'; export type E2eFixtureScenario = | 'fetched-empty' | 'turn-narrative' + | 'chat-prompt-rail' | 'settings-data' | 'settings-bots-onboarding' | 'settings-general' From 57b2f4dd82714cb6fb5b20ae5bfd10cc601c856a Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Wed, 12 Aug 2026 22:18:48 +0800 Subject: [PATCH 2/3] fix(ui): make the prompt rail's hover and jump behave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things the rail got wrong once it was visible again, found by using it: - A 4px gap between ticks was a band where the pointer was over the rail and over no tick, so the dock-style hover falloff dropped out and picked up again every few pixels of travel. The rail's `gap` moves into the ticks' own `padding-block`: same pitch, hit boxes now tile. - The hover preview waited 300ms before opening — Astryx's HoverCard default, meant for a pointer crossing a wide row on its way somewhere else. A tick is 22px of rail that nothing is on the way to, and the wait is the one part of this hover with no motion in it. Now 120ms. - The highlight glided 280ms to wherever a click landed, so crossing twenty prompts read as the bar flying off across the rail. A click now owns the highlight until its scroll settles: no glide, and the scroll no longer walks the highlight through every prompt it passes. - The first click into a session did nothing until the reader scrolled by hand. See below. That last one is a collision between Astryx's auto-follow lock and the progressive transcript mount, and neither side is wrong on its own. `useChatStreamScroll` unlocks on a scroll up, detected by comparing scrollTop between events — but it ignores any scroll event that arrives with a changed scrollHeight or offsetHeight, because Chrome fires those when content resizes and they are not the reader moving. A jump into an unmounted turn mounts it and the fill that follows changes scrollHeight for several frames, so the jump's own scroll is invisible to the lock: it stays on, and `scrollIfLocked` pulls the transcript back to the bottom. Only a wheel gesture broke it, which takes a separate path in Astryx. `holdJumpDestination` re-aims at the target on each height change until the fill stops. The last of those scrolls lands with a stable height, which is the one the lock finally reads as a scroll up. Measured on the 30-prompt fixture: clicking the first tick went to scrollTop 7042 (the bottom) and now goes to 24 and holds. The fixture grows from 8 prompts to 30 because the progressive mount's initial window is 10 — at 8 the head of the transcript is already mounted and the jump-into-unmounted-turns path never runs at all. Coverage note: the e2e case for the first click is an end-to-end check, not a guard. Whether the lock wins depends on which frame the fill lands on relative to a smooth scroll still in flight, and it goes green against the unfixed renderer often enough to be worthless as one. The guard is the `holdJumpDestination` unit test, which drives the frames itself. --- apps/desktop/e2e/prompt-rail.spec.ts | 62 ++++++++++ .../src/main/e2e-fixture/seed-helpers.ts | 13 ++- .../src/renderer/styles/prompt-rail.css | 22 +++- .../src/__tests__/prompt-anchor-rail.test.ts | 75 +++++++++++- packages/ui/src/prompt-anchor-rail.tsx | 110 ++++++++++++++++++ 5 files changed, 274 insertions(+), 8 deletions(-) diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts index c5481b8395a..c006bdf6e8f 100644 --- a/apps/desktop/e2e/prompt-rail.spec.ts +++ b/apps/desktop/e2e/prompt-rail.spec.ts @@ -125,6 +125,68 @@ test('the rail stays inside the scrollport at both scroll extremes', async ({ } }); +test('the pointer is always on a tick while it travels down the rail', async ({ + promptRailWindow: page, +}) => { + // The hover falloff reads which tick the pointer entered. A gap between the + // hit boxes is a band where it is over the rail and over no tick, so the + // effect drops out and picks up again every few pixels of travel. Walked a + // pixel at a time rather than sampled between two ticks: a single midpoint + // would pass on a rail whose gaps sat anywhere else. + const travel = await page.evaluate(() => { + const bars = [...document.querySelectorAll('.maka-prompt-rail-tick-bar')]; + if (bars.length < 2) throw new Error('the prompt rail needs at least two ticks'); + const first = bars[0]!.getBoundingClientRect(); + const last = bars[bars.length - 1]!.getBoundingClientRect(); + const x = Math.round(first.left + first.width / 2); + const misses: number[] = []; + for (let y = Math.round(first.top + first.height / 2); y <= Math.round(last.top + last.height / 2); y += 1) { + const found = document.elementFromPoint(x, y); + if (!found?.closest('.maka-prompt-rail-tick')) misses.push(y); + } + return { misses: misses.length, span: Math.round(last.bottom - first.top) }; + }); + + expect(travel.span).toBeGreaterThan(0); + expect(travel.misses).toBe(0); +}); + +test('the first click of a session lands on its prompt and holds', async ({ + promptRailWindow: page, +}) => { + // Clicked before anything else touches the transcript, which is the case + // that used to fail: the head of a 30-prompt session is not mounted yet, so + // the jump has to mount it, and the fill that follows changes scrollHeight + // under Astryx's auto-follow lock. The lock ignores scroll-ups that arrive + // with a changed height, so it stayed on and pulled the transcript back to + // the bottom — the click looked dead until the reader scrolled by hand. + // + // Motion has to be on, and this is the one assertion here that depends on + // it: under reduced motion the jump is an instant scroll that has already + // landed before the next fill step, and the bug does not reproduce. The + // shipped app scrolls smoothly (#2680), which leaves the jump in flight for + // exactly as long as it takes the lock to win. + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await page.locator('.maka-prompt-rail-tick').first().click({ force: true }); + + const landed = async () => + page.evaluate(() => { + const scroller = document.querySelector('[data-chat-scroll-container="true"]')!; + const first = document.querySelector('[data-turn-id]'); + if (!first) return null; + return Math.round( + first.getBoundingClientRect().top - scroller.getBoundingClientRect().top, + ); + }); + + // Within a turn's own top padding of the scrollport's top edge. + await expect.poll(landed, { message: 'the clicked prompt reaches the top' }).toBeLessThan(24); + // And stays: the fill runs on idle callbacks after the jump, so a jump that + // only wins the first frame reads as landing and then sliding away. + await page.waitForTimeout(1_200); + expect(await landed()).toBeLessThan(24); +}); + test('a tick is what the pointer lands on, not the scrollbar', async ({ promptRailWindow: page, }) => { diff --git a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts index 563bd5261e4..5c7291f9fc7 100644 --- a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts +++ b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts @@ -16,12 +16,15 @@ 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. Clears the rail's own - * three-prompt floor (it renders nothing below that) with room to spare, and - * gives the transcript enough height to overflow the scrollport — without - * that, the rail's pinning has nothing to be pinned against. + * 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 = 8; +export const PROMPT_RAIL_PROMPT_COUNT = 30; 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'; diff --git a/apps/desktop/src/renderer/styles/prompt-rail.css b/apps/desktop/src/renderer/styles/prompt-rail.css index 0f705e8665c..e99ef39d9a1 100644 --- a/apps/desktop/src/renderer/styles/prompt-rail.css +++ b/apps/desktop/src/renderer/styles/prompt-rail.css @@ -74,7 +74,12 @@ /* `safe` so the max-height above can never strand the first ticks past an unscrollable overflow edge, which plain centring does. */ justify-content: safe center; - gap: var(--space-1); + /* No gap on purpose: a gap between ticks is a band where the pointer is over + the rail but over no tick, so the hover falloff drops out and picks up + again every few pixels of travel. The ticks carry the same rhythm in their + own padding instead (see the tick's `padding-block`), which keeps the + pitch identical and leaves the column continuous — moving down the rail + hands the hover from one tick straight to the next. */ padding: var(--space-1); pointer-events: auto; opacity: var(--opacity-muted); @@ -106,7 +111,10 @@ appearance: none; border: 0; margin: 0; - padding: var(--space-0-5) 0; + /* Holds the rail's rhythm now that the rail itself has no gap: half of this + used to be the gap, so the pitch between bars is unchanged and the hit + boxes tile instead of leaving a dead band between them. */ + padding: var(--space-1) 0; background: transparent; color: var(--muted-foreground); display: flex; @@ -178,6 +186,16 @@ transition: top var(--duration-large) var(--ease-out-strong); } +/* The glide is for reading: it carries the eye between neighbouring prompts as + the transcript scrolls. A click is the opposite — the reader picked the + destination, and the same 280ms spent crossing twenty prompts instead of one + reads as the bar flying off across the rail. `jumpTo` in + prompt-anchor-rail.tsx holds this attribute until the scroll it started has + settled, so the highlight is placed at each turn the jump passes through. */ +.maka-prompt-rail[data-jumping="true"] .maka-prompt-rail-indicator { + transition: none; +} + /* No active prompt yet (a transcript that has not been scrolled) leaves the anchor undefined, and an unresolved anchor would park the bar at the top of the rail. Hide it until a tick claims the anchor. */ diff --git a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts index d09261fe554..ee8ecb4c8cf 100644 --- a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts +++ b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts @@ -1,6 +1,79 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { observeActivePromptRailVisibility } from '../prompt-anchor-rail.js'; +import { + holdJumpDestination, + observeActivePromptRailVisibility, + type PromptRailFrameScheduler, +} from '../prompt-anchor-rail.js'; + +test('a jump re-aims at its destination every time the transcript grows', () => { + // The e2e suite cannot stage this: whether the lock wins depends on which + // frame the progressive fill lands on relative to a smooth scroll still in + // flight, and it went green against the unfixed renderer often enough to be + // worthless as a guard. The mechanism is deterministic here. + // The helper builds its selector with `CSS.escape`, which the renderer has + // and Node does not. Stubbed rather than worked around in the source: the + // escaping is what keeps a turn id safe inside a selector, and a fallback + // that only exists for tests would be the wrong thing to ship. + const priorCss = (globalThis as { CSS?: unknown }).CSS; + (globalThis as { CSS?: unknown }).CSS = { escape: (value: string) => value }; + + const frames: Array<() => void> = []; + const scheduler: PromptRailFrameScheduler = { + request: (callback) => { + frames.push(callback); + return frames.length; + }, + cancel: () => {}, + }; + const scrolled: Array = []; + const target = { + scrollIntoView: (options?: ScrollIntoViewOptions) => scrolled.push(options), + }; + let scrollHeight = 1_000; + let queried: string | undefined; + const root = { + get scrollHeight() { + return scrollHeight; + }, + querySelector: (selector: string) => { + queried = selector; + return target; + }, + } as unknown as Element; + + const release = holdJumpDestination(root, () => 'turn-7', scheduler); + const runFrame = (): void => frames.shift()?.(); + + runFrame(); + assert.equal(scrolled.length, 0, 'a steady transcript is left alone'); + + scrollHeight = 1_400; + runFrame(); + assert.equal(scrolled.length, 1, 'a fill step re-aims the jump'); + assert.deepEqual(scrolled[0], { behavior: 'auto', block: 'start' }); + assert.equal(queried, '[data-turn-id="turn-7"]'); + + runFrame(); + assert.equal(scrolled.length, 1, 'the same height does not re-aim again'); + + scrollHeight = 1_800; + runFrame(); + assert.equal(scrolled.length, 2, 'every later fill step re-aims too'); + + // Once the jump is over its target is cleared, and a still-growing + // transcript must no longer be pulled back to it. Started on its own queue: + // the first hold has a frame pending, and shifting that one instead would + // measure the loop this case is not about. + release(); + frames.length = 0; + const released = holdJumpDestination(root, () => null, scheduler); + scrollHeight = 2_200; + runFrame(); + assert.equal(scrolled.length, 2, 'a settled jump releases the transcript'); + released(); + (globalThis as { CSS?: unknown }).CSS = priorCss; +}); test('keeps the active tick visible when the rail viewport resizes', () => { let railBox = box(0, 600); diff --git a/packages/ui/src/prompt-anchor-rail.tsx b/packages/ui/src/prompt-anchor-rail.tsx index cf10dd9c95a..5fb2c99f61c 100644 --- a/packages/ui/src/prompt-anchor-rail.tsx +++ b/packages/ui/src/prompt-anchor-rail.tsx @@ -8,6 +8,21 @@ import { getConversationCopy } from './conversation-copy.js'; const SCROLL_END_EPSILON_PX = 2; /** Hover falloff radius in ticks (0 = hovered). */ const HOVER_FALLOFF_TICKS = 3; +/** + * Astryx's HoverCard waits 300ms before opening, which guards against a + * pointer crossing a wide row on its way somewhere else. A tick is 22px of + * rail that nothing else is on the way to, and the wait is the one part of + * this hover with no motion in it — 300ms of nothing reads as lag rather than + * as restraint. + */ +const PREVIEW_DELAY_MS = 120; +/** + * How long a click-driven jump suppresses the highlight's glide when no + * `scrollend` arrives — a smooth scroll of the whole transcript takes a few + * hundred milliseconds, and overshooting only means the next scroll-driven + * move is placed rather than animated. + */ +const JUMP_SETTLE_TIMEOUT_MS = 700; interface PromptRailResizeObserver { observe(target: Element): void; @@ -32,6 +47,54 @@ export function keepActivePromptRailTickVisible(rail: HTMLElement): void { rail.scrollTop += tickBox.bottom - railBox.bottom; } +/** Frame scheduler seam, so the jump hold can be driven by a test. */ +export interface PromptRailFrameScheduler { + request(callback: () => void): number; + cancel(handle: number): void; +} + +const browserFrameScheduler: PromptRailFrameScheduler = { + request: (callback) => requestAnimationFrame(callback), + cancel: (handle) => cancelAnimationFrame(handle), +}; + +/** + * Hold a jump's destination while the transcript grows under it. + * + * A jump into a part of the transcript the progressive mount has not reached + * has to mount it first, and the fill that follows changes the scroller's + * height for several frames afterwards. Astryx's auto-follow lock unlocks on a + * scroll up, but deliberately ignores any scroll event that arrives with a + * changed `scrollHeight`, reading it as a resize artefact rather than the + * reader moving — so a jump landing mid-fill leaves the lock on, and the lock + * pulls the transcript back to the bottom. Re-aiming on each height change + * holds the destination until the fill stops, and the last of those scrolls + * lands with a stable height, which is the one the lock finally reads. + * + * Only height changes re-aim: correcting every frame would flatten the smooth + * scroll into a jump, and reacting to nothing at all is the bug. + */ +export function holdJumpDestination( + root: Element, + readTargetId: () => string | null, + scheduler: PromptRailFrameScheduler = browserFrameScheduler, +): () => void { + let handle = 0; + let lastHeight = root.scrollHeight; + const hold = (): void => { + handle = scheduler.request(hold); + if (root.scrollHeight === lastHeight) return; + lastHeight = root.scrollHeight; + const turnId = readTargetId(); + if (turnId === null) return; + const target = root.querySelector(`[data-turn-id="${CSS.escape(turnId)}"]`); + // `auto`: this is a correction, not a second journey. + if (target) (target as HTMLElement).scrollIntoView({ behavior: 'auto', block: 'start' }); + }; + handle = scheduler.request(hold); + return () => scheduler.cancel(handle); +} + export function observeActivePromptRailVisibility( rail: HTMLElement, createObserver: PromptRailResizeObserverFactory = createPromptRailResizeObserver, @@ -65,6 +128,11 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe const [safeArea, setSafeArea] = useState<{ scrollport: number; dock: number } | null>(null); const railRef = useRef(null); const [hoveredIndex, setHoveredIndex] = useState(null); + const [jumping, setJumping] = useState(false); + // The turn a click aimed at, held until that click's scroll settles. A ref, + // not state: the observer effect reads it on every scroll frame and must not + // be torn down and rebuilt over the whole transcript when it changes. + const jumpTargetRef = useRef(null); useEffect(() => { const root = scrollRef.current; @@ -79,6 +147,11 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe const visible = new Set(); const resolveActive = (): void => { + // A jump owns the highlight until its scroll settles. Without this the + // observer walks the highlight through every prompt the scroll passes, + // which is the travelling the click was meant to skip — suppressing the + // glide alone would only turn one long slide into a burst of hops. + if (jumpTargetRef.current !== null) return; if (root.scrollHeight - root.scrollTop - root.clientHeight <= SCROLL_END_EPSILON_PX) { setActiveTurnId(turns[turns.length - 1]!.turnId); return; @@ -158,13 +231,48 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe return observeActivePromptRailVisibility(rail); }, [activeTurnId, turns]); + // The highlight glides between prompts because the reader is following it as + // the transcript scrolls under it. A click is the opposite: the reader picked + // the destination, so the highlight switches there once and holds, and the + // scroll the click started moves underneath it without moving it. `jumping` + // (the CSS side) kills the glide for that one switch; `jumpTargetRef` (read + // by the observer above) is what keeps the scroll from walking the highlight + // through every prompt it passes on the way. + useEffect(() => { + if (!jumping) return; + const root = scrollRef.current; + const settle = (): void => { + jumpTargetRef.current = null; + setJumping(false); + }; + // `scrollend` alone would end the jump the moment the transcript grows + // under it (see the frame loop below), so the jump holds for its full + // window and the timer is what ends it. + const timer = window.setTimeout(settle, JUMP_SETTLE_TIMEOUT_MS); + if (!root) return () => window.clearTimeout(timer); + + // The other half of the same click: a jump into an unmounted part of the + // transcript has to survive the fill that follows it. See + // `holdJumpDestination`. + const releaseHold = holdJumpDestination(root, () => jumpTargetRef.current); + + return () => { + window.clearTimeout(timer); + releaseHold(); + }; + }, [jumping, scrollRef]); + function jumpTo(turnId: string): void { const el = scrollRef.current?.querySelector(`[data-turn-id="${CSS.escape(turnId)}"]`); + // Claimed before the scroll starts: a same-frame `scroll` event would + // otherwise reach the observer while the highlight is still unowned. + jumpTargetRef.current = turnId; if (el && 'scrollIntoView' in el) { (el as HTMLElement).scrollIntoView({ behavior: scrollBehavior, block: 'start' }); } else if (!el) { onNavigateFallback?.(turnId); } + setJumping(true); setActiveTurnId(turnId); } @@ -186,6 +294,7 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe