From 78e88807da8c9fc952c08650e6e16e89f9ed9325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 8 Jul 2026 23:55:52 -0400 Subject: [PATCH] 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 --- src/renderer/src/i18n/zh.ts | 2 + .../src/pages/chat/ChatConversation.tsx | 86 +++++++++++++++---- src/renderer/src/styles/components.css | 22 +++++ tests/e2e/desktop.spec.ts | 38 ++++++++ 4 files changed, 129 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 68911c59..ae373ba9 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -125,6 +125,8 @@ export const zh: Record = { "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.": "挥动鞭子:在茶水间或洗手间摸鱼的成员会赶回工位;正在工作或开会的成员不受影响。", diff --git a/src/renderer/src/pages/chat/ChatConversation.tsx b/src/renderer/src/pages/chat/ChatConversation.tsx index 28fbd75a..dc27245d 100644 --- a/src/renderer/src/pages/chat/ChatConversation.tsx +++ b/src/renderer/src/pages/chat/ChatConversation.tsx @@ -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(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 (
{space && } -
- {space && ( - +
+
+ {space && ( + + )} +
+ {!atBottom && ( + )}
{ 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; + } }).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();