From f788ba518bf419cf50acc89fade4600b5739242e Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Tue, 4 Aug 2026 21:07:19 +0800 Subject: [PATCH] fix(ui): pin the prompt anchor rail to the Astryx chat scrollport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt navigation rail (#563) has been invisible since Astryx's ChatLayout took over the chat surface. The rail pins itself with `position: absolute`, which resolved against `.maka-chat-shell` back when the chat view owned a viewport-sized scroll box. ChatLayout is the scroll container now and the whole transcript — chat shell included — renders inside it, so that containing block became as tall as the conversation: against the 24-turn e2e fixture the rail laid out at 32544px in a 780px scrollport, centred itself in the middle of the document, and scrolled away with the content. Pin it with a zero-height sticky anchor instead — the same mechanism Astryx uses for the composer dock. It costs no flow space and holds the scrollport's top edge at every scroll position, so the rail never has to learn the scroll container's height. Sticky only holds an offset from its own static position onward, so ChatView renders the rail as the first child of the shell rather than after the transcript. The anchor holds the top edge rather than the centreline because a sticky offset is clamped by its containing block, and this one's is the chat shell, which ends where the transcript does — above the scrollport's bottom edge, since the dock's own box follows it in flow. An anchor parked mid-scrollport gets dragged upward as the reader reaches the end of a conversation, taking the rail off the top of the scrollport with it. The scrollport's lower band belongs to the sticky composer dock, so the rail centres on, and is capped to, what is left above it. Centring on the bare scrollport ran the lower ticks under the dock — 122px of overlap at 1240x617, over the frosted blur and the composer card. Neither the dock height nor the scrollport height is knowable in CSS and neither is a constant — the dock grows with the composer's draft and with the panels docked above it — so a ResizeObserver on both publishes them as custom properties. Past its cap the rail becomes a scroller in its own right, and then marking a tick active is not enough: the tick can sit outside the rail's own viewport, where it is neither visible nor clickable. The rail scrolls the active tick back into view, by arithmetic on itself rather than `scrollIntoView` — that walks every scrollable ancestor, and the nearest one here is the transcript the reader is scrolling. The rail also marks the prompt being read, and a turn only becomes current once it reaches the top third of the scrollport. A conversation that ends on a one-line answer has no scroll left to bring its last turn up there, so the reader could sit at the very bottom with `aria-current` stranded on the previous prompt. Resolve the end of the scroller explicitly, the way Astryx's own scroll-spy does. The hover preview moves onto Astryx's HoverCard. It is portalled, so the topmost and bottommost ticks no longer push their preview off the window edge, and the card comes with the theme's own surface, radius, and shadow instead of ~30 lines of hand-positioned popover CSS. (Tooltip portals too, but its palette is deliberately inverted for short strings; this preview is a prompt plus a slice of its reply and should read as a card.) Verified on the real surface — `prompt-rail.spec.ts`: - the rail's box stays inside the scrollport, and shorter than it, at the top, middle, and bottom of a 24-turn conversation; - its centre sits on the centre of the band above the dock at 860/700/ 617/500px window heights, at both ends of the scroll — the bottom being where sticky clamping bites; - every tick visible in the rail is the topmost element at its own centre, so the claim is about what a pointer would hit rather than paint order; - a tick click brings its prompt to rest at the top of the scrollport; - the last prompt becomes current at the end of a transcript whose final turn is one line (`short-final-turn` fixture), asserted alongside a check that the turn really never reaches the activation band, so the test cannot pass for the wrong reason; - the active tick stays inside the rail's own viewport and hit-testable once the rail overflows (`overflowing-rail` fixture, 90 prompts), at both ends of the transcript; - all of it holds with the sidebar and the session workbar open. Both new fixtures are their own seeds rather than variations on `long-transcript`: that fixture's warm-up contract reads every turn as taller than its 250px content-visibility placeholder, and neither a turn that shrinks on warm-up nor 90 turns of filler would leave it measuring what it measures today. --- apps/desktop/e2e/fixtures.ts | 34 ++ apps/desktop/e2e/prompt-rail.spec.ts | 337 ++++++++++++++++++ apps/desktop/src/main/e2e-fixture.ts | 32 ++ .../main/e2e-fixture/scenarios-sessions.ts | 112 ++++++ .../src/main/e2e-fixture/seed-helpers.ts | 2 + .../src/renderer/styles/prompt-rail.css | 92 +++-- .../src/renderer/styles/quote-side-panel.css | 4 +- packages/core/src/e2e-fixture.ts | 7 + packages/ui/src/chat-view.tsx | 6 +- packages/ui/src/prompt-anchor-rail.tsx | 181 ++++++++-- 10 files changed, 748 insertions(+), 59 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 d8df7c032d..9ca06930bf 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -253,6 +253,8 @@ export const test = base.extend<{ firstRunWindow: Page; modelPickerLongWindow: Page; longTranscriptWindow: Page; + shortFinalTurnWindow: Page; + overflowingRailWindow: Page; sidebarLongSessionsWindow: Page; disclosureOutputWindow: Page; sandboxBoundaryWindow: Page; @@ -319,6 +321,38 @@ export const test = base.extend<{ use, ); }, + // Short final turn: boots the e2e-fixture `short-final-turn` fixture — five + // tall turns and a one-line last turn — and opens it as the active session. + // Same readiness contract as `longTranscriptWindow` and for the same reason: + // the markdown chunk must have landed before the spec scrolls. Used by the + // prompt-rail spec to reach an end of the scroller that the rail's + // activation band never covers. + shortFinalTurnWindow: async ({}, use) => { + await withE2eWindow( + { + seed: false, + readinessSelector: '[data-chat-scroll-container="true"]:has(.maka-turn):not(:has(.maka-markdown-pending))', + e2eFixtureScenario: 'short-final-turn', + locale: 'zh', + }, + use, + ); + }, + // Overflowing rail: boots the e2e-fixture `overflowing-rail` fixture — 60 + // short turns — and opens it as the active session. Same readiness contract + // as the two above. Used by the prompt-rail spec to exercise the rail once it + // is past its cap and scrolling independently of the transcript. + overflowingRailWindow: async ({}, use) => { + await withE2eWindow( + { + seed: false, + readinessSelector: '[data-chat-scroll-container="true"]:has(.maka-turn):not(:has(.maka-markdown-pending))', + e2eFixtureScenario: 'overflowing-rail', + locale: 'zh', + }, + use, + ); + }, // Long sidebar sessions: boots the e2e-fixture `sidebar-long-sessions` // fixture, which seeds 60 active sessions and opens the newest one // (`...-00`) with the sidebar expanded. Fixture mode seeds its own diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts new file mode 100644 index 0000000000..e8360db129 --- /dev/null +++ b/apps/desktop/e2e/prompt-rail.spec.ts @@ -0,0 +1,337 @@ +import { test, expect } from './fixtures'; +import type { Page } from '@playwright/test'; + +/** + * The prompt anchor rail (#563) has to stay on screen — and reachable — at + * every scroll position. It was pinned with `position: absolute` against + * `.maka-chat-shell` back when the chat view owned a viewport-sized scroll + * box. Astryx's ChatLayout owns the scroll container now and the whole + * transcript renders inside it, so that containing block became as tall as the + * conversation: the rail laid out across ~32000px, centred itself somewhere in + * the middle of the document, and scrolled away with the content — visibly + * gone. + * + * A static CSS read cannot see any of that. Only a real scroller with a real + * transcript can, which is what these fixtures give us. + */ + +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(); + const dock = scroller.lastElementChild.getBoundingClientRect(); + const anchor = document.querySelector('.maka-prompt-rail-anchor'); + return { + ticks: rail.querySelectorAll('.maka-prompt-rail-tick').length, + // 0 = the sticky anchor is holding the scrollport's top edge, i.e. its + // offset has not been clamped by the end of the chat shell. + anchorFromScrollportTop: Math.round(anchor.getBoundingClientRect().top - s.top), + // Where the rail's centre is, against the centre of the band above the dock. + railCentre: Math.round(r.top + r.height / 2 - s.top), + bandCentre: Math.round((dock.top - s.top) / 2), + // Positive on all = the rail's box is inside the scrollport's box, and + // clear of the band the sticky composer 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), + railHeight: Math.round(r.height), + scrollportHeight: Math.round(s.height), + scrollTop: Math.round(scroller.scrollTop), + }; +})()`; + +type RailProbe = { + ticks: number; + anchorFromScrollportTop: number; + railCentre: number; + bandCentre: number; + insetTop: number; + insetBottom: number; + insetRight: number; + dockClearance: number; + railHeight: number; + scrollportHeight: number; + scrollTop: number; +}; + +/** + * Which element a real pointer would land on at each tick's centre, reported + * for every tick that is not its own hit target. + * + * Only ticks inside the rail's visible box count. Once the rail is past its cap + * it scrolls internally, and a tick scrolled out of that box is legitimately + * not on screen — the contract here is that a tick you can see is a tick you + * can hit. + * + * That exemption is also this helper's blind spot: it cannot see an *active* + * tick that has been left outside the rail's viewport, because it skips the + * tick instead of failing on it. `activeTickVisibility` below is what covers + * that, and the overflowing-rail test is where it matters. + */ +async function unreachableTicks(page: Page): Promise> { + return await page.evaluate(() => { + const rail = document.querySelector('.maka-prompt-rail'); + if (!rail) throw new Error('Expected the prompt rail'); + const railBox = rail.getBoundingClientRect(); + const ticks = [...document.querySelectorAll('.maka-prompt-rail-tick')]; + return ticks.flatMap((tick, index) => { + const rect = tick.getBoundingClientRect(); + const centre = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + if (centre.y < railBox.top || centre.y > railBox.bottom) return []; + const hit = document.elementFromPoint(centre.x, centre.y); + if (hit && (hit === tick || tick.contains(hit))) return []; + return [{ index, hit: hit ? `${hit.tagName.toLowerCase()}.${String(hit.className).split(' ')[0]}` : null }]; + }); + }); +} + +/** + * Where the active tick sits relative to the rail's own viewport, and whether + * a pointer at its centre would reach it. + */ +async function activeTickVisibility(page: Page): Promise<{ + found: boolean; + fullyInsideRail: boolean; + hit: string | null; + hitsActiveTick: boolean; + railScrollTop: number; + railScrollHeight: number; + railClientHeight: number; +}> { + return await page.evaluate(() => { + const rail = document.querySelector('.maka-prompt-rail'); + if (!rail) throw new Error('Expected the prompt rail'); + const active = rail.querySelector('.maka-prompt-rail-tick[data-active="true"]'); + const railBox = rail.getBoundingClientRect(); + const base = { + railScrollTop: Math.round(rail.scrollTop), + railScrollHeight: Math.round(rail.scrollHeight), + railClientHeight: Math.round(rail.clientHeight), + }; + if (!active) return { found: false, fullyInsideRail: false, hit: null, hitsActiveTick: false, ...base }; + const box = active.getBoundingClientRect(); + const hitEl = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2); + return { + found: true, + fullyInsideRail: box.top >= railBox.top - 1 && box.bottom <= railBox.bottom + 1, + hit: hitEl ? `${hitEl.tagName.toLowerCase()}.${String(hitEl.className).split(' ')[0]}` : null, + hitsActiveTick: Boolean(hitEl && (hitEl === active || active.contains(hitEl))), + ...base, + }; + }); +} + +async function scrollToRatio(page: Page, ratio: number): Promise { + await page.evaluate((value) => { + const scroller = document.querySelector('[data-chat-scroll-container="true"]'); + if (!scroller) throw new Error('Expected the Astryx chat scroller'); + scroller.scrollTop = (scroller.scrollHeight - scroller.clientHeight) * value; + }, ratio); + // One settled frame, so the read is against the post-scroll layout. + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); +} + +async function settled(page: Page, turns: number): Promise { + await expect(page.locator('.maka-turn')).toHaveCount(turns); + await expect(page.locator('[data-chat-scroll-container="true"][data-turn-warmup="settled"]')).toBeAttached({ timeout: 15_000 }); +} + +test('the prompt rail stays inside the scrollport at every scroll position', async ({ longTranscriptWindow: page }) => { + await settled(page, 24); + + for (const ratio of [0, 0.5, 1]) { + await scrollToRatio(page, ratio); + const probe = (await page.evaluate(RAIL_PROBE)) as RailProbe | null; + const where = `at scroll ratio ${ratio}: ${JSON.stringify(probe)}`; + expect(probe, where).not.toBeNull(); + expect(probe!.ticks, where).toBe(24); + // The regression this locks: the rail's own height tracked the transcript + // (~32000px against a ~780px scrollport), not the ticks it holds. + expect(probe!.railHeight, where).toBeLessThanOrEqual(probe!.scrollportHeight); + expect(probe!.insetTop, where).toBeGreaterThanOrEqual(0); + expect(probe!.insetBottom, where).toBeGreaterThanOrEqual(0); + expect(probe!.insetRight, where).toBeGreaterThanOrEqual(0); + } +}); + +/** + * The rail centres itself on the usable band, which is the scrollport minus + * the sticky composer dock. Centring on the bare scrollport put the lower + * ticks over the dock — 122px of overlap at 1240x617 — where they sit on top + * of the frosted blur and the composer card. + * + * Window height is the variable, not width: the dock's height is fixed by its + * content while the scrollport shrinks around it, so a short window is where + * the band runs out first. + */ +test('the rail clears the composer dock at every window height', async ({ longTranscriptWindow: page }) => { + await settled(page, 24); + + for (const height of [860, 700, 617, 500]) { + await page.setViewportSize({ width: 1240, height }); + await expect.poll(() => page.evaluate(() => window.innerHeight)).toBe(height); + + // Both ends of the scroll. The bottom is the load-bearing one: a sticky + // offset is clamped by its containing block, and the chat shell ends above + // the scrollport's bottom edge (the dock's box follows it in flow), so an + // anchor parked mid-scrollport gets dragged upward exactly there — off the + // top of the scrollport at short window heights. + for (const ratio of [0, 1]) { + await scrollToRatio(page, ratio); + // The dock measurement reaches the rail through a ResizeObserver, one + // layout after the resize lands. + await expect + .poll(async () => ((await page.evaluate(RAIL_PROBE)) as RailProbe | null)?.dockClearance) + .toBeGreaterThanOrEqual(0); + + const probe = (await page.evaluate(RAIL_PROBE)) as RailProbe; + const where = `at ${height}px tall, scroll ratio ${ratio}: ${JSON.stringify(probe)}`; + // The outcome, not the mechanism: the rail sits on the centreline of the + // band above the dock, at both ends of the scroll. `anchorFromScrollportTop` + // rides along in the diagnostics because a non-zero reading here is the + // sticky clamp, which is what dragged the rail off the top before. + expect(probe.railCentre, where).toBe(probe.bandCentre); + expect(probe.insetTop, where).toBeGreaterThanOrEqual(0); + // And every tick is the topmost element at its own centre — the rail is + // not merely visible over the dock, it is what a pointer would hit. + expect(await unreachableTicks(page), where).toEqual([]); + } + } +}); + +test('clicking a tick jumps to that prompt', async ({ longTranscriptWindow: page }) => { + await settled(page, 24); + await scrollToRatio(page, 1); + + // Fixture windows don't pass OS hit-testing, so a synthesised mouse move + // never lands — hence `dispatchEvent` here, as elsewhere in this suite. + // What that leaves untested is whether anything covers the tick, which is + // what `unreachableTicks` (a real `elementFromPoint` at the tick's centre) + // answers, including for the last tick at the bottom of the transcript. + expect(await unreachableTicks(page)).toEqual([]); + const firstTick = page.locator('.maka-prompt-rail-tick').first(); + await firstTick.dispatchEvent('click'); + + // The first prompt's turn comes to rest at the top of the scrollport, which + // is a scroll the smooth behaviour has to finish first. + await expect + .poll(async () => + page.evaluate(() => { + const scroller = document.querySelector('[data-chat-scroll-container="true"]'); + const firstTurn = scroller?.querySelector('.maka-turn'); + if (!scroller || !firstTurn) return null; + return Math.round(firstTurn.getBoundingClientRect().top - scroller.getBoundingClientRect().top); + }), + ) + .toBeLessThanOrEqual(1); + + await expect(firstTick).toHaveAttribute('aria-current', 'true'); +}); + +/** + * A turn only becomes current once it reaches the top third of the scrollport. + * A conversation that ends on a one-line answer has no scroll left to bring + * its last turn up there, so the end of the scroller has to be resolved + * explicitly — otherwise the reader sits at the bottom with `aria-current` + * stranded on the previous prompt. + */ +test('the last prompt is current once the transcript is scrolled to the end', async ({ shortFinalTurnWindow: page }) => { + await settled(page, 6); + const ticks = page.locator('.maka-prompt-rail-tick'); + await expect(ticks).toHaveCount(6); + + await scrollToRatio(page, 0); + await expect(ticks.last()).not.toHaveAttribute('aria-current', 'true'); + + await scrollToRatio(page, 1); + await expect(ticks.last()).toHaveAttribute('aria-current', 'true'); + // The final turn really is short enough to never cross the activation band, + // so the assertion above is about the end-of-scroller rule and not about a + // tall turn that happened to climb into it. + const finalTurnTop = await page.evaluate(() => { + const scroller = document.querySelector('[data-chat-scroll-container="true"]')!; + const turns = [...scroller.querySelectorAll('.maka-turn')]; + const last = turns[turns.length - 1]!; + const s = scroller.getBoundingClientRect(); + return { + fromTop: Math.round(last.getBoundingClientRect().top - s.top), + activationBand: Math.round(s.height * 0.34), + }; + }); + expect(finalTurnTop.fromTop, JSON.stringify(finalTurnTop)).toBeGreaterThan(finalTurnTop.activationBand); +}); + +/** + * The rail shares the right edge with the session workbar, and the sidebar + * takes width off the left. Both change the chat column's box rather than the + * rail's own rules, which is exactly the kind of thing a fixed-viewport test + * never sees. + */ +test('the rail survives the sidebar and workbar being open', async ({ longTranscriptWindow: page }) => { + await settled(page, 24); + await page.locator('button[aria-label="展开侧边栏"]').dispatchEvent('click'); + await expect(page.locator('[data-sidebar-state="expanded"]')).toBeAttached(); + + const workbar = page.getByRole('complementary', { name: '会话工作栏' }); + if (!(await workbar.isVisible())) { + await page.getByRole('button', { name: '展开会话工作栏' }).dispatchEvent('click'); + } + await expect(workbar).toBeVisible(); + + await scrollToRatio(page, 1); + const probe = (await page.evaluate(RAIL_PROBE)) as RailProbe; + const where = `sidebar + workbar open: ${JSON.stringify(probe)}`; + expect(probe.ticks, where).toBe(24); + expect(probe.insetTop, where).toBeGreaterThanOrEqual(0); + expect(probe.insetBottom, where).toBeGreaterThanOrEqual(0); + expect(probe.insetRight, where).toBeGreaterThanOrEqual(0); + expect(probe.dockClearance, where).toBeGreaterThanOrEqual(0); + expect(await unreachableTicks(page), where).toEqual([]); +}); + +/** + * Past its cap the rail is a scroller in its own right, and marking a tick + * active is then not enough: the tick can sit outside the rail's own viewport, + * where it is neither visible nor clickable. Scrolling a 90-prompt transcript + * to the end put the last tick exactly there while the rail stayed at + * scrollTop 0. + */ +test('the active tick stays visible once the rail overflows', async ({ overflowingRailWindow: page }) => { + await settled(page, 90); + await expect(page.locator('.maka-prompt-rail-tick')).toHaveCount(90); + + // The premise: this fixture exists to put the rail past its cap. If it ever + // stops overflowing, the rest of this test proves nothing. + const capped = await activeTickVisibility(page); + expect(capped.railScrollHeight, JSON.stringify(capped)).toBeGreaterThan(capped.railClientHeight); + + await scrollToRatio(page, 1); + await expect(page.locator('.maka-prompt-rail-tick').last()).toHaveAttribute('aria-current', 'true'); + + const atEnd = await activeTickVisibility(page); + const where = JSON.stringify(atEnd); + expect(atEnd.found, where).toBe(true); + // Visible inside the rail, and what a pointer at its centre would land on — + // the two halves of "reachable" that a bare `aria-current` does not imply. + expect(atEnd.fullyInsideRail, where).toBe(true); + expect(atEnd.hitsActiveTick, where).toBe(true); + // And the rail had to scroll to get there, so this is the fix working rather + // than the tick happening to start on screen. + expect(atEnd.railScrollTop, where).toBeGreaterThan(0); + + // Symmetrically, going back to the top brings the first tick back into the + // rail's viewport instead of stranding it above. + await scrollToRatio(page, 0); + await expect(page.locator('.maka-prompt-rail-tick').first()).toHaveAttribute('aria-current', 'true'); + const atStart = await activeTickVisibility(page); + const back = JSON.stringify({ atStart, atEnd }); + expect(atStart.fullyInsideRail, back).toBe(true); + expect(atStart.hitsActiveTick, back).toBe(true); + // Scrolled back up, not merely still wherever the end left it. Not asserted + // as exactly 0: bringing the first tick flush leaves the rail's own top + // padding scrolled through. + expect(atStart.railScrollTop, back).toBeLessThan(atEnd.railScrollTop); +}); diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index 9bc8712117..e99de3e85e 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -16,8 +16,10 @@ import { LONG_SIDEBAR_SCENARIOS, LONG_SIDEBAR_SESSION_PREFIX, LONG_TRANSCRIPT_SESSION_ID, + OVERFLOWING_RAIL_SESSION_ID, PERMISSION_SESSION_ID, PROCESSING_SESSION_ID, + SHORT_FINAL_TURN_SESSION_ID, STALE_FAKE_SESSION_ID, STREAMING_SESSION_ID, TURN_CONTROL_BRANCH_ORPHAN_SESSION_ID, @@ -63,6 +65,10 @@ import { longSidebarSessions, longTranscriptMessages, longTranscriptSession, + overflowingRailMessages, + overflowingRailSession, + shortFinalTurnMessages, + shortFinalTurnSession, staleFakeMessages, staleFakeSession, turnControlSessions, @@ -200,6 +206,13 @@ const E2E_FIXTURE_SCENARIOS = new Set([ // session on boot, so off-screen turns mount as content-visibility // placeholders (see e2e/scroll-geometry.spec.ts). 'long-transcript', + // Prompt-rail activation contract: a transcript whose final turn is one + // line, which never crosses the rail's top-third activation band + // (see e2e/prompt-rail.spec.ts). + 'short-final-turn', + // Prompt-rail overflow contract: enough prompts that the rail exceeds its cap + // and scrolls independently (see e2e/prompt-rail.spec.ts). + 'overflowing-rail', // #819: BrowserPanel renderer-chrome fixture. Seeds `liveBrowserSessionIds` // with the active turn session so `BrowserPanel` mounts; with no native // `WebContentsView` in e2e-fixture mode, `browser.getState` resolves null @@ -652,6 +665,15 @@ function buildE2eFixtureState(fixture: E2eFixture | null): E2eFixtureState | nul // above-viewport turns mount render-skipped (never rendered), the // exact state the warm-up + pinned-bottom invariants protect. return { ...state, activeSessionId: LONG_TRANSCRIPT_SESSION_ID }; + case 'short-final-turn': + // Prompt-rail activation contract: boot into the short-tailed session so + // the spec can scroll straight to an end the activation band never + // reaches on its own. + return { ...state, activeSessionId: SHORT_FINAL_TURN_SESSION_ID }; + case 'overflowing-rail': + // Prompt-rail overflow contract: boot into the 60-prompt session so the + // rail is already past its cap when the spec scrolls to the end. + return { ...state, activeSessionId: OVERFLOWING_RAIL_SESSION_ID }; case 'all': return { ...state, @@ -719,6 +741,16 @@ export async function seedE2eFixture(input: { if (input.fixture.scenario === 'long-transcript') { await writeSession(input.workspaceRoot, longTranscriptSession(now), longTranscriptMessages(now)); } + // Prompt-rail activation contract (e2e/prompt-rail.spec.ts): a scrollable + // transcript that ends on a turn too short to reach the activation band. + if (input.fixture.scenario === 'short-final-turn') { + await writeSession(input.workspaceRoot, shortFinalTurnSession(now), shortFinalTurnMessages(now)); + } + // Prompt-rail overflow contract (e2e/prompt-rail.spec.ts): more prompts than + // the rail can show at once, so it scrolls independently of the transcript. + if (input.fixture.scenario === 'overflowing-rail') { + await writeSession(input.workspaceRoot, overflowingRailSession(now), overflowingRailMessages(now)); + } // PR109f (g): all three turn-control-* scenarios share the same // on-disk seed; only the active session selection differs. Seeding // the same trio for any of them keeps the fixtures interchangeable diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-sessions.ts b/apps/desktop/src/main/e2e-fixture/scenarios-sessions.ts index 6b8627e95d..d78cdd1776 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-sessions.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-sessions.ts @@ -7,6 +7,8 @@ import { LONG_SIDEBAR_SESSION_COUNT, LONG_SIDEBAR_SESSION_PREFIX, LONG_TRANSCRIPT_SESSION_ID, + OVERFLOWING_RAIL_SESSION_ID, + SHORT_FINAL_TURN_SESSION_ID, STALE_FAKE_SESSION_ID, TURN_CONTROL_BRANCH_ORPHAN_SESSION_ID, TURN_CONTROL_BRANCH_VISIBLE_SESSION_ID, @@ -71,6 +73,116 @@ export function longTranscriptMessages(now: number): StoredMessage[] { return messages; } +export function shortFinalTurnSession(now: number): SessionHeader { + return header({ + id: SHORT_FINAL_TURN_SESSION_ID, + name: '末轮极短的会话', + connection: 'zai-live', + model: 'glm-5.1', + now, + lastMessageAt: now - 5 * 60_000, + }); +} + +/** + * Five tall turns and a last turn one line long — a conversation that ends the + * way most do, on a short answer. + * + * The prompt rail's activation band is the top third of the scrollport. A tail + * this short never crosses it: scrolling to the very end still leaves the final + * turn below the line, because there is no scroll left to bring it up. So the + * last prompt can only become current if the rail resolves the end of the + * scroller explicitly, which is what `prompt-rail.spec.ts` asserts here. + * + * Deliberately a separate seed rather than a short tail on `long-transcript`: + * that fixture's warm-up contract (`scroll-geometry.spec.ts`) reads every turn + * as taller than its 250px content-visibility placeholder, and a turn that + * *shrinks* on warm-up instead of growing would change what it measures. + */ +export function shortFinalTurnMessages(now: number): StoredMessage[] { + const filler = Array.from( + { length: 60 }, + (_, line) => `第 ${line + 1} 行 — 用于撑高单个 turn 的占位正文内容。`, + ).join(' \n'); + const messages: StoredMessage[] = []; + const base = now - 30 * 60_000; + const total = 6; + for (let turn = 0; turn < total; turn++) { + const isFinal = turn === total - 1; + const turnId = `short-final-turn-${turn}`; + messages.push({ + type: 'user', + id: `short-final-user-${turn}`, + turnId, + ts: base + turn * 60_000, + text: `短尾会话问题 ${turn + 1}`, + }); + messages.push({ + type: 'assistant', + id: `short-final-assistant-${turn}`, + turnId, + ts: base + turn * 60_000 + 30_000, + text: isFinal ? '好的。' : `短尾会话回答 ${turn + 1}\n\n${filler}`, + modelId: 'glm-5.1', + }); + } + return messages; +} + +export function overflowingRailSession(now: number): SessionHeader { + return header({ + id: OVERFLOWING_RAIL_SESSION_ID, + name: '提问多到索引线放不下', + connection: 'zai-live', + model: 'glm-5.1', + now, + lastMessageAt: now - 5 * 60_000, + }); +} + +/** + * 90 short turns. The count is the point, not the height: past roughly 47 + * prompts the rail exceeds its cap and becomes a scroller of its own, and then + * the active tick can sit outside the rail's own viewport — visible neither to + * the reader nor to a hit test — unless the rail scrolls it back into view. + * + * 90 rather than the ~50 that would just barely overflow, so the margin + * survives a dock that renders taller or shorter than it does here — at 60 the + * rail overflowed by only 58px, close enough to the cap that another + * platform's geometry could erase the premise the test rests on. + * + * Short answers on purpose. The rail only needs many prompts; making each turn + * as tall as `long-transcript`'s would multiply the transcript by 60 and buy + * the test nothing but warm-up time. + */ +export function overflowingRailMessages(now: number): StoredMessage[] { + const body = Array.from( + { length: 6 }, + (_, line) => `第 ${line + 1} 行 — 短回答正文。`, + ).join(' \n'); + const messages: StoredMessage[] = []; + const base = now - 120 * 60_000; + for (let turn = 0; turn < 90; turn++) { + const turnId = `overflowing-rail-turn-${turn}`; + messages.push({ + type: 'user', + id: `overflowing-rail-user-${turn}`, + turnId, + ts: base + turn * 60_000, + text: `密集提问 ${turn + 1}`, + }); + messages.push({ + type: 'assistant', + id: `overflowing-rail-assistant-${turn}`, + turnId, + ts: base + turn * 60_000 + 30_000, + text: `密集回答 ${turn + 1}\n\n${body}`, + modelId: 'glm-5.1', + }); + } + return messages; +} + /** * PR109b workstation-statuses fixture seed. Returns one session per * SessionStatus group + 4 blocked sub-rows (one per diff --git a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts index a470fd58b3..a77daaffe1 100644 --- a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts +++ b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts @@ -15,6 +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 LONG_TRANSCRIPT_SESSION_ID = 'e2e-fixture-long-transcript'; +export const SHORT_FINAL_TURN_SESSION_ID = 'e2e-fixture-short-final-turn'; +export const OVERFLOWING_RAIL_SESSION_ID = 'e2e-fixture-overflowing-rail'; export const PROCESSING_SESSION_ID = 'e2e-fixture-processing'; export const STREAMING_SESSION_ID = 'e2e-fixture-streaming'; export const PERMISSION_SESSION_ID = 'e2e-fixture-permission'; diff --git a/apps/desktop/src/renderer/styles/prompt-rail.css b/apps/desktop/src/renderer/styles/prompt-rail.css index 983823c327..b60eeaa3a8 100644 --- a/apps/desktop/src/renderer/styles/prompt-rail.css +++ b/apps/desktop/src/renderer/styles/prompt-rail.css @@ -1,20 +1,72 @@ /* Codex-style prompt navigation rail: one tick per user prompt, pinned to the - right edge of the chat scroll shell. Low-key by default, brightens on hover; + right edge of the chat scrollport. Low-key by default, brightens on hover; each tick jumps to that prompt and the active turn's tick stays highlighted. The tick bar draws in `currentColor` so active/hover just shift the neutral - text color (muted -> primary) — no brand rail, no hover-surface background. */ + text color (muted -> primary) — no brand rail, no hover-surface background. + The hover preview is Astryx's HoverCard; only the two lines inside it are + styled here. */ + +/* Astryx's ChatLayout is the scroll container and the transcript — chat shell + and all — renders inside it, so `position: absolute` resolves against a box + as tall as the whole conversation: the rail used to be laid out across + ~32000px and scroll away with the content instead of staying on screen. + + This zero-height sticky anchor is what pins it. It costs no flow space and + holds the scrollport's top edge at every scroll position — the same sticky + mechanism Astryx uses for the composer dock. It only works from the anchor's + own static position onward, so ChatView renders it as the first child of + `.maka-chat-shell`. + + `top: 0` rather than the centreline, deliberately: a sticky offset is + clamped by its containing block, and this one's is the chat shell, which + ends where the transcript does — above the scrollport's bottom edge, since + the dock's own box follows it in flow. An anchor parked mid-scrollport + therefore gets dragged upward as the reader reaches the end of a + conversation, taking the rail off the top of the scrollport with it (CI + caught -62px at a 500px window). Pinned to the top edge the clamp cannot + engage while any transcript is on screen, and the rail does its own + centring from the measured band below. + + `--maka-prompt-rail-scrollport` / `--maka-prompt-rail-dock` are measured in + prompt-anchor-rail.tsx; see the rail's own rule for what they buy. */ +.maka-prompt-rail-anchor { + position: sticky; + top: 0; + height: 0; + z-index: var(--z-panel-action); + /* The anchor spans the full chat column; only the rail inside it is a + target, or it would swallow clicks across the transcript. */ + pointer-events: none; +} + .maka-prompt-rail { position: absolute; - top: var(--space-8); - bottom: var(--space-8); right: var(--space-1); - z-index: var(--z-panel-action); + /* The scrollport's lower band belongs to the sticky composer dock, so the + rail centres on — and is capped to — what is left above it. Centring on + the bare scrollport ran the lower ticks under the dock (122px of overlap + at 1240x617), over the frosted blur and the composer card. + + Measured from the anchor's top edge, which is the scrollport's, so this is + an absolute position rather than an offset from a centreline that sticky + clamping can move. The fallbacks are the pre-measurement first paint: no + dock inset, and the window standing in for the scrollport. */ + top: calc((var(--maka-prompt-rail-scrollport, 100svh) - var(--maka-prompt-rail-dock, 0px)) / 2); + transform: translateY(-50%); + max-height: calc( + var(--maka-prompt-rail-scrollport, 100svh) - var(--maka-prompt-rail-dock, 0px) - (2 * var(--space-2)) + ); + overflow-y: auto; + overscroll-behavior: contain; display: flex; flex-direction: column; align-items: flex-end; - justify-content: center; + /* `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); padding: var(--space-1); + pointer-events: auto; opacity: var(--opacity-muted); transition: opacity var(--duration-base) var(--ease-out-strong); -webkit-app-region: no-drag; @@ -60,35 +112,26 @@ width: 22px; } +/* HoverCard content. Astryx owns the card itself — surface, radius, shadow, + padding — so these rules only stack and clamp the two lines and set the two + text tiers, which stay on the same foreground aliases the transcript uses. */ .maka-prompt-rail-preview { font: var(--maka-text-supporting); - position: absolute; - right: calc(100% + var(--space-2)); - top: 50%; - transform: translateY(-50%); display: flex; flex-direction: column; gap: var(--space-0-5); - width: max-content; max-width: 280px; - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-control); - border: var(--border-width-hairline) solid var(--border); - background: var(--card-bg); - box-shadow: var(--card-shadow); - text-align: left; - opacity: 0; - pointer-events: none; - transition: opacity var(--duration-base) var(--ease-out-strong); + text-align: start; } .maka-prompt-rail-preview-prompt { font: var(--maka-text-heading-5); min-width: 0; color: var(--foreground); - white-space: nowrap; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; overflow: hidden; - text-overflow: ellipsis; } .maka-prompt-rail-preview-reply { @@ -98,8 +141,3 @@ -webkit-box-orient: vertical; overflow: hidden; } - -.maka-prompt-rail-tick:hover .maka-prompt-rail-preview, -.maka-prompt-rail-tick:focus-visible .maka-prompt-rail-preview { - opacity: 1; -} diff --git a/apps/desktop/src/renderer/styles/quote-side-panel.css b/apps/desktop/src/renderer/styles/quote-side-panel.css index 1aaac6e850..35f53312c9 100644 --- a/apps/desktop/src/renderer/styles/quote-side-panel.css +++ b/apps/desktop/src/renderer/styles/quote-side-panel.css @@ -42,7 +42,9 @@ min-height: 0; } -.maka-quote-companion .maka-prompt-rail { +/* The anchor, not just the rail inside it: hiding the sticky box outright + keeps a zero-height sticky element out of the narrow panel's flow. */ +.maka-quote-companion .maka-prompt-rail-anchor { display: none; } diff --git a/packages/core/src/e2e-fixture.ts b/packages/core/src/e2e-fixture.ts index 1d0104f8d4..61f2e41bee 100644 --- a/packages/core/src/e2e-fixture.ts +++ b/packages/core/src/e2e-fixture.ts @@ -130,6 +130,13 @@ export type E2eFixtureScenario = // warm-up + pinned-bottom geometry invariants (E2E scroll-geometry spec) // and gives the scroll-geometry Playwright spec a long-transcript surface. | 'long-transcript' + // Prompt-rail activation contract: five tall turns and a one-line final + // turn, so the last turn can never reach the rail's top-third activation + // band (see e2e/prompt-rail.spec.ts). + | 'short-final-turn' + // Prompt-rail overflow contract: 60 short turns, so the rail exceeds its cap + // and scrolls independently of the transcript (see e2e/prompt-rail.spec.ts). + | 'overflowing-rail' // #819: BrowserPanel renderer-chrome fixture. Seeds `liveBrowserSessionIds` // with the active turn session so `BrowserPanel` mounts (app-shell gates // on `activeId && liveBrowserSessionIds.includes(activeId)`). In diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index add614912f..19592d9105 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -479,6 +479,11 @@ export function ChatView(props: { /> )}
+ {/* First child on purpose: the rail pins itself with a sticky anchor, + and a sticky box only takes an offset from its own static position + onward. Rendered after the transcript it would stay parked at the + bottom of the conversation until the reader scrolled there. */} + )} - {selectionQuote && (props.onQuoteSelection || props.onAskAboutSelection) ? ( selectionActionsLayer.render(
(null); + // The scrollport height and the height of Astryx's sticky composer dock. + // Centring on the bare scrollport ran the lower ticks under the dock — up to + // 122px of overlap at an 860x617 window — so the rail centres on, and is + // capped to, what is left above it. Neither number is knowable in CSS and + // neither is a constant: the dock grows with the composer's draft and with + // the panels docked above it. + const [safeArea, setSafeArea] = useState<{ scrollport: number; dock: number } | null>(null); + const railRef = useRef(null); // Rebuilding this observer costs one querySelector + observe per turn over // the whole transcript, so it must not run per streamed token (#2030). What // keeps it from running is the caller: ChatView hands back the same array @@ -44,6 +67,22 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe if (idByElement.size === 0) return; const visible = new Set(); + const resolveActive = (): void => { + // At the end of the scroller there is nothing left to read past, so the + // final prompt is the current one — even when its turn is too short to + // ever cross the activation band below. Without this, a one-line last + // answer leaves `aria-current` stranded on the previous prompt with the + // reader already at the bottom. Astryx's own scroll-spy resolves the end + // of a scroller the same way. + if (root.scrollHeight - root.scrollTop - root.clientHeight <= SCROLL_END_EPSILON_PX) { + setActiveTurnId(turns[turns.length - 1]!.turnId); + return; + } + // Otherwise the topmost prompt still in view is the "current" one. + const firstVisible = turns.find((turn) => visible.has(turn.turnId)); + if (firstVisible) setActiveTurnId(firstVisible.turnId); + }; + const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { @@ -52,18 +91,78 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe if (entry.isIntersecting) visible.add(id); else visible.delete(id); } - // The topmost prompt still in view is the "current" one. - const firstVisible = turns.find((turn) => visible.has(turn.turnId)); - if (firstVisible) setActiveTurnId(firstVisible.turnId); + resolveActive(); }, // Only count a turn as active once it reaches the top third of the // viewport, so the highlight tracks reading position, not mere presence. { root, rootMargin: '0px 0px -66% 0px', threshold: 0 }, ); for (const el of idByElement.keys()) observer.observe(el); - return () => observer.disconnect(); + + // Reaching the bottom crosses no intersection boundary once the last turns + // are already on screen, so the observer alone never hears about it. + let frame = 0; + const onScroll = (): void => { + if (frame !== 0) return; + frame = requestAnimationFrame(() => { + frame = 0; + resolveActive(); + }); + }; + root.addEventListener('scroll', onScroll, { passive: true }); + + return () => { + observer.disconnect(); + root.removeEventListener('scroll', onScroll); + if (frame !== 0) cancelAnimationFrame(frame); + }; }, [scrollRef, turns]); + useEffect(() => { + const root = scrollRef.current; + if (!root) return; + // Astryx renders the dock as the scroll container's last child; the + // scroll-geometry spec reads it the same way for want of a published hook. + const dock = root.lastElementChild; + const measure = (): void => { + setSafeArea((previous) => { + const next = { + scrollport: root.clientHeight, + dock: dock?.getBoundingClientRect().height ?? 0, + }; + return previous && previous.scrollport === next.scrollport && previous.dock === next.dock + ? previous + : next; + }); + }; + const observer = new ResizeObserver(measure); + observer.observe(root); + if (dock) observer.observe(dock); + measure(); + return () => observer.disconnect(); + }, [scrollRef]); + + // Past enough prompts the rail hits its cap and becomes a scroller of its own, + // and then marking a tick active is not enough — the tick can be outside the + // rail's own viewport, where it is neither visible nor clickable. Scrolling + // the main transcript to the end of a 60-prompt conversation put the last + // tick there while the rail sat at scrollTop 0. + // + // Deliberately arithmetic on the rail rather than `scrollIntoView`: that + // walks every scrollable ancestor, and the nearest one here is the + // transcript itself. Nudging the rail must never move the conversation the + // reader is scrolling. + useEffect(() => { + const rail = railRef.current; + if (!rail || activeTurnId === null) return; + const tick = rail.querySelector('.maka-prompt-rail-tick[data-active="true"]'); + if (!tick) return; + const railBox = rail.getBoundingClientRect(); + const tickBox = tick.getBoundingClientRect(); + if (tickBox.top < railBox.top) rail.scrollTop -= railBox.top - tickBox.top; + else if (tickBox.bottom > railBox.bottom) rail.scrollTop += tickBox.bottom - railBox.bottom; + }, [activeTurnId]); + function jumpTo(turnId: string): void { const el = scrollRef.current?.querySelector(`[data-turn-id="${CSS.escape(turnId)}"]`); if (el && 'scrollIntoView' in el) { @@ -76,30 +175,52 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe if (turns.length < 3) return null; return ( - +
+ +
); });