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:
1 parent
349b0d104d
commit
5000aadd8f
5 files changed
+207
-17
No files matched your search
@@ -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();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 " +
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user