Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/desktop/e2e/fixtures.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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
Expand Down
337 changes: 337 additions & 0 deletions apps/desktop/e2e/prompt-rail.spec.ts

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions apps/desktop/src/main/e2e-fixture.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -63,6 +65,10 @@ import {
longSidebarSessions,
longTranscriptMessages,
longTranscriptSession,
overflowingRailMessages,
overflowingRailSession,
shortFinalTurnMessages,
shortFinalTurnSession,
staleFakeMessages,
staleFakeSession,
turnControlSessions,
Expand DownExpand Up@@ -200,6 +206,13 @@ const E2E_FIXTURE_SCENARIOS = new Set<E2eFixtureScenario>([
// 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
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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
Expand Down
112 changes: 112 additions & 0 deletions apps/desktop/src/main/e2e-fixture/scenarios-sessions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/e2e-fixture/seed-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand Down
92 changes: 65 additions & 27 deletions apps/desktop/src/renderer/styles/prompt-rail.css
Original file line numberDiff line numberDiff line change
@@ -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;
Expand DownExpand Up@@ -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 {
Expand All@@ -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;
}
4 changes: 3 additions & 1 deletion apps/desktop/src/renderer/styles/quote-side-panel.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(ui): pin the prompt anchor rail to the Astryx chat scrollport by ARE404 · Pull Request #2161 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/desktop/e2e/fixtures.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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
Expand Down
337 changes: 337 additions & 0 deletions apps/desktop/e2e/prompt-rail.spec.ts

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions apps/desktop/src/main/e2e-fixture.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -63,6 +65,10 @@ import {
longSidebarSessions,
longTranscriptMessages,
longTranscriptSession,
overflowingRailMessages,
overflowingRailSession,
shortFinalTurnMessages,
shortFinalTurnSession,
staleFakeMessages,
staleFakeSession,
turnControlSessions,
Expand DownExpand Up@@ -200,6 +206,13 @@ const E2E_FIXTURE_SCENARIOS = new Set<E2eFixtureScenario>([
// 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
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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
Expand Down
112 changes: 112 additions & 0 deletions apps/desktop/src/main/e2e-fixture/scenarios-sessions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/e2e-fixture/seed-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand Down
92 changes: 65 additions & 27 deletions apps/desktop/src/renderer/styles/prompt-rail.css
Original file line numberDiff line numberDiff line change
@@ -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;
Expand DownExpand Up@@ -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 {
Expand All@@ -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;
}
4 changes: 3 additions & 1 deletion apps/desktop/src/renderer/styles/quote-side-panel.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(ui): pin the prompt anchor rail to the Astryx chat scrollport by ARE404 · Pull Request #2161 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/desktop/e2e/fixtures.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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
Expand Down
337 changes: 337 additions & 0 deletions apps/desktop/e2e/prompt-rail.spec.ts

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions apps/desktop/src/main/e2e-fixture.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -63,6 +65,10 @@ import {
longSidebarSessions,
longTranscriptMessages,
longTranscriptSession,
overflowingRailMessages,
overflowingRailSession,
shortFinalTurnMessages,
shortFinalTurnSession,
staleFakeMessages,
staleFakeSession,
turnControlSessions,
Expand DownExpand Up@@ -200,6 +206,13 @@ const E2E_FIXTURE_SCENARIOS = new Set<E2eFixtureScenario>([
// 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
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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
Expand Down
112 changes: 112 additions & 0 deletions apps/desktop/src/main/e2e-fixture/scenarios-sessions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/e2e-fixture/seed-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand Down
92 changes: 65 additions & 27 deletions apps/desktop/src/renderer/styles/prompt-rail.css
Original file line numberDiff line numberDiff line change
@@ -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;
Expand DownExpand Up@@ -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 {
Expand All@@ -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;
}
4 changes: 3 additions & 1 deletion apps/desktop/src/renderer/styles/quote-side-panel.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(ui): pin the prompt anchor rail to the Astryx chat scrollport by ARE404 · Pull Request #2161 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/desktop/e2e/fixtures.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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
Expand Down
337 changes: 337 additions & 0 deletions apps/desktop/e2e/prompt-rail.spec.ts

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions apps/desktop/src/main/e2e-fixture.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -63,6 +65,10 @@ import {
longSidebarSessions,
longTranscriptMessages,
longTranscriptSession,
overflowingRailMessages,
overflowingRailSession,
shortFinalTurnMessages,
shortFinalTurnSession,
staleFakeMessages,
staleFakeSession,
turnControlSessions,
Expand DownExpand Up@@ -200,6 +206,13 @@ const E2E_FIXTURE_SCENARIOS = new Set<E2eFixtureScenario>([
// 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
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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
Expand Down
112 changes: 112 additions & 0 deletions apps/desktop/src/main/e2e-fixture/scenarios-sessions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/e2e-fixture/seed-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand Down
92 changes: 65 additions & 27 deletions apps/desktop/src/renderer/styles/prompt-rail.css
Original file line numberDiff line numberDiff line change
@@ -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;
Expand DownExpand Up@@ -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 {
Expand All@@ -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;
}
4 changes: 3 additions & 1 deletion apps/desktop/src/renderer/styles/quote-side-panel.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(ui): pin the prompt anchor rail to the Astryx chat scrollport by ARE404 · Pull Request #2161 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/desktop/e2e/fixtures.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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
Expand Down
337 changes: 337 additions & 0 deletions apps/desktop/e2e/prompt-rail.spec.ts

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions apps/desktop/src/main/e2e-fixture.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -63,6 +65,10 @@ import {
longSidebarSessions,
longTranscriptMessages,
longTranscriptSession,
overflowingRailMessages,
overflowingRailSession,
shortFinalTurnMessages,
shortFinalTurnSession,
staleFakeMessages,
staleFakeSession,
turnControlSessions,
Expand DownExpand Up@@ -200,6 +206,13 @@ const E2E_FIXTURE_SCENARIOS = new Set<E2eFixtureScenario>([
// 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
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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
Expand Down
112 changes: 112 additions & 0 deletions apps/desktop/src/main/e2e-fixture/scenarios-sessions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/e2e-fixture/seed-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand Down
92 changes: 65 additions & 27 deletions apps/desktop/src/renderer/styles/prompt-rail.css
Original file line numberDiff line numberDiff line change
@@ -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;
Expand DownExpand Up@@ -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 {
Expand All@@ -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;
}
4 changes: 3 additions & 1 deletion apps/desktop/src/renderer/styles/quote-side-panel.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(ui): pin the prompt anchor rail to the Astryx chat scrollport by ARE404 · Pull Request #2161 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/desktop/e2e/fixtures.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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
Expand Down
337 changes: 337 additions & 0 deletions apps/desktop/e2e/prompt-rail.spec.ts

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions apps/desktop/src/main/e2e-fixture.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -63,6 +65,10 @@ import {
longSidebarSessions,
longTranscriptMessages,
longTranscriptSession,
overflowingRailMessages,
overflowingRailSession,
shortFinalTurnMessages,
shortFinalTurnSession,
staleFakeMessages,
staleFakeSession,
turnControlSessions,
Expand DownExpand Up@@ -200,6 +206,13 @@ const E2E_FIXTURE_SCENARIOS = new Set<E2eFixtureScenario>([
// 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
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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
Expand Down
112 changes: 112 additions & 0 deletions apps/desktop/src/main/e2e-fixture/scenarios-sessions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/e2e-fixture/seed-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand Down
92 changes: 65 additions & 27 deletions apps/desktop/src/renderer/styles/prompt-rail.css
Original file line numberDiff line numberDiff line change
@@ -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;
Expand DownExpand Up@@ -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 {
Expand All@@ -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;
}
4 changes: 3 additions & 1 deletion apps/desktop/src/renderer/styles/quote-side-panel.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(ui): pin the prompt anchor rail to the Astryx chat scrollport by ARE404 · Pull Request #2161 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/desktop/e2e/fixtures.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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
Expand Down
337 changes: 337 additions & 0 deletions apps/desktop/e2e/prompt-rail.spec.ts

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions apps/desktop/src/main/e2e-fixture.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -63,6 +65,10 @@ import {
longSidebarSessions,
longTranscriptMessages,
longTranscriptSession,
overflowingRailMessages,
overflowingRailSession,
shortFinalTurnMessages,
shortFinalTurnSession,
staleFakeMessages,
staleFakeSession,
turnControlSessions,
Expand DownExpand Up@@ -200,6 +206,13 @@ const E2E_FIXTURE_SCENARIOS = new Set<E2eFixtureScenario>([
// 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
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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
Expand Down
112 changes: 112 additions & 0 deletions apps/desktop/src/main/e2e-fixture/scenarios-sessions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/e2e-fixture/seed-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand Down
92 changes: 65 additions & 27 deletions apps/desktop/src/renderer/styles/prompt-rail.css
Original file line numberDiff line numberDiff line change
@@ -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;
Expand DownExpand Up@@ -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 {
Expand All@@ -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;
}
4 changes: 3 additions & 1 deletion apps/desktop/src/renderer/styles/quote-side-panel.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(ui): pin the prompt anchor rail to the Astryx chat scrollport by ARE404 · Pull Request #2161 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/desktop/e2e/fixtures.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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
Expand Down
337 changes: 337 additions & 0 deletions apps/desktop/e2e/prompt-rail.spec.ts

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions apps/desktop/src/main/e2e-fixture.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -63,6 +65,10 @@ import {
longSidebarSessions,
longTranscriptMessages,
longTranscriptSession,
overflowingRailMessages,
overflowingRailSession,
shortFinalTurnMessages,
shortFinalTurnSession,
staleFakeMessages,
staleFakeSession,
turnControlSessions,
Expand DownExpand Up@@ -200,6 +206,13 @@ const E2E_FIXTURE_SCENARIOS = new Set<E2eFixtureScenario>([
// 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
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -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
Expand Down
112 changes: 112 additions & 0 deletions apps/desktop/src/main/e2e-fixture/scenarios-sessions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/e2e-fixture/seed-helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand Down
92 changes: 65 additions & 27 deletions apps/desktop/src/renderer/styles/prompt-rail.css
Original file line numberDiff line numberDiff line change
@@ -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;
Expand DownExpand Up@@ -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 {
Expand All@@ -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;
}
4 changes: 3 additions & 1 deletion apps/desktop/src/renderer/styles/quote-side-panel.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}

Expand Down
Loading
Loading