Skip to content

Commit baf1909

Browse files
authored
Merge origin/main into loop/command-palette-recents
2 parents 3ca23e8 + 3a4f438 commit baf1909

9 files changed

Lines changed: 224 additions & 1 deletion

File tree

apps/electron/src/renderer/components/app-shell/ChatDisplay.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,8 @@ export const ChatDisplay = React.forwardRef<ChatDisplayHandle, ChatDisplayProps>
584584
const [visibleTurnCount, setVisibleTurnCount] = React.useState(TURNS_PER_PAGE)
585585
// Sticky-bottom: When true, auto-scroll on content changes. Toggled by user scroll behavior.
586586
const isStickToBottomRef = React.useRef(true)
587+
// Show a floating "jump to latest" button when the user has scrolled far from the bottom.
588+
const [showScrollToBottom, setShowScrollToBottom] = React.useState(false)
587589
// Mirror isFocusedPanel into a ref so the ResizeObserver closure reads the latest value
588590
const isFocusedPanelRef = React.useRef(isFocusedPanel)
589591
isFocusedPanelRef.current = isFocusedPanel
@@ -1175,6 +1177,9 @@ export const ChatDisplay = React.forwardRef<ChatDisplayHandle, ChatDisplayProps>
11751177
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
11761178
// 20px threshold for "at bottom" detection
11771179
isStickToBottomRef.current = distanceFromBottom < 20
1180+
// Reveal the jump-to-latest button once the user is well away from the bottom
1181+
// (200px hysteresis so it doesn't flicker while reading near the end).
1182+
setShowScrollToBottom(distanceFromBottom > 200)
11781183

11791184
// Load more turns when scrolling near top (within 100px)
11801185
if (scrollTop < 100) {
@@ -1205,6 +1210,13 @@ export const ChatDisplay = React.forwardRef<ChatDisplayHandle, ChatDisplayProps>
12051210
return () => viewport.removeEventListener('scroll', handleScroll)
12061211
}, [handleScroll])
12071212

