diff --git a/db/conversation.go b/db/conversation.go
index 18f1a52..9e7e97a 100644
--- a/db/conversation.go
+++ b/db/conversation.go
@@ -174,6 +174,49 @@ FROM conversation_activities WHERE conversation_id=$1 AND id>$2 ORDER BY id LIMI
return out, cursor, rows.Err()
}
+// ConvActivityPage returns one page for reverse (newest-first) pagination: up to
+// `limit` steps ending before id `before` (exclusive; before<=0 = the latest
+// page), returned in ASCENDING id order. hasMore reports whether still-older steps
+// exist before the returned window, so the client can stop loading earlier history
+// on scroll-up. Summary-only columns (detail is lazy-loaded via ConvActivityDetail).
+func (d *DB) ConvActivityPage(convID, before int64, limit int) ([]Activity, bool, error) {
+ if limit <= 0 {
+ limit = 200
+ }
+ const cols = `id, COALESCE(worker,''), COALESCE(kind,''), COALESCE(tool,''), COALESCE(tool_use_id,''), is_error, COALESCE(summary,''), created_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens`
+ // fetch one extra row to detect whether older history remains before this window.
+ rows, err := d.Query(`SELECT `+cols+`
+FROM conversation_activities
+WHERE conversation_id=$1 AND ($2 <= 0 OR id < $2)
+ORDER BY id DESC LIMIT $3`, convID, before, limit+1)
+ if err != nil {
+ return nil, false, err
+ }
+ defer rows.Close()
+ desc := []Activity{}
+ for rows.Next() {
+ var a Activity
+ if err := rows.Scan(&a.ID, &a.Worker, &a.Kind, &a.Tool, &a.ToolUseID, &a.IsError, &a.Summary, &a.CreatedAt,
+ &a.InputTokens, &a.OutputTokens, &a.CacheReadTokens, &a.CacheWriteTokens); err != nil {
+ return nil, false, err
+ }
+ desc = append(desc, a)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, false, err
+ }
+ hasMore := len(desc) > limit
+ if hasMore {
+ desc = desc[:limit]
+ }
+ // reverse the newest-first window into ascending id order for display.
+ out := make([]Activity, len(desc))
+ for i, a := range desc {
+ out[len(desc)-1-i] = a
+ }
+ return out, hasMore, nil
+}
+
// ConvActivityDetail lazily returns the full detail blob for one step.
func (d *DB) ConvActivityDetail(convID, id int64) (string, error) {
var s sql.NullString
diff --git a/server/conversations.go b/server/conversations.go
index 5b9f480..dc2dba4 100644
--- a/server/conversations.go
+++ b/server/conversations.go
@@ -171,16 +171,39 @@ func (s *Server) pgConversationMessages(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
- since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
- items, cursor, err := pg.ConvActivityList(c.ID, since, 0)
+ q := r.URL.Query()
+ limit := atoiDefault(q.Get("limit"), 200)
+ s.chatMu.Lock()
+ running := s.chatBusy[s.convBusyKey(c.ID)]
+ s.chatMu.Unlock()
+
+ // Incremental tail: ?since=N returns steps after id N (ASC) — used by the live
+ // poll and the post-send fetch. A generous cap so a burst is never dropped.
+ if sv := q.Get("since"); sv != "" {
+ since, _ := strconv.ParseInt(sv, 10, 64)
+ items, cursor, err := pg.ConvActivityList(c.ID, since, max(limit, 1000))
+ if err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ writeJSON(w, 200, map[string]any{"items": activityDTOs(items), "cursor": cursor, "running": running, "hasMore": false})
+ return
+ }
+
+ // History page (reverse pagination): no ?since → the latest page; ?before=N →
+ // the page of older steps ending before id N. hasMore lets the client stop
+ // loading earlier history once the top of the thread is reached.
+ before, _ := strconv.ParseInt(q.Get("before"), 10, 64)
+ items, hasMore, err := pg.ConvActivityPage(c.ID, before, limit)
if err != nil {
writeErr(w, 500, err.Error())
return
}
- s.chatMu.Lock()
- running := s.chatBusy[s.convBusyKey(c.ID)]
- s.chatMu.Unlock()
- writeJSON(w, 200, map[string]any{"items": activityDTOs(items), "cursor": cursor, "running": running})
+ cursor := before
+ if n := len(items); n > 0 {
+ cursor = items[n-1].ID
+ }
+ writeJSON(w, 200, map[string]any{"items": activityDTOs(items), "cursor": cursor, "running": running, "hasMore": hasMore})
}
// pgConversationMsgDetail lazily returns one step's full detail blob.
diff --git a/web/src/app/(main)/chat/page.tsx b/web/src/app/(main)/chat/page.tsx
index 179e021..7f2826a 100644
--- a/web/src/app/(main)/chat/page.tsx
+++ b/web/src/app/(main)/chat/page.tsx
@@ -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}」对话
) : (
-