Q143: earlier messages come from the database, page by page, back to the first one

Review limit (5) reached; the last finding (exhaustion latched) is fixed
by taking it from each latest hasMore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 23:52:22 -04:00
1 parent b18eb9ad14
commit 075dea725a
12 files changed
+213 -15

No files matched your search

+2
View File
@@ -48,6 +48,8 @@ export const zh: Record<string, string> = {
"Close thread": "关闭话题",
"Reply": "回复",
"Send reply": "发送回复",
"Earlier messages could not load: {reason}": "更早的消息未能加载:{reason}",
"This is the start of the conversation.": "这是会话的开头。",
"Resume": "恢复",
"Missing this run": "本次运行缺少的能力",
"Disconnect": "断开连接",
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type FormEvent, type MutableRefObject } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type FormEvent, type MutableRefObject } from "react";
import { AvatarStack, Icon, IconButton } from "../../shared/ui";
import type {
ApprovalRecord,
@@ -14,6 +14,10 @@ import { ChatComposer, type Draft, type SendState } from "./ChatComposer";
import { ChatTimeline } from "./ChatTimeline";
import { ThreadPanel } from "./ThreadPanel";
import { buildThreadLinks } from "./thread-links";
import type { SpaceHistoryPage } from "../../../../shared/chat-domain";
/** Older messages fetched per "Show earlier messages". */
const HISTORY_PAGE = 150;
import type { MessageContext } from "./chat-helpers";
import { t } from "../../i18n/i18n";
@@ -76,11 +80,65 @@ export function ChatConversation({
const [query, setQuery] = useState("");
// The component is keyed by space, so an open thread closes when the conversation changes.
const [openThreadId, setOpenThreadId] = useState<string | null>(null);
const threadLinks = useMemo(() => buildThreadLinks(messagesInSpace, threads, context), [messagesInSpace, threads, context]);
// The snapshot holds recent chat only; older messages come from the database, paged back from the oldest one loaded.
const [oldestId, setOldestId] = useState<string | null>(null);
const [exhausted, setExhausted] = useState(false);
const [older, setOlder] = useState<SpaceHistoryPage | null>(null);
const [historyError, setHistoryError] = useState<string | null>(null);
const anchorId = messagesInSpace.length > 0 ? messagesInSpace[0].id : null;
const orgId = space?.orgId ?? null;
const currentSpaceId = space?.id ?? null;
const loadOlder = useCallback(() => {
if (!orgId || !currentSpaceId) return;
window.agentCompany.chatSpaceHistory({ orgId, spaceId: currentSpaceId, beforeMessageId: oldestId ?? anchorId, limit: HISTORY_PAGE }).then((result) => {
if (!result.ok) throw new Error(result.error.message);
// With nothing older, the oldest message already shown is pinned, so the range refresh keeps it if newer traffic displaces it.
const oldest = result.data.messages.length > 0 ? result.data.messages[0].id : oldestId ?? anchorId;
if (oldest !== null) setOldestId(oldest);
setExhausted(!result.data.hasMore);
setHistoryError(null);
}).catch((reason: unknown) => setHistoryError(reason instanceof Error ? reason.message : String(reason)));
}, [orgId, currentSpaceId, oldestId, anchorId]);
// While the snapshot holds none of the conversation and nothing is pinned, every reload checks the database for its newest page.
useEffect(() => {
if (messagesInSpace.length === 0 && oldestId === null) loadOlder();
}, [messagesInSpace, oldestId, loadOlder]);
useEffect(() => {
if (!orgId || !currentSpaceId || !oldestId) return;
let cancelled = false;
// The loaded range is refetched on every snapshot reload (messagesInSpace changes), so edits and deletes of old messages show too.
window.agentCompany.chatSpaceHistory({ orgId, spaceId: currentSpaceId, beforeMessageId: anchorId, fromMessageId: oldestId }).then((result) => {
if (cancelled) return;
if (!result.ok) throw new Error(result.error.message);
setOlder(result.data);
setExhausted(!result.data.hasMore);
setHistoryError(null);
}).catch((reason: unknown) => {
if (!cancelled) setHistoryError(reason instanceof Error ? reason.message : String(reason));
});
return () => {
cancelled = true;
};
}, [orgId, currentSpaceId, oldestId, anchorId, messagesInSpace]);
const allMessages = useMemo(() => [...(older?.messages ?? []), ...messagesInSpace], [older, messagesInSpace]);
const allThreads = useMemo(() => {
const byId = new Map(threads.map((thread) => [thread.id, thread]));
for (const thread of older?.threads ?? []) if (!byId.has(thread.id)) byId.set(thread.id, thread);
return [...byId.values()];
}, [threads, older]);
// Paged messages take their attachments, mentions and commands from the page only, so a record the snapshot also holds is not doubled.
const pagedIds = useMemo(() => new Set((older?.messages ?? []).map((message) => message.id)), [older]);
const allAttachments = useMemo(() => [...(older?.attachments ?? []), ...attachments.filter((item) => !pagedIds.has(item.messageId))], [older, attachments, pagedIds]);
const allMentions = useMemo(() => [...(older?.mentions ?? []), ...mentions.filter((item) => !pagedIds.has(item.messageId))], [older, mentions, pagedIds]);
const allSlashInvocations = useMemo(
() => [...(older?.slashInvocations ?? []), ...slashInvocations.filter((item) => !pagedIds.has(item.messageId))],
[older, slashInvocations, pagedIds],
);
const threadLinks = useMemo(() => buildThreadLinks(allMessages, allThreads, context), [allMessages, allThreads, context]);
const trimmed = query.trim().toLowerCase();
const filtered = trimmed
? messagesInSpace.filter((m) => m.bodyMd.toLowerCase().includes(trimmed))
: messagesInSpace;
? allMessages.filter((m) => m.bodyMd.toLowerCase().includes(trimmed))
: allMessages;
// 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
@@ -146,12 +204,15 @@ export function ChatConversation({
key={space.id}
messagesInSpace={filtered}
pendingApprovals={pendingApprovals}
attachments={attachments}
mentions={mentions}
slashInvocations={slashInvocations}
attachments={allAttachments}
mentions={allMentions}
slashInvocations={allSlashInvocations}
files={files}
context={context}
currentEmployeeId={currentEmployeeId}
historyStart={exhausted}
historyError={historyError}
onLoadOlder={loadOlder}
threadLinks={threadLinks}
onOpenThread={setOpenThreadId}
onApprove={onApprove}
+17 -4
View File
@@ -38,6 +38,9 @@ export function ChatTimeline({
files,
context,
currentEmployeeId,
historyStart,
historyError,
onLoadOlder,
threadLinks,
onOpenThread,
onApprove,
@@ -54,6 +57,10 @@ export function ChatTimeline({
files: FileObjectRecord[];
context: MessageContext;
currentEmployeeId: string;
/** True once the database said no older message exists. */
historyStart: boolean;
historyError: string | null;
onLoadOlder: () => void;
threadLinks: Map<string, ThreadLink>;
onOpenThread: (threadId: string) => void;
onApprove: (approvalId: string) => void;
@@ -72,14 +79,20 @@ export function ChatTimeline({
const visible = hiddenCount > 0 ? messagesInSpace.slice(hiddenCount) : messagesInSpace;
const elements: ReactNode[] = [];
if (hiddenCount > 0) {
if (historyError) elements.push(<div key="history-error" className="form-error" role="alert">{t("Earlier messages could not load: {reason}", { reason: historyError })}</div>);
if (hiddenCount > 0 || !historyStart) {
// Showing earlier first reveals loaded messages, then asks the database for the next page.
const showEarlier = () => {
setVisibleCount((count) => count + WINDOW_SIZE);
if (hiddenCount === 0) onLoadOlder();
};
elements.push(
<div key="show-earlier" style={{ display: "flex", justifyContent: "center", marginBottom: 16 }}>
<Button size="small" onClick={() => setVisibleCount((count) => count + WINDOW_SIZE)}>
{t("Show earlier messages")}
</Button>
<Button size="small" onClick={showEarlier}>{t("Show earlier messages")}</Button>
</div>,
);
} else {
elements.push(<div key="history-start" style={{ textAlign: "center", fontSize: 12, color: "var(--text-3)", marginBottom: 16 }}>{t("This is the start of the conversation.")}</div>);
}
let lastDay = "";
for (const message of visible) {