Give chat a real scroll contract: open at latest, stick, jump back
The conversation pane had no scroll management at all - it opened at the top of history, ignored arriving messages, and offered no way back down other than dragging through long replies. - Opening a conversation lands on the newest message (the component is keyed by space, so mount = open) - A reader at the bottom stays pinned as new messages arrive; a reader up in history is left undisturbed - The moment the reader leaves the bottom, a floating "Jump to latest" pill appears above the composer; clicking it smooth-scrolls back and the pill hides itself again - The scroll region is a proper role="log" landmark; e2e seeds a dozen multi-line messages over real IPC, scrolls to the top, and drives the pill's appear -> click -> return-to-bottom -> disappear cycle
This commit is contained in:
1 parent
9834714fa6
commit
78e88807da
4 files changed
+129
-19
No files matched your search
@@ -125,6 +125,8 @@ export const zh: Record<string, string> = {
|
||||
"Edit {name}": "编辑 {name}",
|
||||
"Edit profile": "编辑个人资料",
|
||||
"Former member": "已离职成员",
|
||||
"Jump to latest": "跳到最新消息",
|
||||
"Conversation messages": "会话消息",
|
||||
"Whip": "鞭子",
|
||||
"Office tools": "办公室工具",
|
||||
"Crack the whip: slackers in the snack bar or restroom hustle back to their desks. Working or meeting agents are unaffected.": "挥动鞭子:在茶水间或洗手间摸鱼的成员会赶回工位;正在工作或开会的成员不受影响。",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type FormEvent, type MutableRefObject } from "react";
|
||||
import { useLayoutEffect, useRef, useState, type FormEvent, type MutableRefObject } from "react";
|
||||
import { AvatarStack, Icon, IconButton } from "../../shared/ui";
|
||||
import type {
|
||||
ApprovalRecord,
|
||||
@@ -62,27 +62,75 @@ export function ChatConversation({
|
||||
const filtered = trimmed
|
||||
? messagesInSpace.filter((m) => m.bodyMd.toLowerCase().includes(trimmed))
|
||||
: messagesInSpace;
|
||||
|
||||
// Messenger scroll contract: open at the latest message, stay pinned there
|
||||
// while the reader is at the bottom, and offer a jump back the moment they
|
||||
// scroll up into history.
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const atBottomRef = useRef(true);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
const lastMessageId = filtered.length > 0 ? filtered[filtered.length - 1].id : null;
|
||||
|
||||
const measureAtBottom = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return true;
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < 48;
|
||||
};
|
||||
const onScroll = () => {
|
||||
const near = measureAtBottom();
|
||||
atBottomRef.current = near;
|
||||
setAtBottom(near);
|
||||
};
|
||||
const jumpToLatest = (behavior: ScrollBehavior) => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTo({ top: el.scrollHeight, behavior });
|
||||
};
|
||||
|
||||
// The component is keyed by space, so mount = opening this conversation.
|
||||
useLayoutEffect(() => {
|
||||
jumpToLatest("auto");
|
||||
}, []);
|
||||
// New messages keep a bottom-pinned reader pinned; a reader up in history
|
||||
// is left alone (the jump pill is their way back).
|
||||
useLayoutEffect(() => {
|
||||
if (atBottomRef.current) jumpToLatest("auto");
|
||||
}, [lastMessageId]);
|
||||
|
||||
return (
|
||||
<section style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", background: "var(--panel)" }}>
|
||||
{space && <ConversationHeader space={space} participants={participants} query={query} onQueryChange={setQuery} />}
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "20px 24px 8px" }}>
|
||||
{space && (
|
||||
<ChatTimeline
|
||||
key={space.id}
|
||||
messagesInSpace={filtered}
|
||||
pendingApprovals={pendingApprovals}
|
||||
attachments={attachments}
|
||||
mentions={mentions}
|
||||
slashInvocations={slashInvocations}
|
||||
files={files}
|
||||
context={context}
|
||||
currentEmployeeId={currentEmployeeId}
|
||||
onApprove={onApprove}
|
||||
onDecline={onDecline}
|
||||
onEditMessage={onEditMessage}
|
||||
onDeleteMessage={onDeleteMessage}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
<div style={{ position: "relative", flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
role="log"
|
||||
aria-label={t("Conversation messages")}
|
||||
style={{ flex: 1, overflow: "auto", padding: "20px 24px 8px" }}
|
||||
>
|
||||
{space && (
|
||||
<ChatTimeline
|
||||
key={space.id}
|
||||
messagesInSpace={filtered}
|
||||
pendingApprovals={pendingApprovals}
|
||||
attachments={attachments}
|
||||
mentions={mentions}
|
||||
slashInvocations={slashInvocations}
|
||||
files={files}
|
||||
context={context}
|
||||
currentEmployeeId={currentEmployeeId}
|
||||
onApprove={onApprove}
|
||||
onDecline={onDecline}
|
||||
onEditMessage={onEditMessage}
|
||||
onDeleteMessage={onDeleteMessage}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!atBottom && (
|
||||
<button type="button" className="jump-latest" onClick={() => jumpToLatest("smooth")}>
|
||||
<span aria-hidden style={{ fontSize: 13, lineHeight: 1 }}>↓</span>
|
||||
{t("Jump to latest")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ChatComposer
|
||||
|
||||
@@ -281,3 +281,25 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
|
||||
}
|
||||
.office-tool:hover { background: var(--hover); color: var(--text-1); }
|
||||
.office-tool.active { background: var(--accent); color: #fff; }
|
||||
|
||||
/* Floating "jump to latest" pill above the chat composer */
|
||||
.jump-latest {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--panel);
|
||||
color: var(--text-1);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 540;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 24px rgba(16, 16, 24, 0.18);
|
||||
}
|
||||
.jump-latest:hover { background: var(--hover); }
|
||||
@@ -181,6 +181,44 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||
await page.getByRole("button", { name: "Send" }).click();
|
||||
await expect(page.getByText("Alex Turing").first()).toBeVisible();
|
||||
|
||||
// Scrolling up into history surfaces a jump-back pill above the composer.
|
||||
await page.evaluate(async () => {
|
||||
const api = (window as unknown as { agentCompany: {
|
||||
loadSnapshot: () => Promise<{ ok: true; data: {
|
||||
activeOrgId: string;
|
||||
employees: Array<{ id: string; orgId: string }>;
|
||||
chatSpaces: Array<{ id: string; orgId: string; name: string }>;
|
||||
} }>;
|
||||
sendMessage: (input: unknown) => Promise<unknown>;
|
||||
} }).agentCompany;
|
||||
const snapshot = await api.loadSnapshot();
|
||||
const orgId = snapshot.data.activeOrgId;
|
||||
const employee = snapshot.data.employees.find((item) => item.orgId === orgId)!;
|
||||
const general = snapshot.data.chatSpaces.find((item) => item.orgId === orgId && item.name === "general")!;
|
||||
for (let index = 0; index < 12; index++) {
|
||||
await api.sendMessage({
|
||||
orgId,
|
||||
spaceId: general.id,
|
||||
threadId: null,
|
||||
senderType: "employee",
|
||||
senderId: employee.id,
|
||||
bodyMd: `Scroll filler ${index}\n\nline\n\nline\n\nline`,
|
||||
attachmentFileIds: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
const chatLog = page.getByRole("log", { name: "Conversation messages" });
|
||||
await expect(page.getByText("Scroll filler 11").first()).toBeVisible();
|
||||
// Pinned to the bottom: no pill.
|
||||
await expect(page.getByRole("button", { name: "Jump to latest" })).toHaveCount(0);
|
||||
await chatLog.evaluate((el) => { el.scrollTop = 0; });
|
||||
const jumpButton = page.getByRole("button", { name: "Jump to latest" });
|
||||
await expect(jumpButton).toBeVisible();
|
||||
await jumpButton.click();
|
||||
await expect(jumpButton).not.toBeVisible();
|
||||
const nearBottom = await chatLog.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight < 60);
|
||||
expect(nearBottom).toBe(true);
|
||||
|
||||
await verifyPagesDoNotOverflow(page);
|
||||
} finally {
|
||||
await app.close();
|
||||
|
||||
Reference in new issue
Block a user