fix(chat): 修复会话刷新丢失最终答案(#3) + 反向分页/详情懒加载/会话URL持久化

- 后端消息列表默认按 id 升序取最旧一页,长会话刷新后最终答案落在页外而丢失
  (发新消息推进游标才重现)。改为:
  - db.ConvActivityPage:反向分页,取最新/更早一页 + hasMore
  - 消息接口按 since(增量)/before(更早页)/最新页 三种模式返回
- 前端聊天页:
  - 打开会话仅加载最新一页,滚动到顶部再反向加载更早(保持滚动位置不跳)
  - Transcript 的 UserRow/AnswerBlock 改为进入视口才拉 detail,避免打开会话即对
    整页每步发详情请求
  - 选中会话 id 持久化到 URL(?c=<id>),刷新回到当前会话
  - 修复新建会话后被"清理失效 id"effect 误清导致停留在新建页、输入框内容与灰色
    发送按钮卡死:移除该 effect + onStarted 乐观插入

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Autumn-27andClaude Opus 5 committed 2026-08-07 11:45:33 -04:00
1 parent 349b0d104d
commit 5000aadd8f
5 files changed
+207 -17

No files matched your search

+87 -7
View File
@@ -54,6 +54,11 @@ function fmtDuration(ms: number): string {
return `${h}h${String(m % 60).padStart(2, "0")}m`;
}
// HISTORY_PAGE is how many steps one history page loads: the latest page on open,
// then one more page each time the user scrolls to the top. Kept modest so a long
// thread stays snappy (only ~a page of rows is in the DOM until you scroll up).
const HISTORY_PAGE = 200;
// LiveBadge is the small pulsing "实时" chip reused from the task's main-agent
// console — shown while a turn is streaming.
function LiveBadge() {
@@ -307,7 +312,11 @@ function ChatView({
const [input, setInput] = React.useState("");
const [sending, setSending] = React.useState(false);
const [stopping, setStopping] = React.useState(false);
const cursorRef = React.useRef(0);
const cursorRef = React.useRef(0); // newest loaded id — incremental-tail anchor
const earliestRef = React.useRef(0); // earliest loaded id — reverse-pagination anchor
const hasMoreRef = React.useRef(false); // older history remains above the loaded window
const loadingMoreRef = React.useRef(false); // guard: one scroll-up load at a time
const [hasMore, setHasMore] = React.useState(false); // drives the "load earlier" hint
const agent = agents.find((a) => a.key === conv.agent_key);
const currentProfileId = conv.llm_profile_id ?? null;
@@ -335,18 +344,28 @@ function ChatView({
return null;
}, [messages]);
// reset + load whenever the selected conversation changes.
// reset + load whenever the selected conversation changes. Load only the LATEST
// page on open — a long thread's final answer sits at the very end, so the newest
// page shows it immediately (issue #3: opening/refresh used to load the oldest
// page, so the completed result was missing until another message advanced the
// cursor). Older history streams in on scroll-up (loadEarlier below).
React.useEffect(() => {
cursorRef.current = 0;
earliestRef.current = 0;
hasMoreRef.current = false;
setHasMore(false);
setMessages([]);
setRunning(false);
let live = true;
api
.conversationMessages(conv.id, 0)
.conversationHistory(conv.id, 0, HISTORY_PAGE)
.then((r) => {
if (!live) return;
setMessages(r.items);
cursorRef.current = r.cursor;
cursorRef.current = r.cursor; // newest id → incremental-tail anchor
earliestRef.current = r.items.length ? r.items[0].seq : 0;
hasMoreRef.current = r.hasMore;
setHasMore(r.hasMore);
setRunning(r.running);
})
.catch(() => {});
@@ -389,15 +408,46 @@ function ChatView({
null,
[],
);
// Scroll-up loads one older page and prepends it, preserving the visual position
// so the view doesn't jump (record height/offset before, restore the delta after).
const loadEarlier = React.useCallback(async () => {
if (loadingMoreRef.current || !hasMoreRef.current) return;
const vp = viewport();
if (!vp) return;
loadingMoreRef.current = true;
const prevH = vp.scrollHeight;
const prevTop = vp.scrollTop;
try {
const r = await api.conversationHistory(conv.id, earliestRef.current, HISTORY_PAGE);
if (r.items.length) {
setMessages((prev) => {
const seen = new Set(prev.map((a) => a.seq));
return [...r.items.filter((a) => !seen.has(a.seq)), ...prev];
});
earliestRef.current = r.items[0].seq;
}
hasMoreRef.current = r.hasMore;
setHasMore(r.hasMore);
requestAnimationFrame(() => {
const v = viewport();
if (v) v.scrollTop = prevTop + (v.scrollHeight - prevH);
});
} catch {
/* transient — a later scroll retries */
} finally {
loadingMoreRef.current = false;
}
}, [conv.id, viewport]);
React.useEffect(() => {
const vp = viewport();
if (!vp) return;
const onScroll = () => {
atBottomRef.current = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 60;
if (vp.scrollTop <= 80) void loadEarlier(); // near top → pull an older page
};
vp.addEventListener("scroll", onScroll, { passive: true });
return () => vp.removeEventListener("scroll", onScroll);
}, [viewport]);
}, [viewport, loadEarlier]);
// open/switch a conversation → jump to the latest (bottom)
React.useLayoutEffect(() => {
const vp = viewport();
@@ -512,7 +562,14 @@ function ChatView({
开始和「{agent?.name ?? conv.agent_key}」对话
</div>
) : (
<Transcript activity={messages} live={running} chat fetchDetail={fetchDetail} />
<>
{hasMore && (
<div className="text-muted-foreground/70 pb-2 text-center text-[11px]">
向上滚动加载更早的消息…
</div>
)}
<Transcript activity={messages} live={running} chat fetchDetail={fetchDetail} />
</>
)}
</div>
</ScrollArea>
@@ -642,6 +699,25 @@ export default function ChatPage() {
reloadConvs();
}, [reloadConvs]);
// Restore the open conversation from the URL (?c=<id>) on mount, so a refresh
// returns to the same thread instead of the empty draft view. Runs after
// hydration (not a lazy useState init) to avoid a server/client mismatch.
React.useEffect(() => {
const c = new URLSearchParams(window.location.search).get("c");
const id = c ? Number(c) : NaN;
if (Number.isFinite(id)) setSelectedId(id);
}, []);
// Mirror the current selection into the URL (replaceState → no history spam). A
// selectedId with no matching conversation (e.g. a stale ?c=, or a just-created
// one before reloadConvs lands) simply renders the draft view — harmless — so we
// deliberately do NOT auto-clear it here (that raced new-conversation creation).
React.useEffect(() => {
const url = new URL(window.location.href);
if (selectedId != null) url.searchParams.set("c", String(selectedId));
else url.searchParams.delete("c");
window.history.replaceState(null, "", url);
}, [selectedId]);
const selected = convs.find((c) => c.id === selectedId) ?? null;
// conversation agents: custom agents + conversational built-ins (role=assistant,
// e.g. Auto / 渗透测试). The orchestration built-ins (goals/planner/mainagent/worker)
@@ -728,8 +804,12 @@ export default function ChatPage() {
agents={chatAgents}
profiles={profiles}
onStarted={(c) => {
reloadConvs();
// Insert the new conversation immediately so `selected` resolves to
// it on this render (switching to ChatView right away, before the
// async reloadConvs lands); reloadConvs then reconciles titles etc.
setConvs((prev) => (prev.some((x) => x.id === c.id) ? prev : [c, ...prev]));
setSelectedId(c.id);
reloadConvs();
}}
/>
)}
+38 -4
View File
@@ -39,6 +39,36 @@ function workerColor(name: string): string {
const chip = (worker: string) =>
"mt-0.5 shrink-0 rounded px-1 text-[9px] font-medium text-white " + workerColor(worker);
// useInView latches true when the ref'd element first comes within `rootMargin` of
// the enclosing scroll viewport. Blocks that always show their full body (user
// bubbles, answers) use it to defer fetching that body until they're about to be
// seen — so opening a long thread doesn't fire a detail request for every off-
// screen step. Observes the ScrollArea viewport (falls back to eager load when
// IntersectionObserver is unavailable, e.g. SSR).
function useInView(rootMargin = "400px"): [React.RefObject<HTMLDivElement | null>, boolean] {
const ref = React.useRef<HTMLDivElement | null>(null);
const [inView, setInView] = React.useState(false);
React.useEffect(() => {
if (inView) return; // latch: once seen, stop observing
const el = ref.current;
if (!el) return;
if (typeof IntersectionObserver === "undefined") {
setInView(true);
return;
}
const root = el.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null;
const ob = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) setInView(true);
},
{ root, rootMargin },
);
ob.observe(el);
return () => ob.disconnect();
}, [inView, rootMargin]);
return [ref, inView];
}
// A tool group pairs a tool_use with its matching tool_result (by tool_use_id);
// a run of consecutive conversational steps (text/thinking/result) from the same
// agent is one "message".
@@ -417,8 +447,10 @@ function MessageBlock({
// and shown in full (bubble is whitespace-pre-wrap, so long/multi-line text wraps).
function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean; getDetail: (seq: number) => Promise<string> }) {
const Icon = intent ? CrosshairIcon : UserIcon;
const [ref, inView] = useInView();
const [full, setFull] = React.useState<string | null>(null);
React.useEffect(() => {
if (!inView) return; // fetch the full message only when the bubble nears view
let live = true;
getDetail(step.seq)
.then((d) => {
@@ -430,9 +462,9 @@ function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean
return () => {
live = false;
};
}, [step.seq, getDetail, step.summary]);
}, [inView, step.seq, getDetail, step.summary]);
return (
<div className="mt-3 mb-2 flex justify-end gap-2">
<div ref={ref} className="mt-3 mb-2 flex justify-end gap-2">
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-lg rounded-tr-sm bg-primary px-3 py-1.5 text-sm text-primary-foreground">
{full ?? step.summary}
</div>
@@ -447,8 +479,10 @@ function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean
// collapsed. The summary is a truncated first line, so the full text is pulled
// from the detail and shown inline.
function AnswerBlock({ step, getDetail }: { step: Activity; getDetail: (seq: number) => Promise<string> }) {
const [ref, inView] = useInView();
const [full, setFull] = React.useState<string | null>(null);
React.useEffect(() => {
if (!inView) return; // fetch the full answer only when it nears view
let live = true;
getDetail(step.seq)
.then((d) => {
@@ -460,9 +494,9 @@ function AnswerBlock({ step, getDetail }: { step: Activity; getDetail: (seq: num
return () => {
live = false;
};
}, [step.seq, getDetail, step.summary]);
}, [inView, step.seq, getDetail, step.summary]);
return (
<div className="mb-2 mt-1 flex">
<div ref={ref} className="mb-2 mt-1 flex">
<div
className={
"min-w-0 flex-1 break-words rounded-lg bg-muted px-3 py-2 " +
+10
View File
@@ -275,10 +275,20 @@ export const api = {
updateConversationProfile: (id: number, llm_profile_id: number | null) =>
patch<{ ok: boolean }>(`/conversations/${id}/profile`, { llm_profile_id }),
deleteConversation: (id: number) => del<{ deleted: number }>(`/conversations/${id}`),
// Incremental tail: steps after `since` (id ASC) — live poll + post-send fetch.
conversationMessages: (id: number, since = 0) =>
get<{ items: Activity[]; cursor: number; running: boolean }>(`/conversations/${id}/messages?since=${since}`).then(
(r) => ({ items: arr(r.items), cursor: r.cursor ?? 0, running: !!r.running }),
),
// Reverse pagination: the latest `limit` steps (before=0) or the page of older
// steps ending before id `before`. hasMore = still-older steps exist.
conversationHistory: (id: number, before = 0, limit = 200) => {
const q = new URLSearchParams({ limit: String(limit) });
if (before > 0) q.set("before", String(before));
return get<{ items: Activity[]; cursor: number; running: boolean; hasMore: boolean }>(
`/conversations/${id}/messages?${q.toString()}`,
).then((r) => ({ items: arr(r.items), cursor: r.cursor ?? 0, running: !!r.running, hasMore: !!r.hasMore }));
},
conversationMsgDetail: (id: number, seq: number) =>
get<{ detail: string }>(`/conversations/${id}/messages/${seq}`).then((r) => r.detail ?? ""),
sendConversationMessage: (id: number, message: string) =>