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();