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

+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 " +