1213+
// Jump back to the latest message and resume sticky-bottom auto-scroll.
1214+
const scrollToBottom = React.useCallback(() => {
1215+
isStickToBottomRef.current = true
1216+
setShowScrollToBottom(false)
1217+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
1218+
}, [])
1219+
12081220
// Auto-scroll using ResizeObserver for streaming content
12091221
// Initial scroll is handled by ScrollOnMount (useLayoutEffect, before paint)
12101222
React.useEffect(() => {
@@ -1218,6 +1230,7 @@ export const ChatDisplay = React.forwardRef<ChatDisplayHandle, ChatDisplayProps>
12181230
if (isSessionSwitch) {
12191231
isStickToBottomRef.current = true
12201232
setVisibleTurnCount(TURNS_PER_PAGE)
1233+
setShowScrollToBottom(false)
12211234
}
12221235

12231236
// Debounced scroll for streaming - waits for layout to settle
@@ -1698,7 +1711,7 @@ export const ChatDisplay = React.forwardRef<ChatDisplayHandle, ChatDisplayProps>
16981711
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, black 32px, black calc(100% - 32px), transparent 100%)'
16991712
}}
17001713
>
1701-
<ScrollArea className="h-full min-w-0" viewportRef={scrollViewportRef}>
1714+
<ScrollArea className="h-full min-w-0" viewportRef={scrollViewportRef} data-testid="chat-transcript">
17021715
<div className={cn(
17031716
CHAT_LAYOUT.maxWidth,
17041717
"mx-auto min-w-0",
@@ -2073,6 +2086,32 @@ export const ChatDisplay = React.forwardRef<ChatDisplayHandle, ChatDisplayProps>
20732086
</div>
20742087
</ScrollArea>
20752088
</div>
2089+
2090+
{/* Jump-to-latest: floating button shown when scrolled up from the bottom */}
2091+
<AnimatePresence>
2092+
{showScrollToBottom && (
2093+
<motion.button
2094+
type="button"
2095+
onClick={scrollToBottom}
2096+
aria-label={t('chat.scrollToBottom')}
2097+
title={t('chat.scrollToBottom')}
2098+
data-testid="scroll-to-bottom"
2099+
initial={{ opacity: 0, y: 8 }}
2100+
animate={{ opacity: 1, y: 0 }}
2101+
exit={{ opacity: 0, y: 8 }}
2102+
transition={{ duration: 0.12, ease: 'easeOut' }}
2103+
className={cn(
2104+
"absolute bottom-4 left-1/2 -translate-x-1/2 z-10",
2105+
"flex items-center justify-center h-9 w-9 rounded-full",
2106+
"bg-background/90 backdrop-blur border border-border shadow-md",
2107+
"text-muted-foreground hover:text-foreground hover:bg-accent",
2108+
"transition-colors"
2109+
)}
2110+
>
2111+
<ChevronDown className="h-5 w-5" />
2112+
</motion.button>
2113+
)}
2114+
</AnimatePresence>
20762115
</div>
20772116

20782117
{/* === INPUT CONTAINER: FreeForm or Structured Input === */}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/**
2+
* Feature assertion: the chat transcript's "jump to latest" (scroll-to-bottom)
3+
* button.
4+
*
5+
* Drives the real built app over CDP through the full path:
6+
* seed a 40-message session on disk → open it → transcript renders at the
7+
* bottom with the button hidden → scroll up → the floating button appears →
8+
* click it → the transcript returns to the bottom and the button disappears.
9+
*
10+
* The final steps prove the button actually *scrolls* the transcript back to
11+
* the latest message, not merely that it renders.
12+
*
13+
* A backend is NOT required: the session is pre-seeded as a plain `session.jsonl`
14+
* file under the isolated profile's default-workspace root before the app boots,
15+
* so the transcript is real, local, and scrollable without invoking qwen-code.
16+
*/
17+
18+
import { mkdirSync, writeFileSync } from 'node:fs';
19+
import { join } from 'node:path';
20+
import type { Assertion } from '../runner';
21+
import type { ProfileDirs } from '../app';
22+
23+
const SESSION_ID = 'e2e-scroll-seeded';
24+
const SESSION_NAME = 'Scroll seed';
25+
const MESSAGE_COUNT = 40;
26+
27+
const ROW = `[data-session-id="${SESSION_ID}"]`;
28+
const BUTTON = '[data-testid="scroll-to-bottom"]';
29+
30+
/** The scrollable transcript viewport (Radix ScrollArea viewport inside our tagged region). */
31+
const VIEWPORT = `document.querySelector('[data-testid="chat-transcript"] [data-radix-scroll-area-viewport]')`;
32+
33+
/** distanceFromBottom of the transcript viewport, or -1 when it isn't mounted yet. */
34+
const DISTANCE_EXPR = `(() => {
35+
const v = ${VIEWPORT};
36+
if (!v) return -1;
37+
return v.scrollHeight - v.scrollTop - v.clientHeight;
38+
})()`;
39+
40+
/**
41+
* Pre-seed a session with many messages so the transcript overflows the
42+
* viewport. Written before Electron launches; the app lists it on boot.
43+
*/
44+
function seed({ workspaceDir }: ProfileDirs): void {
45+
const sessionDir = join(workspaceDir, 'sessions', SESSION_ID);
46+
mkdirSync(sessionDir, { recursive: true });
47+
48+
const base = 1700000000000;
49+
const header = {
50+
id: SESSION_ID,
51+
workspaceRootPath: workspaceDir,
52+
name: SESSION_NAME,
53+
createdAt: base,
54+
lastUsedAt: base + MESSAGE_COUNT,
55+
lastMessageAt: base + MESSAGE_COUNT,
56+
sessionStatus: 'todo',
57+
messageCount: MESSAGE_COUNT,
58+
lastMessageRole: 'assistant',
59+
preview: 'Seeded conversation for scroll-to-bottom testing',
60+
};
61+
62+
const lines = [JSON.stringify(header)];
63+
for (let i = 1; i <= MESSAGE_COUNT; i++) {
64+
const isUser = i % 2 === 1;
65+
// Long, multi-paragraph body so 40 messages reliably overflow the viewport.
66+
const body =
67+
`${isUser ? 'User' : 'Assistant'} message ${i}.\n\n` +
68+
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(6);
69+
const msg: Record<string, unknown> = {
70+
id: `m${i}`,
71+
type: isUser ? 'user' : 'assistant',
72+
content: body,
73+
timestamp: base + i,
74+
};
75+
if (!isUser) msg.turnId = `t${i}`;
76+
lines.push(JSON.stringify(msg));
77+
}
78+
writeFileSync(join(sessionDir, 'session.jsonl'), lines.join('\n') + '\n', 'utf-8');
79+
}
80+
81+
const assertion: Assertion = {
82+
name: 'chat scroll-to-bottom button appears when scrolled up and jumps to latest',
83+
seed,
84+
async run(app) {
85+
const { session } = app;
86+
87+
// App fully mounted and at the ready AppShell (same anchors other assertions use).
88+
await session.waitForFunction(
89+
'!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0',
90+
{ timeoutMs: 30000, message: 'React UI did not mount' },
91+
);
92+
await session.waitForSelector('[aria-label="Craft menu"]', {
93+
timeoutMs: 30000,
94+
message: 'app did not reach the ready AppShell state',
95+
});
96+
97+
// 1. The seeded session appears in the sidebar list.
98+
await session.waitForSelector(ROW, {
99+
timeoutMs: 20000,
100+
message: 'seeded session row did not appear in the session list',
101+
});
102+
103+
// 2. Open it. The row selects on `mousedown` (not click), so dispatch a real
104+
// left-button pointer press on the row's button.
105+
const opened = await session.evaluate<boolean>(`(() => {
106+
const btn = document.querySelector(${JSON.stringify(ROW)} + ' button.entity-row-btn')
107+
|| document.querySelector(${JSON.stringify(ROW)} + ' button')
108+
|| document.querySelector(${JSON.stringify(ROW)});
109+
if (!btn) return false;
110+
btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }));
111+
btn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 }));
112+
return true;
113+
})()`);
114+
if (!opened) throw new Error('could not find/press the seeded session row');
115+
116+
// 3. The transcript renders and overflows the viewport (proof the seeded
117+
// conversation actually loaded, not the empty/draft state).
118+
await session.waitForFunction(
119+
`(() => { const v = ${VIEWPORT}; return !!v && v.clientHeight > 0 && v.scrollHeight > v.clientHeight + 200; })()`,
120+
{ timeoutMs: 20000, message: 'seeded transcript did not render as a scrollable viewport' },
121+
);
122+
123+
// 4. On open the transcript sticks to the bottom, so the jump button is hidden.
124+
await session.waitForFunction(`${DISTANCE_EXPR} >= 0 && ${DISTANCE_EXPR} < 40`, {
125+
timeoutMs: 8000,
126+
message: 'transcript did not settle at the bottom on open',
127+
});
128+
const buttonVisibleAtBottom = await session.evaluate<boolean>(
129+
`!!document.querySelector(${JSON.stringify(BUTTON)})`,
130+
);
131+
if (buttonVisibleAtBottom) {
132+
throw new Error('scroll-to-bottom button was visible while already at the bottom');
133+
}
134+
135+
// 5. Scroll to the top. Setting scrollTop fires a scroll event; also dispatch
136+
// one explicitly so the handler recomputes regardless.
137+
await session.evaluate(`(() => {
138+
const v = ${VIEWPORT};
139+
if (!v) return false;
140+
v.scrollTop = 0;
141+
v.dispatchEvent(new Event('scroll', { bubbles: true }));
142+
return true;
143+
})()`);
144+
145+
// 6. Now well away from the bottom, the button appears.
146+
await session.waitForFunction(`${DISTANCE_EXPR} > 200`, {
147+
timeoutMs: 5000,
148+
message: 'scrolling to top did not move the viewport away from the bottom',
149+
});
150+
await session.waitForSelector(BUTTON, {
151+
timeoutMs: 5000,
152+
message: 'scroll-to-bottom button did not appear after scrolling up',
153+
});
154+
155+
// 7. Click it — it must scroll the transcript back to the latest message...
156+
const clicked = await session.evaluate<boolean>(`(() => {
157+
const btn = document.querySelector(${JSON.stringify(BUTTON)});
158+
if (!btn) return false;
159+
btn.click();
160+
return true;
161+
})()`);
162+
if (!clicked) throw new Error('could not click the scroll-to-bottom button');
163+
164+
await session.waitForFunction(`${DISTANCE_EXPR} >= 0 && ${DISTANCE_EXPR} < 40`, {
165+
timeoutMs: 8000,
166+
message: 'clicking the button did not return the transcript to the bottom',
167+
});
168+
169+
// 8. ...and hide itself again once back at the bottom.
170+
await session.waitForFunction(`!document.querySelector(${JSON.stringify(BUTTON)})`, {
171+
timeoutMs: 5000,
172+
message: 'scroll-to-bottom button did not disappear after returning to the bottom',
173+
});
174+
},
175+
};
176+
177+
export default assertion;

packages/shared/src/i18n/locales/de.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@
215215
"chat.processing.zipping": "Flitzt...",
216216
"chat.processing.zooming": "Rast...",
217217
"chat.renameSession": "Sitzung umbenennen",
218+
"chat.scrollToBottom": "Nach unten scrollen",
218219
"chat.scrollUpForEarlier": "Nach oben scrollen für ältere Nachrichten ({{count}} weitere)",
219220
"chat.selectedText": "Ausgewählter Text",
220221
"chat.session": "Sitzung",

packages/shared/src/i18n/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@
215215
"chat.processing.zipping": "Zipping...",
216216
"chat.processing.zooming": "Zooming...",
217217
"chat.renameSession": "Rename Session",
218+
"chat.scrollToBottom": "Scroll to bottom",
218219
"chat.scrollUpForEarlier": "Scroll up for earlier messages ({{count}} more)",
219220
"chat.selectedText": "Selected text",
220221
"chat.session": "Session",

packages/shared/src/i18n/locales/es.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@
215215
"chat.processing.zipping": "Volando...",
216216
"chat.processing.zooming": "¡Zoom!...",
217217
"chat.renameSession": "Renombrar sesión",
218+
"chat.scrollToBottom": "Desplazarse al final",
218219
"chat.scrollUpForEarlier": "Sube para ver mensajes anteriores ({{count}} más)",
219220
"chat.selectedText": "Texto seleccionado",
220221
"chat.session": "Sesión",

packages/shared/src/i18n/locales/hu.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@
215215
"chat.processing.zipping": "Száguldok...",
216216
"chat.processing.zooming": "Zoom...",
217217
"chat.renameSession": "Munkamenet átnevezése",
218+
"chat.scrollToBottom": "Görgetés az aljára",
218219
"chat.scrollUpForEarlier": "Görgess fel a korábbi üzenetekért (még {{count}})",
219220
"chat.selectedText": "Kijelölt szöveg",
220221
"chat.session": "Munkamenet",

packages/shared/src/i18n/locales/ja.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@
215215
"chat.processing.zipping": "シュッシュッ...",
216216
"chat.processing.zooming": "ズーム中...",
217217
"chat.renameSession": "セッション名を変更",
218+
"chat.scrollToBottom": "最下部へスクロール",
218219
"chat.scrollUpForEarlier": "上にスクロールして以前のメッセージを表示 (あと {{count}} 件)",
219220
"chat.selectedText": "選択テキスト",
220221
"chat.session": "セッション",

packages/shared/src/i18n/locales/pl.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@
215215
"chat.processing.zipping": "Gnam...",
216216
"chat.processing.zooming": "Pędzę...",
217217
"chat.renameSession": "Zmień nazwę sesji",
218+
"chat.scrollToBottom": "Przewiń na dół",
218219
"chat.scrollUpForEarlier": "Przewiń w górę, aby zobaczyć wcześniejsze wiadomości (jeszcze {{count}})",
219220
"chat.selectedText": "Zaznaczony tekst",
220221
"chat.session": "Sesja",

packages/shared/src/i18n/locales/zh-Hans.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@
215215
"chat.processing.zipping": "飞速中...",
216216
"chat.processing.zooming": "疾驰中...",
217217
"chat.renameSession": "重命名会话",
218+
"chat.scrollToBottom": "滚动到底部",
218219
"chat.scrollUpForEarlier": "向上滚动查看更早的消息 (还有 {{count}} 条)",
219220
"chat.selectedText": "选中文本",
220221
"chat.session": "会话",

0 commit comments

Comments
 (0)