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:
ZacharyZhang-NY committed 2026-07-08 23:55:52 -04:00
1 parent 9834714fa6
commit 78e88807da
4 files changed
+129 -19

No files matched your search

+2
View File
@@ -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
+22
View File
@@ -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); }
+38
View File
@@ -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();