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 ( - +
+ +
); });